(function(){ 'use strict'; // ===================== DOM ===================== const $ = s => document.querySelector(s); const viewport = $('#viewport'); const container = $('#canvasContainer'); const cMain = $('#canvasMain'); const cOverlay = $('#canvasOverlay'); const cUI = $('#canvasUI'); const ctxMain = cMain.getContext('2d'); const ctxOverlay = cOverlay.getContext('2d'); const ctxUI = cUI.getContext('2d'); const dropzone = $('#dropzone'); const fileInput = $('#fileInput'); const toolOptions = $('#toolOptions'); const textOverlay = $('#textOverlay'); const textArea = $('#textArea'); const layerPanel = $('#layerPanel'); const layerList = $('#layerList'); // ===================== STATE ===================== let img = null; let fileName = 'image'; let hasTransparency = false; let canvasW = 0, canvasH = 0; // Zoom/Pan let zoom = 1; let panX = 0, panY = 0; let isPanning = false; let panStart = {x:0,y:0}; let spaceDown = false; // Current tool let currentTool = null; // History (sync ImageData-based) const MAX_HISTORY = 15; let history = []; let historyIndex = -1; // Drawing state let isDrawing = false; let drawStart = {x:0,y:0}; let lastDraw = {x:0,y:0}; // Tool settings let brushSize = 8; let brushOpacity = 100; let strokeWidth = 2; let fillShape = false; let fgColor = '#0054e6'; // Crop state let cropRect = null; let cropDragging = null; let cropStart = null; let cropAspect = 0; // Internal clipboard for selection copy/paste let internalClipboard = null; // { imageData, x, y, w, h } // Layer move state let layerMoving = false; let layerMoveStart = null; let layerMoveSnapshot = null; // Text state let textFont = 'sans-serif'; let textSize = 32; let textBold = false; let textItalic = false; // Adjustments let adjBrightness = 0; let adjContrast = 0; let adjSaturation = 0; // Resize state let resizeW = 0, resizeH = 0, resizeKeepRatio = true, resizeOrigW = 0, resizeOrigH = 0; // Clone stamp state let cloneSrc = null; let cloneOffset = null; let cloneSrcSet = false; // Fill tool let fillTolerance = 30; // Blur / Sharpen let blurRadius = 5; let sharpenAmount = 50; // Selection state let selRect = null; let selPath = null; // lasso path: [{x,y}, ...] let selDragging = false; let selDragMode = null; let selStart = {x:0, y:0}; let selStartRect = null; // Marching ants animation let marchRaf = 0; let marchOffset = 0; // Pending shape state let pendingShape = null; let pendingDragging = false; let pendingDragStart = null; let pendingTransformHandle = null; let pendingTransformStart = null; // Text layer re-editing let editingTextLayerIndex = -1; // Tabs let tabs = []; let activeTabId = 0; let tabIdCounter = 0; // Layer system const MAX_LAYERS = 10; let layers = []; let activeLayerIndex = 0; let layerIdCounter = 0; let layerPanelVisible = false; let compositeDirtyRaf = 0; // ===================== HELPERS ===================== function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } function canvasToViewport(cx, cy) { return { x: cx * zoom + panX, y: cy * zoom + panY }; } function viewportToCanvas(vx, vy) { return { x: (vx - panX) / zoom, y: (vy - panY) / zoom }; } function getPointerCanvas(e) { const rect = viewport.getBoundingClientRect(); const vx = e.clientX - rect.left; const vy = e.clientY - rect.top; return viewportToCanvas(vx, vy); } function updateTransform() { container.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`; $('#zoomLabel').textContent = Math.round(zoom * 100) + '%'; $('#statusZoom').textContent = Math.round(zoom * 100) + '%'; redrawUI(); } function redrawUI() { clearUI(); drawRulers(); drawImageBorder(); if (selPath && selPath.length > 2) drawLassoOutline(); else if (selRect && selRect.w > 5) drawSelectionRect(false); else if (cropRect) drawCropOverlay(false); if (pendingShape) drawPendingBBoxOnUI(); } function fitToViewport() { if (!canvasW || !canvasH) return; const vw = viewport.clientWidth; const vh = viewport.clientHeight; const scaleX = (vw - 40) / canvasW; const scaleY = (vh - 40) / canvasH; zoom = Math.min(scaleX, scaleY, 1); panX = (vw - canvasW * zoom) / 2; panY = (vh - canvasH * zoom) / 2; updateTransform(); } function setCanvasSize(w, h) { canvasW = w; canvasH = h; [cMain, cOverlay].forEach(c => { c.width = w; c.height = h; }); container.style.width = w + 'px'; container.style.height = h + 'px'; selRect = null; selPath = null; stopMarchingAnts(); pendingShape = null; $('#statusDims').textContent = w + ' x ' + h; resizeUICanvas(); } function resizeUICanvas() { const dpr = window.devicePixelRatio || 1; const vw = viewport.clientWidth; const vh = viewport.clientHeight; cUI.width = vw * dpr; cUI.height = vh * dpr; cUI.style.width = vw + 'px'; cUI.style.height = vh + 'px'; ctxUI.setTransform(dpr, 0, 0, dpr, 0, 0); } // Convert canvas coords to screen (viewport-local) coords for UI drawing function c2s(cx, cy) { return { x: cx * zoom + panX, y: cy * zoom + panY }; } // ===================== TOOL DISPATCHER ===================== class ToolDispatcher { constructor() { this._tools = new Map(); // toolName → {onDown, onMove, onUp} this._pendingHandler = null; this._selectionHandler = null; } registerTool(name, handler) { this._tools.set(name, handler); } registerPending(handler) { this._pendingHandler = handler; } registerSelection(handler) { this._selectionHandler = handler; } _dispatch(type, e) { if (!canvasW) return false; // pendingShape перехватывает первым if (pendingShape && this._pendingHandler && this._pendingHandler[type]) { this._pendingHandler[type](e); return true; } // нет инструмента → selection if (!currentTool && this._selectionHandler && this._selectionHandler[type]) { this._selectionHandler[type](e); return true; } // инструмент const h = this._tools.get(currentTool); if (h && h[type]) { h[type](e); return true; } return false; } onDown(e) { this._dispatch('onDown', e); } onMove(e) { this._dispatch('onMove', e); } onUp(e) { this._dispatch('onUp', e); } } const dispatcher = new ToolDispatcher(); // ===================== LAYER SYSTEM ===================== function createLayer(name, w, h) { if (layers.length >= MAX_LAYERS) return null; const c = document.createElement('canvas'); c.width = w || canvasW; c.height = h || canvasH; const layer = { id: ++layerIdCounter, name: name || ('Слой ' + layerIdCounter), canvas: c, ctx: c.getContext('2d'), visible: true, opacity: 1, blendMode: 'source-over' }; return layer; } function getActiveLayer() { return layers[activeLayerIndex]; } function activeCtx() { return layers[activeLayerIndex].ctx; } function activeCanvas() { return layers[activeLayerIndex].canvas; } function compositeLayersToMain() { ctxMain.clearRect(0, 0, canvasW, canvasH); for (const layer of layers) { if (!layer.visible || layer.opacity === 0) continue; ctxMain.save(); ctxMain.globalAlpha = layer.opacity; ctxMain.globalCompositeOperation = layer.blendMode; ctxMain.drawImage(layer.canvas, 0, 0); ctxMain.restore(); } } function markCompositeDirty() { if (compositeDirtyRaf) return; compositeDirtyRaf = requestAnimationFrame(() => { compositeDirtyRaf = 0; compositeLayersToMain(); }); } function initLayers(w, h, image) { layers = []; layerIdCounter = 0; activeLayerIndex = 0; const layer = createLayer('Фон', w, h); if (image) layer.ctx.drawImage(image, 0, 0, w, h); layers.push(layer); compositeLayersToMain(); renderLayerPanel(); } function addLayer(name) { if (layers.length >= MAX_LAYERS) return; const layer = createLayer(name || ('Слой ' + (layerIdCounter + 1)), canvasW, canvasH); layers.splice(activeLayerIndex + 1, 0, layer); activeLayerIndex++; compositeLayersToMain(); renderLayerPanel(); pushHistory(); } function removeLayer(index) { if (layers.length <= 1) return; layers.splice(index, 1); if (activeLayerIndex >= layers.length) activeLayerIndex = layers.length - 1; compositeLayersToMain(); renderLayerPanel(); pushHistory(); } function mergeDown(index) { if (index <= 0 || index >= layers.length) return; const upper = layers[index]; const lower = layers[index - 1]; lower.ctx.save(); lower.ctx.globalAlpha = upper.opacity; lower.ctx.globalCompositeOperation = upper.blendMode; lower.ctx.drawImage(upper.canvas, 0, 0); lower.ctx.restore(); layers.splice(index, 1); if (activeLayerIndex >= index) activeLayerIndex = Math.max(0, activeLayerIndex - 1); compositeLayersToMain(); renderLayerPanel(); pushHistory(); } function flattenLayers() { if (layers.length <= 1) return; compositeLayersToMain(); const flat = createLayer('Фон', canvasW, canvasH); flat.ctx.drawImage(cMain, 0, 0); layers = [flat]; activeLayerIndex = 0; compositeLayersToMain(); renderLayerPanel(); pushHistory(); } function moveLayer(fromIndex, toIndex) { if (fromIndex === toIndex || toIndex < 0 || toIndex >= layers.length) return; const layer = layers.splice(fromIndex, 1)[0]; layers.splice(toIndex, 0, layer); if (activeLayerIndex === fromIndex) { activeLayerIndex = toIndex; } else { if (fromIndex < activeLayerIndex && toIndex >= activeLayerIndex) activeLayerIndex--; else if (fromIndex > activeLayerIndex && toIndex <= activeLayerIndex) activeLayerIndex++; } compositeLayersToMain(); renderLayerPanel(); pushHistory(); } function updateStatusLayer() { const el = $('#statusLayer'); if (layers.length > 0) { el.textContent = layers[activeLayerIndex].name + ' (' + layers.length + ')'; } else { el.textContent = ''; } } // ===================== HISTORY (sync ImageData) ===================== // Refactoring B: replaced async toBlob/URL.createObjectURL with sync getImageData/putImageData. // freeHistoryEntry() removed — no blob URLs to revoke. function pushHistory() { while (history.length > historyIndex + 1) history.pop(); if (history.length >= MAX_HISTORY) { history.shift(); historyIndex--; } const snap = { layers: layers.map(layer => ({ imageData: layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height), id: layer.id, name: layer.name, visible: layer.visible, opacity: layer.opacity, blendMode: layer.blendMode })), activeIndex: activeLayerIndex, w: canvasW, h: canvasH }; history.push(snap); historyIndex = history.length - 1; updateHistoryButtons(); } function restoreHistory(index) { const entry = history[index]; if (!entry) return; setCanvasSize(entry.w, entry.h); layers = []; activeLayerIndex = entry.activeIndex; entry.layers.forEach(snap => { const layer = createLayer(snap.name, entry.w, entry.h); layer.id = snap.id; layer.visible = snap.visible; layer.opacity = snap.opacity; layer.blendMode = snap.blendMode; layer.ctx.putImageData(snap.imageData, 0, 0); layers.push(layer); }); compositeLayersToMain(); clearOverlay(); redrawUI(); renderLayerPanel(); updateStatusLayer(); } function undo() { if (historyIndex <= 0) return; historyIndex--; restoreHistory(historyIndex); updateHistoryButtons(); } function redo() { if (historyIndex >= history.length - 1) return; historyIndex++; restoreHistory(historyIndex); updateHistoryButtons(); } function updateHistoryButtons() { $('#btnUndo').disabled = historyIndex <= 0; $('#btnRedo').disabled = historyIndex >= history.length - 1; if (history.length > 0) { $('#statusHistory').textContent = (historyIndex + 1) + '/' + history.length; } } // ===================== CLEAR ===================== function clearOverlay() { ctxOverlay.clearRect(0, 0, canvasW, canvasH); } function clearUI() { ctxUI.save(); ctxUI.setTransform(1,0,0,1,0,0); ctxUI.clearRect(0, 0, cUI.width, cUI.height); ctxUI.restore(); } // ===================== LOAD IMAGE ===================== function _applyImageToCurrentTab(im, name, transparency) { img = im; hasTransparency = transparency; container.classList.toggle('has-transparency', transparency); fileName = name; const w = Math.min(im.width, 16384); const h = Math.min(im.height, 16384); setCanvasSize(w, h); initLayers(w, h, im); // Refactoring B: removed freeHistoryEntry loop — no blob URLs to revoke history = []; historyIndex = -1; pushHistory(); fitToViewport(); enableTools(); dropzone.classList.add('hidden'); cancelCurrentTool(); const tab = tabs.find(t => t.id === activeTabId); if (tab) { tab.name = name; tab.hasImage = true; } renderTabBar(); if (!layerPanelVisible) { layerPanelVisible = true; layerPanel.classList.add('visible'); $('#btnLayers').classList.add('active'); renderLayerPanel(); } } function loadImage(file) { if (!file || !file.type.startsWith('image/')) return; const name = file.name.replace(/\.[^.]+$/, '') || 'image'; const transparency = file.type === 'image/png' || file.type === 'image/webp'; if (canvasW > 0) { saveCurrentTabState(); const newTab = createTab(name); activeTabId = newTab.id; layers = []; history = []; historyIndex = -1; canvasW = 0; canvasH = 0; selRect = null; } const reader = new FileReader(); reader.onload = e => { const im = new Image(); im.onload = () => _applyImageToCurrentTab(im, name, transparency); im.src = e.target.result; }; reader.readAsDataURL(file); } function loadImageFromDataURL(dataUrl) { const name = 'pasted'; if (canvasW > 0) { saveCurrentTabState(); const newTab = createTab(name); activeTabId = newTab.id; layers = []; history = []; historyIndex = -1; canvasW = 0; canvasH = 0; selRect = null; } const im = new Image(); im.onload = () => _applyImageToCurrentTab(im, name, true); im.src = dataUrl; } function enableTools() { document.querySelectorAll('.tb[disabled]').forEach(b => b.disabled = false); document.querySelectorAll('#btnExport,#btnRotCCW,#btnRotCW,#btnFlipH,#btnFlipV,#btnResize,#btnAdjust,#btnBlur,#btnSharpen').forEach(b => b.disabled = false); document.querySelectorAll('#filtersGroup .tb').forEach(b => b.disabled = false); updateHistoryButtons(); } function disableTools() { document.querySelectorAll('.tb[data-tool]').forEach(b => b.disabled = true); document.querySelectorAll('#btnExport,#btnRotCCW,#btnRotCW,#btnFlipH,#btnFlipV,#btnResize,#btnAdjust,#btnBlur,#btnSharpen').forEach(b => b.disabled = true); document.querySelectorAll('#filtersGroup .tb').forEach(b => b.disabled = true); updateHistoryButtons(); } // ===================== TABS ===================== function createTab(name) { const id = ++tabIdCounter; const tab = { id, name: name || 'Новый', layers: null, activeLayerIndex: 0, layerIdCounter: 0, history: [], historyIndex: -1, canvasW: 0, canvasH: 0, zoom: 1, panX: 0, panY: 0, selRect: null, selPath: null, fileName: name || 'image', hasTransparency: false, hasImage: false, }; tabs.push(tab); return tab; } function saveCurrentTabState() { const tab = tabs.find(t => t.id === activeTabId); if (!tab) return; tab.layers = layers; tab.activeLayerIndex = activeLayerIndex; tab.layerIdCounter = layerIdCounter; tab.history = history; tab.historyIndex = historyIndex; tab.canvasW = canvasW; tab.canvasH = canvasH; tab.zoom = zoom; tab.panX = panX; tab.panY = panY; tab.selRect = selRect; tab.selPath = selPath; tab.fileName = fileName; tab.hasTransparency = hasTransparency; tab.hasImage = canvasW > 0; } function loadTabState(tab) { if (pendingShape) cancelPendingShape(); cancelCurrentTool(); hideTextOverlay(); activeTabId = tab.id; if (!tab.hasImage || !tab.layers || !tab.layers.length) { layers = []; activeLayerIndex = 0; layerIdCounter = 0; history = []; historyIndex = -1; canvasW = 0; canvasH = 0; zoom = 1; panX = 0; panY = 0; selRect = null; fileName = 'image'; hasTransparency = false; container.classList.remove('has-transparency'); [cMain, cOverlay].forEach(c => { c.width = 1; c.height = 1; }); container.style.width = '1px'; container.style.height = '1px'; container.style.transform = 'translate(0px,0px) scale(1)'; $('#zoomLabel').textContent = '100%'; $('#statusZoom').textContent = '100%'; $('#statusDims').textContent = 'Нет изображения'; disableTools(); dropzone.classList.remove('hidden'); clearOverlay(); clearUI(); renderLayerPanel(); renderTabBar(); return; } layers = tab.layers; activeLayerIndex = tab.activeLayerIndex; layerIdCounter = tab.layerIdCounter || 0; history = tab.history; historyIndex = tab.historyIndex; canvasW = tab.canvasW; canvasH = tab.canvasH; zoom = tab.zoom; panX = tab.panX; panY = tab.panY; selRect = tab.selRect || null; selPath = tab.selPath || null; if (selPath && selPath.length > 2) startMarchingAnts(); else stopMarchingAnts(); fileName = tab.fileName; hasTransparency = tab.hasTransparency; container.classList.toggle('has-transparency', !!hasTransparency); [cMain, cOverlay].forEach(c => { c.width = canvasW; c.height = canvasH; }); container.style.width = canvasW + 'px'; container.style.height = canvasH + 'px'; $('#statusDims').textContent = canvasW + ' x ' + canvasH; compositeLayersToMain(); enableTools(); updateTransform(); dropzone.classList.add('hidden'); renderLayerPanel(); renderTabBar(); } function switchTab(tabId) { if (tabId === activeTabId) return; saveCurrentTabState(); const tab = tabs.find(t => t.id === tabId); if (tab) loadTabState(tab); } function closeTab(tabId) { const idx = tabs.findIndex(t => t.id === tabId); if (idx === -1) return; if (tabs.length === 1) { // Last tab: reset to empty // Refactoring B: removed freeHistoryEntry calls — ImageData snapshots are GC'd automatically saveCurrentTabState(); const tab = tabs[0]; tab.layers = null; tab.history = []; tab.historyIndex = -1; tab.canvasW = 0; tab.canvasH = 0; tab.hasImage = false; tab.name = 'Новый'; loadTabState(tab); return; } if (tabId !== activeTabId) { // Refactoring B: removed freeHistoryEntry — no blob URLs to revoke tabs.splice(idx, 1); renderTabBar(); } else { // Closing active tab: switch first, then remove old const newIdx = idx > 0 ? idx - 1 : 1; const newTab = tabs[newIdx]; // Refactoring B: removed freeHistoryEntry loop — ImageData GC'd automatically history = []; historyIndex = -1; layers = []; tabs.splice(idx, 1); loadTabState(newTab); } } function renderTabBar() { const bar = $('#tabBar'); bar.innerHTML = ''; tabs.forEach(tab => { const item = document.createElement('div'); item.className = 'tab-item' + (tab.id === activeTabId ? ' active' : ''); item.dataset.tabId = tab.id; const name = document.createElement('span'); name.className = 'tab-name'; name.textContent = tab.name; const close = document.createElement('button'); close.className = 'tab-close'; close.innerHTML = '×'; close.title = 'Закрыть вкладку'; close.addEventListener('click', e => { e.stopPropagation(); closeTab(tab.id); }); item.appendChild(name); item.appendChild(close); item.addEventListener('click', () => switchTab(tab.id)); bar.appendChild(item); }); const addBtn = document.createElement('button'); addBtn.className = 'tab-add'; addBtn.title = 'Новая вкладка (Ctrl+T)'; addBtn.innerHTML = ''; addBtn.addEventListener('click', () => { saveCurrentTabState(); const tab = createTab('Новый'); activeTabId = tab.id; loadTabState(tab); }); bar.appendChild(addBtn); const activeItem = bar.querySelector('.tab-item.active'); if (activeItem) activeItem.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } // ===================== DROP ZONE ===================== dropzone.addEventListener('click', () => fileInput.click()); dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('drag-over'); }); dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over')); dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('drag-over'); const f = e.dataTransfer.files[0]; if (f) loadImage(f); }); viewport.addEventListener('dragover', e => e.preventDefault()); viewport.addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) loadImage(f); }); fileInput.addEventListener('change', e => { if (e.target.files[0]) loadImage(e.target.files[0]); e.target.value = ''; }); document.addEventListener('paste', e => { const items = e.clipboardData.items; for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault(); loadImage(item.getAsFile()); return; } } }); $('#btnOpen').addEventListener('click', () => fileInput.click()); // ===================== ZOOM / PAN ===================== viewport.addEventListener('wheel', e => { if (!canvasW) return; e.preventDefault(); const rect = viewport.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const oldZoom = zoom; const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; zoom = clamp(zoom * factor, 0.05, 32); panX = mx - (mx - panX) * (zoom / oldZoom); panY = my - (my - panY) * (zoom / oldZoom); updateTransform(); }, { passive: false }); $('#btnZoomIn').addEventListener('click', () => { zoomCenter(1.25); }); $('#btnZoomOut').addEventListener('click', () => { zoomCenter(1/1.25); }); $('#btnFit').addEventListener('click', fitToViewport); $('#btnUndo').addEventListener('click', () => { if (pendingShape) cancelPendingShape(); else undo(); }); $('#btnRedo').addEventListener('click', redo); function zoomCenter(factor) { if (!canvasW) return; const vw = viewport.clientWidth; const vh = viewport.clientHeight; const mx = vw / 2, my = vh / 2; const oldZoom = zoom; zoom = clamp(zoom * factor, 0.05, 32); panX = mx - (mx - panX) * (zoom / oldZoom); panY = my - (my - panY) * (zoom / oldZoom); updateTransform(); } // Pan: capture phase — space+drag or middle mouse always wins viewport.addEventListener('pointerdown', e => { if (!canvasW) return; if (spaceDown || e.button === 1) { isPanning = true; panStart = { x: e.clientX - panX, y: e.clientY - panY }; viewport.setPointerCapture(e.pointerId); viewport.style.cursor = 'grabbing'; container.style.cursor = 'grabbing'; e.preventDefault(); e.stopPropagation(); return; } }, true); // Normal pointerdown — tools, selection, auto-pan outside image viewport.addEventListener('pointerdown', e => { if (!canvasW || isPanning) return; if (e.button === 0 && !currentTool && !pendingShape) { const p = getPointerCanvas(e); const outsideImage = p.x < 0 || p.y < 0 || p.x > canvasW || p.y > canvasH; if (outsideImage) { isPanning = true; panStart = { x: e.clientX - panX, y: e.clientY - panY }; viewport.setPointerCapture(e.pointerId); viewport.style.cursor = 'grabbing'; e.preventDefault(); return; } } dispatcher.onDown(e); }); viewport.addEventListener('pointermove', e => { if (!canvasW) return; if (isPanning) { panX = e.clientX - panStart.x; panY = e.clientY - panStart.y; updateTransform(); return; } const p = getPointerCanvas(e); $('#statusCursor').textContent = Math.round(p.x) + ', ' + Math.round(p.y); dispatcher.onMove(e); }); viewport.addEventListener('pointerup', e => { if (isPanning) { isPanning = false; viewport.releasePointerCapture(e.pointerId); const cur = spaceDown ? 'grab' : (currentTool ? 'crosshair' : 'default'); viewport.style.cursor = cur; container.style.cursor = cur; return; } dispatcher.onUp(e); }); // ===================== TOOL SELECTION ===================== function setTool(name) { if (name === 'select') { if (pendingShape) commitPendingShape(); if (currentTool === 'select') { cancelCurrentTool(); return; } cancelCurrentTool(); currentTool = 'select'; document.querySelectorAll('#toolsGroup .tb').forEach(b => { b.classList.toggle('active', b.dataset.tool === 'select'); }); toolOptions.innerHTML = ``; toolOptions.classList.add('visible'); viewport.style.cursor = 'move'; container.style.cursor = 'move'; return; } if (currentTool === name) { cancelCurrentTool(); return; } if (pendingShape) commitPendingShape(); cancelCurrentTool(); currentTool = name; document.querySelectorAll('#toolsGroup .tb').forEach(b => { b.classList.toggle('active', b.dataset.tool === name); }); $('#btnAdjust').classList.remove('active'); showToolOptions(name); viewport.style.cursor = 'crosshair'; container.style.cursor = 'crosshair'; } function cancelCurrentTool() { currentTool = null; document.querySelectorAll('#toolsGroup .tb').forEach(b => { b.classList.toggle('active', b.dataset.tool === 'select'); }); $('#btnAdjust').classList.remove('active'); $('#btnResize').classList.remove('active'); $('#btnBlur').classList.remove('active'); $('#btnSharpen').classList.remove('active'); cMain.style.opacity = '1'; cOverlay.style.filter = ''; toolOptions.classList.remove('visible'); toolOptions.innerHTML = ''; clearOverlay(); cropRect = null; selDragging = false; hideTextOverlay(); adjBrightness = 0; adjContrast = 0; adjSaturation = 0; cOverlay.style.filter = ''; redrawUI(); if (selPath && selPath.length > 2) { showLassoOptions(); } else if (selRect && selRect.w > 5 && selRect.h > 5) { showSelectionOptions(); } viewport.style.cursor = 'default'; container.style.cursor = 'default'; } document.querySelectorAll('#toolsGroup .tb').forEach(btn => { btn.addEventListener('click', () => { if (btn.disabled) return; setTool(btn.dataset.tool); }); }); // ===================== TOOL OPTIONS ===================== // Refactoring D: helper to build a labelled range row, eliminates repeated slider HTML function buildRangeRow(id, label, min, max, value) { return `${value}`; } function showToolOptions(tool) { toolOptions.innerHTML = ''; toolOptions.classList.add('visible'); if (tool === 'brush' || tool === 'eraser') { toolOptions.innerHTML = ` ${buildRangeRow('optSize', 'Размер', 1, 100, brushSize)}
${buildRangeRow('optOpacity', 'Прозр.', 1, 100, brushOpacity)} `; $('#optSize').oninput = e => { brushSize = +e.target.value; $('#optSizeVal').textContent = brushSize; }; $('#optOpacity').oninput = e => { brushOpacity = +e.target.value; $('#optOpacityVal').textContent = brushOpacity + '%'; }; } else if (tool === 'line' || tool === 'arrow') { toolOptions.innerHTML = ` ${buildRangeRow('optStroke', 'Толщина', 1, 20, strokeWidth)} `; $('#optStroke').oninput = e => { strokeWidth = +e.target.value; $('#optStrokeVal').textContent = strokeWidth; }; } else if (tool === 'rect' || tool === 'circle') { toolOptions.innerHTML = ` ${buildRangeRow('optStroke', 'Толщина', 1, 20, strokeWidth)}
`; $('#optStroke').oninput = e => { strokeWidth = +e.target.value; $('#optStrokeVal').textContent = strokeWidth; }; $('#optFill').onclick = () => { fillShape = !fillShape; $('#optFill').classList.toggle('on', fillShape); }; } else if (tool === 'crop') { toolOptions.innerHTML = `
`; cropAspect = 0; initCrop(); $('#optCropRatio').onchange = e => { cropAspect = +e.target.value; if (cropAspect > 0 && cropRect) { cropRect.h = cropRect.w / cropAspect; drawCropOverlay(); } }; $('#optCropApply').onclick = applyCrop; $('#optCropCancel').onclick = () => cancelCurrentTool(); } else if (tool === 'text') { toolOptions.innerHTML = `
`; const fontSel = $('#optFont'); fontSel.value = textFont; fontSel.onchange = e => textFont = e.target.value; $('#optTextSize').onchange = e => textSize = clamp(+e.target.value, 8, 200); $('#optBold').onclick = () => { textBold = !textBold; $('#optBold').classList.toggle('on', textBold); }; $('#optItalic').onclick = () => { textItalic = !textItalic; $('#optItalic').classList.toggle('on', textItalic); }; } else if (tool === 'eyedropper') { toolOptions.innerHTML = ``; } else if (tool === 'fill') { toolOptions.innerHTML = ` ${buildRangeRow('optTolerance', 'Допуск', 0, 255, fillTolerance)} `; $('#optTolerance').oninput = e => { fillTolerance = +e.target.value; $('#optToleranceVal').textContent = fillTolerance; }; } else if (tool === 'clone') { toolOptions.innerHTML = ` ${buildRangeRow('optSize', 'Размер', 1, 100, brushSize)}
${buildRangeRow('optOpacity', 'Прозр.', 1, 100, brushOpacity)}
`; $('#optSize').oninput = e => { brushSize = +e.target.value; $('#optSizeVal').textContent = brushSize; }; $('#optOpacity').oninput = e => { brushOpacity = +e.target.value; $('#optOpacityVal').textContent = brushOpacity + '%'; }; } else { toolOptions.classList.remove('visible'); } } // ===================== ADJUST ===================== $('#btnAdjust').addEventListener('click', () => { if ($('#btnAdjust').classList.contains('active')) { cancelCurrentTool(); return; } cancelCurrentTool(); currentTool = 'adjust'; $('#btnAdjust').classList.add('active'); toolOptions.classList.add('visible'); toolOptions.innerHTML = ` 0
0
0
`; const update = () => { const b = 1 + adjBrightness / 100; const c = 1 + adjContrast / 100; const s = 1 + adjSaturation / 100; cOverlay.style.filter = `brightness(${b}) contrast(${c}) saturate(${s})`; ctxOverlay.clearRect(0, 0, canvasW, canvasH); ctxOverlay.drawImage(cMain, 0, 0); cMain.style.opacity = '0'; }; $('#optBright').oninput = e => { adjBrightness = +e.target.value; $('#optBrightVal').textContent = adjBrightness; update(); }; $('#optContrast').oninput = e => { adjContrast = +e.target.value; $('#optContrastVal').textContent = adjContrast; update(); }; $('#optSatur').oninput = e => { adjSaturation = +e.target.value; $('#optSaturVal').textContent = adjSaturation; update(); }; $('#optAdjApply').onclick = applyAdjustments; $('#optAdjCancel').onclick = () => { cMain.style.opacity = '1'; cOverlay.style.filter = ''; clearOverlay(); cancelCurrentTool(); }; }); function applyAdjustments() { if (adjBrightness === 0 && adjContrast === 0 && adjSaturation === 0) { cMain.style.opacity = '1'; cOverlay.style.filter = ''; clearOverlay(); cancelCurrentTool(); return; } const _ctx = activeCtx(); const { sx, sy, sw, sh } = getSelectionBBox(); const imageData = _ctx.getImageData(sx, sy, sw, sh); const d = imageData.data; const br = adjBrightness / 100; const co = adjContrast / 100; const sa = adjSaturation / 100; const contrastFactor = (1 + co) * (1 + co); for (let i = 0; i < d.length; i += 4) { let r = d[i], g = d[i+1], b = d[i+2]; r += 255 * br; g += 255 * br; b += 255 * br; r = ((r / 255 - 0.5) * contrastFactor + 0.5) * 255; g = ((g / 255 - 0.5) * contrastFactor + 0.5) * 255; b = ((b / 255 - 0.5) * contrastFactor + 0.5) * 255; const gray = 0.2126 * r + 0.7152 * g + 0.0722 * b; r = gray + (r - gray) * (1 + sa); g = gray + (g - gray) * (1 + sa); b = gray + (b - gray) * (1 + sa); d[i] = clamp(Math.round(r), 0, 255); d[i+1] = clamp(Math.round(g), 0, 255); d[i+2] = clamp(Math.round(b), 0, 255); } putFilteredRegion(_ctx, imageData, sx, sy, sw, sh); cMain.style.opacity = '1'; cOverlay.style.filter = ''; clearOverlay(); compositeLayersToMain(); pushHistory(); cancelCurrentTool(); } // ===================== BLUR ===================== $('#btnBlur').addEventListener('click', () => { if ($('#btnBlur').classList.contains('active')) { cancelCurrentTool(); return; } cancelCurrentTool(); currentTool = 'blur'; $('#btnBlur').classList.add('active'); toolOptions.classList.add('visible'); toolOptions.innerHTML = ` ${blurRadius}
`; const updateBlur = () => { ctxOverlay.clearRect(0, 0, canvasW, canvasH); ctxOverlay.drawImage(cMain, 0, 0); cOverlay.style.filter = `blur(${blurRadius}px)`; cMain.style.opacity = '0'; }; updateBlur(); $('#optBlurR').oninput = e => { blurRadius = +e.target.value; $('#optBlurRVal').textContent = blurRadius; updateBlur(); }; $('#optBlurApply').onclick = applyBlur; $('#optBlurCancel').onclick = () => { cMain.style.opacity = '1'; cOverlay.style.filter = ''; clearOverlay(); cancelCurrentTool(); }; }); function applyBlur() { if (blurRadius === 0) { cMain.style.opacity = '1'; cOverlay.style.filter = ''; clearOverlay(); cancelCurrentTool(); return; } const _c = activeCanvas(); const hasSel = hasActiveSelection(); if (hasSel) { const { sx, sy, sw, sh } = getSelectionBBox(); const tmp = document.createElement('canvas'); tmp.width = sw; tmp.height = sh; const tctx = tmp.getContext('2d'); tctx.filter = `blur(${blurRadius}px)`; tctx.drawImage(_c, sx, sy, sw, sh, 0, 0, sw, sh); const ac = activeCtx(); ac.save(); ac.beginPath(); if (selPath && selPath.length > 2) { selPath.forEach((p, i) => i === 0 ? ac.moveTo(p.x, p.y) : ac.lineTo(p.x, p.y)); ac.closePath(); } else { ac.rect(sx, sy, sw, sh); } ac.clip(); ac.clearRect(sx, sy, sw, sh); ac.drawImage(tmp, sx, sy); ac.restore(); } else { const tmp = document.createElement('canvas'); tmp.width = canvasW; tmp.height = canvasH; const tctx = tmp.getContext('2d'); tctx.filter = `blur(${blurRadius}px)`; tctx.drawImage(_c, 0, 0); activeCtx().clearRect(0, 0, canvasW, canvasH); activeCtx().drawImage(tmp, 0, 0); } cMain.style.opacity = '1'; cOverlay.style.filter = ''; clearOverlay(); compositeLayersToMain(); pushHistory(); cancelCurrentTool(); } // ===================== SHARPEN ===================== $('#btnSharpen').addEventListener('click', () => { if ($('#btnSharpen').classList.contains('active')) { cancelCurrentTool(); return; } cancelCurrentTool(); currentTool = 'sharpen'; $('#btnSharpen').classList.add('active'); toolOptions.classList.add('visible'); toolOptions.innerHTML = ` ${sharpenAmount}
`; previewSharpen(); $('#optSharpAmt').oninput = e => { sharpenAmount = +e.target.value; $('#optSharpAmtVal').textContent = sharpenAmount; previewSharpen(); }; $('#optSharpApply').onclick = applySharpen; $('#optSharpCancel').onclick = () => { cMain.style.opacity = '1'; clearOverlay(); cancelCurrentTool(); }; }); function previewSharpen() { ctxOverlay.clearRect(0, 0, canvasW, canvasH); const tmp = document.createElement('canvas'); tmp.width = canvasW; tmp.height = canvasH; const tctx = tmp.getContext('2d'); tctx.drawImage(cMain, 0, 0); sharpenCanvas(tctx, canvasW, canvasH, sharpenAmount / 100); ctxOverlay.drawImage(tmp, 0, 0); cMain.style.opacity = '0'; } function sharpenCanvas(ctx, w, h, amount) { const src = ctx.getImageData(0, 0, w, h); const dst = ctx.createImageData(w, h); const s = src.data, d = dst.data; const mix = amount; for (let y = 1; y < h - 1; y++) { for (let x = 1; x < w - 1; x++) { const i = (y * w + x) * 4; for (let c = 0; c < 3; c++) { const val = s[i+c] * (1 + 4*mix) - s[((y-1)*w+x)*4+c] * mix - s[((y+1)*w+x)*4+c] * mix - s[(y*w+x-1)*4+c] * mix - s[(y*w+x+1)*4+c] * mix; d[i+c] = clamp(Math.round(val), 0, 255); } d[i+3] = s[i+3]; } } for (let x = 0; x < w; x++) { for (let c = 0; c < 4; c++) { d[x*4+c] = s[x*4+c]; d[((h-1)*w+x)*4+c] = s[((h-1)*w+x)*4+c]; } } for (let y = 0; y < h; y++) { for (let c = 0; c < 4; c++) { d[(y*w)*4+c] = s[(y*w)*4+c]; d[(y*w+w-1)*4+c] = s[(y*w+w-1)*4+c]; } } ctx.putImageData(dst, 0, 0); } function applySharpen() { const hasSel = hasActiveSelection(); if (hasSel) { const { sx, sy, sw, sh } = getSelectionBBox(); const tmp = document.createElement('canvas'); tmp.width = sw; tmp.height = sh; const tctx = tmp.getContext('2d'); tctx.drawImage(activeCanvas(), sx, sy, sw, sh, 0, 0, sw, sh); sharpenCanvas(tctx, sw, sh, sharpenAmount / 100); const ac = activeCtx(); ac.save(); ac.beginPath(); if (selPath && selPath.length > 2) { selPath.forEach((p, i) => i === 0 ? ac.moveTo(p.x, p.y) : ac.lineTo(p.x, p.y)); ac.closePath(); } else { ac.rect(sx, sy, sw, sh); } ac.clip(); ac.clearRect(sx, sy, sw, sh); ac.drawImage(tmp, sx, sy); ac.restore(); } else { sharpenCanvas(activeCtx(), canvasW, canvasH, sharpenAmount / 100); } cMain.style.opacity = '1'; clearOverlay(); compositeLayersToMain(); pushHistory(); cancelCurrentTool(); } // ===================== FILTERS ===================== function applyFilter(fn) { const _ctx = activeCtx(); const { sx, sy, sw, sh } = getSelectionBBox(); const imageData = _ctx.getImageData(sx, sy, sw, sh); fn(imageData.data); putFilteredRegion(_ctx, imageData, sx, sy, sw, sh); compositeLayersToMain(); pushHistory(); } $('#btnGrayscale').addEventListener('click', () => { applyFilter(d => { for (let i = 0; i < d.length; i += 4) { const g = Math.round(0.2126 * d[i] + 0.7152 * d[i+1] + 0.0722 * d[i+2]); d[i] = d[i+1] = d[i+2] = g; } }); }); $('#btnSepia').addEventListener('click', () => { applyFilter(d => { for (let i = 0; i < d.length; i += 4) { const r = d[i], g = d[i+1], b = d[i+2]; d[i] = clamp(Math.round(r * 0.393 + g * 0.769 + b * 0.189), 0, 255); d[i+1] = clamp(Math.round(r * 0.349 + g * 0.686 + b * 0.168), 0, 255); d[i+2] = clamp(Math.round(r * 0.272 + g * 0.534 + b * 0.131), 0, 255); } }); }); $('#btnInvert').addEventListener('click', () => { applyFilter(d => { for (let i = 0; i < d.length; i += 4) { d[i] = 255 - d[i]; d[i+1] = 255 - d[i+1]; d[i+2] = 255 - d[i+2]; } }); }); $('#btnAutoLevels').addEventListener('click', () => { applyFilter(d => { let minR = 255, maxR = 0, minG = 255, maxG = 0, minB = 255, maxB = 0; for (let i = 0; i < d.length; i += 4) { if (d[i+3] < 10) continue; minR = Math.min(minR, d[i]); maxR = Math.max(maxR, d[i]); minG = Math.min(minG, d[i+1]); maxG = Math.max(maxG, d[i+1]); minB = Math.min(minB, d[i+2]); maxB = Math.max(maxB, d[i+2]); } const rng = (v, min, max) => max > min ? clamp(Math.round((v - min) / (max - min) * 255), 0, 255) : v; for (let i = 0; i < d.length; i += 4) { d[i] = rng(d[i], minR, maxR); d[i+1] = rng(d[i+1], minG, maxG); d[i+2] = rng(d[i+2], minB, maxB); } }); }); // ===================== BUCKET FILL / CLONE STAMP ===================== // Handled via dispatcher.registerTool('fill',...) and dispatcher.registerTool('clone',...) above. // ===================== RESIZE ===================== $('#btnResize').addEventListener('click', () => { if ($('#btnResize').classList.contains('active')) { cancelCurrentTool(); return; } cancelCurrentTool(); currentTool = 'resize'; $('#btnResize').classList.add('active'); resizeW = canvasW; resizeH = canvasH; resizeOrigW = canvasW; resizeOrigH = canvasH; resizeKeepRatio = true; toolOptions.classList.add('visible'); toolOptions.innerHTML = `
`; $('#optResLink').onclick = () => { resizeKeepRatio = !resizeKeepRatio; $('#optResLink').classList.toggle('on', resizeKeepRatio); }; $('#optResW').onchange = e => { resizeW = clamp(+e.target.value, 1, 16384); if (resizeKeepRatio) { resizeH = Math.round(resizeW * resizeOrigH / resizeOrigW); $('#optResH').value = resizeH; } }; $('#optResH').onchange = e => { resizeH = clamp(+e.target.value, 1, 16384); if (resizeKeepRatio) { resizeW = Math.round(resizeH * resizeOrigW / resizeOrigH); $('#optResW').value = resizeW; } }; $('#optResApply').onclick = applyResize; $('#optResCancel').onclick = () => cancelCurrentTool(); }); function applyResize() { if (resizeW === canvasW && resizeH === canvasH) { cancelCurrentTool(); return; } layers.forEach(layer => { const tmp = document.createElement('canvas'); tmp.width = resizeW; tmp.height = resizeH; const tc = tmp.getContext('2d'); tc.imageSmoothingEnabled = true; tc.imageSmoothingQuality = 'high'; tc.drawImage(layer.canvas, 0, 0, resizeW, resizeH); layer.canvas = tmp; layer.ctx = tc; }); setCanvasSize(resizeW, resizeH); compositeLayersToMain(); pushHistory(); fitToViewport(); cancelCurrentTool(); renderLayerPanel(); } // ===================== ROTATE / FLIP ===================== $('#btnRotCW').addEventListener('click', () => rotate90(1)); $('#btnRotCCW').addEventListener('click', () => rotate90(-1)); $('#btnFlipH').addEventListener('click', () => flip('h')); $('#btnFlipV').addEventListener('click', () => flip('v')); function rotate90(dir) { const oldW = canvasW, oldH = canvasH; layers.forEach(layer => { const tmp = document.createElement('canvas'); tmp.width = oldH; tmp.height = oldW; const tc = tmp.getContext('2d'); tc.save(); if (dir === 1) { tc.translate(oldH, 0); tc.rotate(Math.PI / 2); } else { tc.translate(0, oldW); tc.rotate(-Math.PI / 2); } tc.drawImage(layer.canvas, 0, 0); tc.restore(); layer.canvas = tmp; layer.ctx = tc; }); setCanvasSize(oldH, oldW); compositeLayersToMain(); pushHistory(); fitToViewport(); renderLayerPanel(); } function flip(dir) { layers.forEach(layer => { const tmp = document.createElement('canvas'); tmp.width = canvasW; tmp.height = canvasH; const tc = tmp.getContext('2d'); tc.save(); if (dir === 'h') { tc.translate(canvasW, 0); tc.scale(-1, 1); } else { tc.translate(0, canvasH); tc.scale(1, -1); } tc.drawImage(layer.canvas, 0, 0); tc.restore(); layer.canvas = tmp; layer.ctx = tc; }); compositeLayersToMain(); pushHistory(); renderLayerPanel(); } // ===================== CROP ===================== function initCrop() { const margin = Math.min(canvasW, canvasH) * 0.1; cropRect = { x: margin, y: margin, w: canvasW - margin * 2, h: canvasH - margin * 2 }; drawCropOverlay(); } function drawCropOverlay(full) { if (!cropRect) return; if (full !== false) { clearUI(); drawRulers(); drawImageBorder(); } const ctx = ctxUI; const vw = viewport.clientWidth; const vh = viewport.clientHeight; const tl = c2s(cropRect.x, cropRect.y); const br = c2s(cropRect.x + cropRect.w, cropRect.y + cropRect.h); const sx = tl.x, sy = tl.y, sw = br.x - tl.x, sh = br.y - tl.y; ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillRect(0, 0, vw, sy); ctx.fillRect(0, sy, sx, sh); ctx.fillRect(sx + sw, sy, vw - sx - sw, sh); ctx.fillRect(0, sy + sh, vw, vh - sy - sh); ctx.strokeStyle = '#0054e6'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]); ctx.strokeRect(sx, sy, sw, sh); ctx.setLineDash([]); ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 0.5; for (let i = 1; i < 3; i++) { ctx.beginPath(); ctx.moveTo(sx + sw * i / 3, sy); ctx.lineTo(sx + sw * i / 3, sy + sh); ctx.stroke(); ctx.beginPath(); ctx.moveTo(sx, sy + sh * i / 3); ctx.lineTo(sx + sw, sy + sh * i / 3); ctx.stroke(); } const hs = 5; ctx.fillStyle = '#0054e6'; const handles = getCropHandles(); for (const hp of Object.values(handles)) { const sp = c2s(hp.x, hp.y); ctx.fillRect(sp.x - hs, sp.y - hs, hs * 2, hs * 2); } } function getCropHandles() { if (!cropRect) return {}; const {x, y, w, h} = cropRect; return { tl: {x, y}, tm: {x: x+w/2, y}, tr: {x: x+w, y}, ml: {x, y: y+h/2}, mr: {x: x+w, y: y+h/2}, bl: {x, y: y+h}, bm: {x: x+w/2, y: y+h}, br: {x: x+w, y: y+h} }; } function hitCropHandle(cx, cy) { const handles = getCropHandles(); const threshold = 8 / zoom; for (const [name, hp] of Object.entries(handles)) { if (Math.abs(cx - hp.x) < threshold && Math.abs(cy - hp.y) < threshold) return name; } if (cropRect && cx >= cropRect.x && cx <= cropRect.x + cropRect.w && cy >= cropRect.y && cy <= cropRect.y + cropRect.h) { return 'move'; } return null; } function applyCrop() { if (!cropRect) return; const rx = Math.round(cropRect.x); const ry = Math.round(cropRect.y); const rw = Math.max(1, Math.round(cropRect.w)); const rh = Math.max(1, Math.round(cropRect.h)); layers.forEach(layer => { const tmp = document.createElement('canvas'); tmp.width = rw; tmp.height = rh; const tc = tmp.getContext('2d'); // Draw old layer content shifted: source at (rx,ry) maps to (0,0) tc.drawImage(layer.canvas, -rx, -ry); layer.canvas.width = rw; layer.canvas.height = rh; layer.ctx.clearRect(0, 0, rw, rh); layer.ctx.drawImage(tmp, 0, 0); }); setCanvasSize(rw, rh); compositeLayersToMain(); cropRect = null; pushHistory(); fitToViewport(); cancelCurrentTool(); renderLayerPanel(); } // ===================== TOOL HANDLERS (registered into dispatcher below) ===================== // Brush handler dispatcher.registerTool('brush', { onDown(e) { if (!canvasW) return; const p = getPointerCanvas(e); isDrawing = true; drawStart = { x: p.x, y: p.y }; lastDraw = { x: p.x, y: p.y }; viewport.setPointerCapture(e.pointerId); applySelectionClip(ctxOverlay); ctxOverlay.lineCap = 'round'; ctxOverlay.lineJoin = 'round'; ctxOverlay.lineWidth = brushSize; ctxOverlay.globalCompositeOperation = 'source-over'; ctxOverlay.strokeStyle = fgColor; ctxOverlay.globalAlpha = brushOpacity / 100; ctxOverlay.beginPath(); ctxOverlay.moveTo(p.x, p.y); ctxOverlay.lineTo(p.x + 0.1, p.y + 0.1); ctxOverlay.stroke(); }, onMove(e) { if (!isDrawing) return; const p = getPointerCanvas(e); ctxOverlay.beginPath(); ctxOverlay.moveTo(lastDraw.x, lastDraw.y); ctxOverlay.lineTo(p.x, p.y); ctxOverlay.stroke(); lastDraw = { x: p.x, y: p.y }; }, onUp(e) { if (!isDrawing) return; isDrawing = false; if (selRect && selRect.w > 5 && selRect.h > 5) ctxOverlay.restore(); activeCtx().save(); if (selRect && selRect.w > 5 && selRect.h > 5) { activeCtx().beginPath(); activeCtx().rect(selRect.x, selRect.y, selRect.w, selRect.h); activeCtx().clip(); } activeCtx().globalAlpha = 1; activeCtx().drawImage(cOverlay, 0, 0); activeCtx().restore(); ctxOverlay.globalCompositeOperation = 'source-over'; ctxOverlay.globalAlpha = 1; clearOverlay(); compositeLayersToMain(); pushHistory(); } }); // Eraser handler — draws directly on activeCtx() with destination-out dispatcher.registerTool('eraser', (function() { let eraserDrawing = false; let eraserLastPoint = null; return { onDown(e) { if (!canvasW) return; const p = getPointerCanvas(e); eraserDrawing = true; eraserLastPoint = { x: p.x, y: p.y }; viewport.setPointerCapture(e.pointerId); isDrawing = true; activeCtx().save(); if (selRect && selRect.w > 5 && selRect.h > 5) { activeCtx().beginPath(); activeCtx().rect(selRect.x, selRect.y, selRect.w, selRect.h); activeCtx().clip(); } activeCtx().globalCompositeOperation = 'destination-out'; activeCtx().lineCap = 'round'; activeCtx().lineJoin = 'round'; activeCtx().lineWidth = brushSize; activeCtx().globalAlpha = brushOpacity / 100; activeCtx().strokeStyle = 'rgba(0,0,0,1)'; activeCtx().beginPath(); activeCtx().moveTo(p.x, p.y); activeCtx().lineTo(p.x + 0.1, p.y + 0.1); activeCtx().stroke(); compositeLayersToMain(); }, onMove(e) { if (!eraserDrawing) return; const p = getPointerCanvas(e); activeCtx().beginPath(); activeCtx().moveTo(eraserLastPoint.x, eraserLastPoint.y); activeCtx().lineTo(p.x, p.y); activeCtx().stroke(); compositeLayersToMain(); eraserLastPoint = { x: p.x, y: p.y }; redrawUI(); const sp = c2s(p.x, p.y); ctxUI.save(); ctxUI.strokeStyle = '#fff'; ctxUI.lineWidth = 1; ctxUI.beginPath(); ctxUI.arc(sp.x, sp.y, brushSize / 2 * zoom, 0, Math.PI * 2); ctxUI.stroke(); ctxUI.restore(); }, onUp(e) { if (!eraserDrawing) return; eraserDrawing = false; eraserLastPoint = null; isDrawing = false; activeCtx().restore(); compositeLayersToMain(); pushHistory(); } }; })()); const CROP_HANDLE_CURSORS = { tl:'nwse-resize', tr:'nesw-resize', bl:'nesw-resize', br:'nwse-resize', tm:'ns-resize', bm:'ns-resize', ml:'ew-resize', mr:'ew-resize', move:'move' }; // Crop handler dispatcher.registerTool('crop', { onDown(e) { if (!canvasW) return; const p = getPointerCanvas(e); const handle = hitCropHandle(p.x, p.y); if (handle) { cropDragging = handle; cropStart = { x: p.x, y: p.y, rect: {...cropRect} }; viewport.setPointerCapture(e.pointerId); viewport.style.cursor = CROP_HANDLE_CURSORS[handle] || 'crosshair'; } }, onMove(e) { if (!cropDragging || !cropStart) { // Hover: update cursor based on handle under pointer const p = getPointerCanvas(e); const hit = hitCropHandle(p.x, p.y); viewport.style.cursor = hit ? (CROP_HANDLE_CURSORS[hit] || 'crosshair') : 'crosshair'; return; } const p = getPointerCanvas(e); const dx = p.x - cropStart.x; const dy = p.y - cropStart.y; const r = cropStart.rect; if (cropDragging === 'move') { cropRect.x = r.x + dx; cropRect.y = r.y + dy; } else { let nx = r.x, ny = r.y, nw = r.w, nh = r.h; if (cropDragging.includes('l')) { nx = r.x + dx; nw = r.w - dx; } if (cropDragging.includes('r')) { nw = r.w + dx; } if (cropDragging.includes('t')) { ny = r.y + dy; nh = r.h - dy; } if (cropDragging.includes('b')) { nh = r.h + dy; } if (nw < 10) { nw = 10; if (cropDragging.includes('l')) nx = r.x + r.w - 10; } if (nh < 10) { nh = 10; if (cropDragging.includes('t')) ny = r.y + r.h - 10; } if (cropAspect > 0) { if (cropDragging.includes('l') || cropDragging.includes('r')) { nh = nw / cropAspect; } else { nw = nh * cropAspect; } } cropRect = { x: nx, y: ny, w: nw, h: nh }; } drawCropOverlay(); }, onUp(e) { if (cropDragging) { cropDragging = null; cropStart = null; viewport.style.cursor = 'crosshair'; } } }); // Select (move layer content) handler dispatcher.registerTool('select', { onDown(e) { if (!canvasW) return; const p = getPointerCanvas(e); layerMoving = true; layerMoveStart = { x: p.x, y: p.y }; const layer = layers[activeLayerIndex]; layerMoveSnapshot = layer.ctx.getImageData(0, 0, canvasW, canvasH); viewport.setPointerCapture(e.pointerId); }, onMove(e) { if (!layerMoving || !layerMoveStart || !layerMoveSnapshot) return; const p = getPointerCanvas(e); const dx = Math.round(p.x - layerMoveStart.x); const dy = Math.round(p.y - layerMoveStart.y); const layer = layers[activeLayerIndex]; layer.ctx.clearRect(0, 0, canvasW, canvasH); layer.ctx.putImageData(layerMoveSnapshot, dx, dy); compositeLayersToMain(); }, onUp(e) { if (layerMoving) { layerMoving = false; layerMoveStart = null; layerMoveSnapshot = null; pushHistory(); } } }); // Eyedropper handler dispatcher.registerTool('eyedropper', { onDown(e) { if (!canvasW) return; const p = getPointerCanvas(e); if (p.x >= 0 && p.x < canvasW && p.y >= 0 && p.y < canvasH) { const pixel = ctxMain.getImageData(Math.round(p.x), Math.round(p.y), 1, 1).data; const hex = '#' + [pixel[0],pixel[1],pixel[2]].map(c => c.toString(16).padStart(2,'0')).join(''); fgColor = hex; $('#colorPicker').value = hex; updateColorSwatch(); } }, onMove(e) {}, onUp(e) {} }); // Text handler dispatcher.registerTool('text', { onDown(e) { if (!canvasW) return; const p = getPointerCanvas(e); const hadText = ((textArea.innerText || '').trim()).length > 0; commitText(); if (!hadText) { const textLayerIdx = findTextLayerAt(p.x, p.y); if (textLayerIdx >= 0) { const td = layers[textLayerIdx].textData; textFont = td.font; textSize = td.size; textBold = td.bold; textItalic = td.italic; fgColor = td.color; $('#colorPicker').value = fgColor; updateColorSwatch(); editingTextLayerIndex = textLayerIdx; layers[textLayerIdx].ctx.clearRect(0, 0, canvasW, canvasH); compositeLayersToMain(); showTextOverlay(td.x + td.ox, td.y + td.oy); textArea.textContent = td.content; textArea.dataset.cx = td.x + td.ox; textArea.dataset.cy = td.y + td.oy; textArea.dataset.rotation = td.rotation || 0; textArea.dataset.scaleX = td.scaleX || 1; textArea.dataset.scaleY = td.scaleY || 1; setTimeout(() => { textArea.focus(); selectAllTextEditor(); }, 50); } else { showTextOverlay(p.x, p.y); } } else { hideTextOverlay(); } }, onMove(e) {}, onUp(e) {} }); // Shape handler factory (line, rect, circle, arrow) function _makeShapeHandler(toolName) { return { onDown(e) { if (!canvasW) return; const p = getPointerCanvas(e); isDrawing = true; drawStart = { x: p.x, y: p.y }; lastDraw = { x: p.x, y: p.y }; viewport.setPointerCapture(e.pointerId); }, onMove(e) { if (!isDrawing) return; const p = getPointerCanvas(e); clearOverlay(); ctxOverlay.save(); if (selRect && selRect.w > 5 && selRect.h > 5) { ctxOverlay.beginPath(); ctxOverlay.rect(selRect.x, selRect.y, selRect.w, selRect.h); ctxOverlay.clip(); } ctxOverlay.strokeStyle = fgColor; ctxOverlay.fillStyle = fgColor; ctxOverlay.lineWidth = strokeWidth; ctxOverlay.lineCap = 'round'; ctxOverlay.lineJoin = 'round'; ctxOverlay.globalAlpha = 1; let ex = p.x, ey = p.y; const shift = e.shiftKey; if (toolName === 'line') { if (shift) { const sn = snapAngle(drawStart.x, drawStart.y, ex, ey); ex = sn.x; ey = sn.y; } ctxOverlay.beginPath(); ctxOverlay.moveTo(drawStart.x, drawStart.y); ctxOverlay.lineTo(ex, ey); ctxOverlay.stroke(); } else if (toolName === 'arrow') { if (shift) { const sn = snapAngle(drawStart.x, drawStart.y, ex, ey); ex = sn.x; ey = sn.y; } drawArrow(ctxOverlay, drawStart.x, drawStart.y, ex, ey, strokeWidth); } else if (toolName === 'rect') { let rw = ex - drawStart.x, rh = ey - drawStart.y; if (shift) { const s = Math.max(Math.abs(rw), Math.abs(rh)); rw = s * Math.sign(rw); rh = s * Math.sign(rh); } if (fillShape) ctxOverlay.fillRect(drawStart.x, drawStart.y, rw, rh); ctxOverlay.strokeRect(drawStart.x, drawStart.y, rw, rh); } else if (toolName === 'circle') { let rx = Math.abs(ex - drawStart.x) / 2; let ry = Math.abs(ey - drawStart.y) / 2; if (shift) { rx = ry = Math.max(rx, ry); } const cx = (drawStart.x + (shift ? drawStart.x + rx * 2 * Math.sign(ex - drawStart.x) : ex)) / 2; const cy = (drawStart.y + (shift ? drawStart.y + ry * 2 * Math.sign(ey - drawStart.y) : ey)) / 2; ctxOverlay.beginPath(); ctxOverlay.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); if (fillShape) ctxOverlay.fill(); ctxOverlay.stroke(); } ctxOverlay.restore(); }, onUp(e) { if (!isDrawing) return; isDrawing = false; const p = getPointerCanvas(e); let ex = p.x, ey = p.y; const shift = e.shiftKey; const ps = { type: toolName, ox: 0, oy: 0, params: { color: fgColor, strokeWidth: strokeWidth } }; if (toolName === 'line') { if (shift) { const sn = snapAngle(drawStart.x, drawStart.y, ex, ey); ex = sn.x; ey = sn.y; } ps.params.x1 = drawStart.x; ps.params.y1 = drawStart.y; ps.params.x2 = ex; ps.params.y2 = ey; } else if (toolName === 'arrow') { if (shift) { const sn = snapAngle(drawStart.x, drawStart.y, ex, ey); ex = sn.x; ey = sn.y; } ps.params.x1 = drawStart.x; ps.params.y1 = drawStart.y; ps.params.x2 = ex; ps.params.y2 = ey; } else if (toolName === 'rect') { let rw = ex - drawStart.x, rh = ey - drawStart.y; if (shift) { const s = Math.max(Math.abs(rw), Math.abs(rh)); rw = s * Math.sign(rw); rh = s * Math.sign(rh); } ps.params.x = drawStart.x; ps.params.y = drawStart.y; ps.params.w = rw; ps.params.h = rh; ps.params.fill = fillShape; } else if (toolName === 'circle') { let rx = Math.abs(ex - drawStart.x) / 2; let ry = Math.abs(ey - drawStart.y) / 2; if (shift) { rx = ry = Math.max(rx, ry); } const cx2 = (drawStart.x + (shift ? drawStart.x + rx * 2 * Math.sign(ex - drawStart.x) : ex)) / 2; const cy2 = (drawStart.y + (shift ? drawStart.y + ry * 2 * Math.sign(ey - drawStart.y) : ey)) / 2; ps.params.cx = cx2; ps.params.cy = cy2; ps.params.rx = rx; ps.params.ry = ry; ps.params.fill = fillShape; } pendingShape = ps; renderPendingOnOverlay(); showPendingOptions(); } }; } ['line','rect','circle','arrow'].forEach(t => dispatcher.registerTool(t, _makeShapeHandler(t))); // Fill handler dispatcher.registerTool('fill', { onDown(e) { if (!canvasW) return; const p = getPointerCanvas(e); const px = Math.round(p.x), py = Math.round(p.y); if (px < 0 || py < 0 || px >= canvasW || py >= canvasH) return; floodFill(px, py, fgColor, fillTolerance); pushHistory(); }, onMove(e) {}, onUp(e) {} }); // Clone handler dispatcher.registerTool('clone', (function() { let cloneDrawing = false; return { onDown(e) { if (!canvasW) return; const p = getPointerCanvas(e); if (e.altKey) { cloneSrc = { x: p.x, y: p.y }; cloneOffset = null; cloneSrcSet = true; if ($('#cloneHint')) $('#cloneHint').textContent = `Источник: ${Math.round(p.x)}, ${Math.round(p.y)}`; redrawUI(); return; } if (!cloneSrc) return; if (!cloneOffset) { cloneOffset = { dx: cloneSrc.x - p.x, dy: cloneSrc.y - p.y }; } cloneDrawing = true; viewport.setPointerCapture(e.pointerId); _clonePaint(p.x, p.y); }, onMove(e) { const p = getPointerCanvas(e); if (cloneDrawing) { _clonePaint(p.x, p.y); redrawUI(); _drawCloneCrosshair(p.x + cloneOffset.dx, p.y + cloneOffset.dy); return; } if (!canvasW) return; redrawUI(); if (cloneSrc) { const off = cloneOffset || { dx: cloneSrc.x - p.x, dy: cloneSrc.y - p.y }; _drawCloneCrosshair(p.x + off.dx, p.y + off.dy); } const sp = c2s(p.x, p.y); ctxUI.save(); ctxUI.strokeStyle = '#fff'; ctxUI.lineWidth = 1; ctxUI.beginPath(); ctxUI.arc(sp.x, sp.y, brushSize / 2 * zoom, 0, Math.PI * 2); ctxUI.stroke(); ctxUI.restore(); }, onUp(e) { if (cloneDrawing) { cloneDrawing = false; pushHistory(); } } }; })()); dispatcher.registerTool('lasso', (function() { let lassoPoints = [], lassoDrawing = false; return { onDown(e) { if (!canvasW || e.button !== 0) return; lassoPoints = []; lassoDrawing = true; selPath = null; selRect = null; stopMarchingAnts(); const p = getPointerCanvas(e); lassoPoints.push(p); viewport.setPointerCapture(e.pointerId); clearUI(); toolOptions.classList.remove('visible'); toolOptions.innerHTML = ''; }, onMove(e) { if (!lassoDrawing) return; const p = getPointerCanvas(e); const last = lassoPoints[lassoPoints.length - 1]; if (Math.hypot(p.x - last.x, p.y - last.y) > 2) { lassoPoints.push(p); drawLassoInProgress(lassoPoints); } }, onUp(e) { if (!lassoDrawing) return; lassoDrawing = false; if (lassoPoints.length < 3) { lassoPoints = []; return; } selPath = lassoPoints.slice(); lassoPoints = []; redrawUI(); showLassoOptions(); } }; })()); function _clonePaint(destX, destY) { const srcX = destX + cloneOffset.dx; const srcY = destY + cloneOffset.dy; const r = brushSize / 2; const sx = Math.round(srcX - r), sy = Math.round(srcY - r); const size = Math.round(brushSize); if (sx < 0 || sy < 0 || sx + size > canvasW || sy + size > canvasH) return; const srcData = ctxMain.getImageData(sx, sy, size, size); const tmp = document.createElement('canvas'); tmp.width = size; tmp.height = size; const tctx = tmp.getContext('2d'); tctx.beginPath(); tctx.arc(size/2, size/2, size/2, 0, Math.PI * 2); tctx.clip(); tctx.putImageData(srcData, 0, 0); activeCtx().save(); if (selRect && selRect.w > 5 && selRect.h > 5) { activeCtx().beginPath(); activeCtx().rect(selRect.x, selRect.y, selRect.w, selRect.h); activeCtx().clip(); } activeCtx().globalAlpha = brushOpacity / 100; activeCtx().drawImage(tmp, Math.round(destX - r), Math.round(destY - r)); activeCtx().restore(); compositeLayersToMain(); } function _drawCloneCrosshair(cx, cy) { const sp = c2s(cx, cy); const ctx = ctxUI; const len = 10; ctx.save(); ctx.strokeStyle = '#ff5252'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(sp.x - len, sp.y); ctx.lineTo(sp.x + len, sp.y); ctx.moveTo(sp.x, sp.y - len); ctx.lineTo(sp.x, sp.y + len); ctx.stroke(); ctx.beginPath(); ctx.arc(sp.x, sp.y, brushSize / 2 * zoom, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } // ===================== SELECTION + PENDING registered into dispatcher ===================== // ===================== SELECTION HELPERS ===================== function getSelHandles() { if (!selRect) return {}; const {x, y, w, h} = selRect; return { tl:{x,y}, tm:{x:x+w/2,y}, tr:{x:x+w,y}, ml:{x,y:y+h/2}, mr:{x:x+w,y:y+h/2}, bl:{x,y:y+h}, bm:{x:x+w/2,y:y+h}, br:{x:x+w,y:y+h} }; } function hitSelHandle(cx, cy) { if (!selRect) return null; const handles = getSelHandles(); const threshold = 8 / zoom; for (const [name, hp] of Object.entries(handles)) { if (Math.abs(cx - hp.x) < threshold && Math.abs(cy - hp.y) < threshold) return name; } if (cx >= selRect.x && cx <= selRect.x + selRect.w && cy >= selRect.y && cy <= selRect.y + selRect.h) return 'move'; return null; } const SEL_HANDLE_CURSORS = { tl:'nwse-resize', tr:'nesw-resize', bl:'nesw-resize', br:'nwse-resize', tm:'ns-resize', bm:'ns-resize', ml:'ew-resize', mr:'ew-resize', move:'grab' }; dispatcher.registerSelection({ onDown(e) { if (!canvasW || e.button !== 0) return; const p = getPointerCanvas(e); if (selRect && selRect.w > 5 && selRect.h > 5) { const hit = hitSelHandle(p.x, p.y); if (hit) { selDragging = true; selDragMode = hit; selStart = {x: p.x, y: p.y}; selStartRect = {...selRect}; viewport.setPointerCapture(e.pointerId); return; } } selDragging = true; selDragMode = 'new'; selStart = {x: p.x, y: p.y}; selRect = {x: p.x, y: p.y, w: 0, h: 0}; selStartRect = null; viewport.setPointerCapture(e.pointerId); clearUI(); toolOptions.classList.remove('visible'); toolOptions.innerHTML = ''; }, onMove(e) { const p = getPointerCanvas(e); if (canvasW && selRect && selRect.w > 5 && !selDragging) { const hit = hitSelHandle(p.x, p.y); viewport.style.cursor = hit ? (SEL_HANDLE_CURSORS[hit] || 'default') : 'default'; } if (!selDragging) return; viewport.style.cursor = selDragMode === 'move' ? 'grabbing' : (SEL_HANDLE_CURSORS[selDragMode] || 'crosshair'); if (selDragMode === 'new') { const x1 = Math.min(selStart.x, p.x); const y1 = Math.min(selStart.y, p.y); const x2 = Math.max(selStart.x, p.x); const y2 = Math.max(selStart.y, p.y); selRect = {x: x1, y: y1, w: x2 - x1, h: y2 - y1}; } else if (selDragMode === 'move') { const dx = p.x - selStart.x; const dy = p.y - selStart.y; selRect.x = clamp(selStartRect.x + dx, 0, canvasW - selStartRect.w); selRect.y = clamp(selStartRect.y + dy, 0, canvasH - selStartRect.h); } else { const dx = p.x - selStart.x; const dy = p.y - selStart.y; const r = selStartRect; let nx = r.x, ny = r.y, nw = r.w, nh = r.h; if (selDragMode.includes('l')) { nx = r.x + dx; nw = r.w - dx; } if (selDragMode.includes('r')) { nw = r.w + dx; } if (selDragMode !== 'ml' && selDragMode !== 'mr' && selDragMode.includes('t')) { ny = r.y + dy; nh = r.h - dy; } if (selDragMode !== 'tm' && selDragMode !== 'bm' && selDragMode.includes('b')) { nh = r.h + dy; } if (nw < 0) { nx = nx + nw; nw = -nw; } if (nh < 0) { ny = ny + nh; nh = -nh; } if (nx < 0) { nw += nx; nx = 0; } if (ny < 0) { nh += ny; ny = 0; } if (nx + nw > canvasW) nw = canvasW - nx; if (ny + nh > canvasH) nh = canvasH - ny; selRect = {x: nx, y: ny, w: Math.max(nw, 2), h: Math.max(nh, 2)}; } drawSelectionRect(); }, onUp(e) { if (!selDragging) return; selDragging = false; selDragMode = null; selStartRect = null; if (selRect && selRect.w > 5 && selRect.h > 5) { selRect.x = clamp(selRect.x, 0, canvasW); selRect.y = clamp(selRect.y, 0, canvasH); selRect.w = clamp(selRect.w, 0, canvasW - selRect.x); selRect.h = clamp(selRect.h, 0, canvasH - selRect.y); drawSelectionRect(); showSelectionOptions(); } else { selRect = null; clearUI(); toolOptions.classList.remove('visible'); toolOptions.innerHTML = ''; } } }); function drawSelectionRect(full) { if (!selRect) return; if (full !== false) { clearUI(); drawRulers(); drawImageBorder(); } const ctx = ctxUI; const vw = viewport.clientWidth; const vh = viewport.clientHeight; const tl = c2s(selRect.x, selRect.y); const br = c2s(selRect.x + selRect.w, selRect.y + selRect.h); const sx = tl.x, sy = tl.y, sw = br.x - tl.x, sh = br.y - tl.y; ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(0, 0, vw, sy); ctx.fillRect(0, sy, sx, sh); ctx.fillRect(sx + sw, sy, vw - sx - sw, sh); ctx.fillRect(0, sy + sh, vw, vh - sy - sh); ctx.strokeStyle = '#0054e6'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]); ctx.strokeRect(sx, sy, sw, sh); ctx.setLineDash([]); const hs = 4; ctx.fillStyle = '#0054e6'; ctx.strokeStyle = '#000'; ctx.lineWidth = 1; for (const hp of Object.values(getSelHandles())) { const sp = c2s(hp.x, hp.y); ctx.fillRect(sp.x - hs, sp.y - hs, hs * 2, hs * 2); ctx.strokeRect(sp.x - hs, sp.y - hs, hs * 2, hs * 2); } const lw = Math.round(selRect.w); const lh = Math.round(selRect.h); ctx.font = '12px JetBrains Mono, monospace'; ctx.fillStyle = '#0054e6'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(lw + ' \u00d7 ' + lh, sx + sw/2, sy - 6); } function showSelectionOptions() { toolOptions.innerHTML = `
`; toolOptions.classList.add('visible'); $('#optSelCrop').onclick = applySelectionCrop; $('#optSelCancel').onclick = clearSelection; } function applySelectionCrop() { if (!selRect) return; const rx = Math.round(clamp(selRect.x, 0, canvasW)); const ry = Math.round(clamp(selRect.y, 0, canvasH)); const rw = Math.round(clamp(selRect.w, 1, canvasW - rx)); const rh = Math.round(clamp(selRect.h, 1, canvasH - ry)); layers.forEach(layer => { const data = layer.ctx.getImageData(rx, ry, rw, rh); layer.canvas.width = rw; layer.canvas.height = rh; layer.ctx.putImageData(data, 0, 0); }); setCanvasSize(rw, rh); compositeLayersToMain(); selRect = null; clearUI(); toolOptions.classList.remove('visible'); toolOptions.innerHTML = ''; pushHistory(); fitToViewport(); renderLayerPanel(); } function clearSelection() { selRect = null; selPath = null; selDragMode = null; selStartRect = null; stopMarchingAnts(); redrawUI(); toolOptions.classList.remove('visible'); toolOptions.innerHTML = ''; } function applySelectionClip(ctx) { if (selPath && selPath.length > 2) { ctx.save(); ctx.beginPath(); selPath.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)); ctx.closePath(); ctx.clip(); return true; } if (!selRect || selRect.w <= 5 || selRect.h <= 5) return false; ctx.save(); ctx.beginPath(); ctx.rect(selRect.x, selRect.y, selRect.w, selRect.h); ctx.clip(); return true; } // ===================== SELECTION HELPERS ===================== function hasActiveSelection() { return (selPath && selPath.length > 2) || (selRect && selRect.w > 5 && selRect.h > 5); } function getSelectionBBox() { if (selPath && selPath.length > 2) { const xs = selPath.map(p => p.x), ys = selPath.map(p => p.y); const sx = Math.max(0, Math.floor(Math.min(...xs))); const sy = Math.max(0, Math.floor(Math.min(...ys))); const sw = Math.min(canvasW - sx, Math.ceil(Math.max(...xs)) - sx); const sh = Math.min(canvasH - sy, Math.ceil(Math.max(...ys)) - sy); return { sx, sy, sw: Math.max(1, sw), sh: Math.max(1, sh) }; } if (selRect && selRect.w > 5 && selRect.h > 5) { const sx = Math.round(clamp(selRect.x, 0, canvasW)); const sy = Math.round(clamp(selRect.y, 0, canvasH)); const sw = Math.round(clamp(selRect.w, 1, canvasW - sx)); const sh = Math.round(clamp(selRect.h, 1, canvasH - sy)); return { sx, sy, sw, sh }; } return { sx: 0, sy: 0, sw: canvasW, sh: canvasH }; } // Composite filtered temp canvas back to ctx, clipped to active selection. // Use instead of ctx.putImageData when selection is lasso (putImageData ignores clip). function putFilteredRegion(ctx, imageData, sx, sy, sw, sh) { if (selPath && selPath.length > 2) { const tmp = document.createElement('canvas'); tmp.width = sw; tmp.height = sh; tmp.getContext('2d').putImageData(imageData, 0, 0); ctx.save(); ctx.beginPath(); selPath.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)); ctx.closePath(); ctx.clip(); ctx.clearRect(sx, sy, sw, sh); ctx.drawImage(tmp, sx, sy); ctx.restore(); } else { ctx.putImageData(imageData, sx, sy); } } // Point-in-polygon (ray casting) for lasso masking function pointInPolygon(x, y, pts) { let inside = false; for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { const xi = pts[i].x, yi = pts[i].y, xj = pts[j].x, yj = pts[j].y; if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside; } return inside; } // ===================== LASSO UI ===================== function drawLassoInProgress(pts) { clearUI(); drawRulers(); drawImageBorder(); const ctx = ctxUI; ctx.save(); ctx.strokeStyle = '#0054e6'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]); ctx.beginPath(); pts.forEach((p, i) => { const s = c2s(p.x, p.y); i === 0 ? ctx.moveTo(s.x, s.y) : ctx.lineTo(s.x, s.y); }); const s0 = c2s(pts[0].x, pts[0].y); ctx.lineTo(s0.x, s0.y); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } function drawLassoOutline() { if (!selPath || selPath.length < 3) return; const ctx = ctxUI; ctx.save(); const buildPath = () => { ctx.beginPath(); selPath.forEach((p, i) => { const s = c2s(p.x, p.y); i === 0 ? ctx.moveTo(s.x, s.y) : ctx.lineTo(s.x, s.y); }); ctx.closePath(); }; // White base stroke ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]); ctx.lineDashOffset = -(marchOffset + 6); buildPath(); ctx.stroke(); // Blue top stroke ctx.strokeStyle = '#0054e6'; ctx.lineDashOffset = -marchOffset; buildPath(); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } function startMarchingAnts() { if (marchRaf) return; function step() { if (!selPath || selPath.length < 3) { marchRaf = 0; return; } marchOffset = (marchOffset + 0.4) % 20; clearUI(); drawRulers(); drawImageBorder(); drawLassoOutline(); marchRaf = requestAnimationFrame(step); } marchRaf = requestAnimationFrame(step); } function stopMarchingAnts() { if (marchRaf) { cancelAnimationFrame(marchRaf); marchRaf = 0; } } function showLassoOptions() { const { sw, sh } = getSelectionBBox(); toolOptions.innerHTML = `
`; toolOptions.classList.add('visible'); $('#optLassoCrop').onclick = applyLassoCrop; $('#optLassoCancel').onclick = clearSelection; startMarchingAnts(); } function applyLassoCrop() { if (!selPath || selPath.length < 3) return; const { sx, sy, sw, sh } = getSelectionBBox(); layers.forEach(layer => { const tmp = document.createElement('canvas'); tmp.width = sw; tmp.height = sh; const tctx = tmp.getContext('2d'); tctx.save(); tctx.beginPath(); selPath.forEach((p, i) => i === 0 ? tctx.moveTo(p.x - sx, p.y - sy) : tctx.lineTo(p.x - sx, p.y - sy)); tctx.closePath(); tctx.clip(); tctx.drawImage(layer.canvas, sx, sy, sw, sh, 0, 0, sw, sh); tctx.restore(); layer.canvas = tmp; layer.ctx = tctx; }); selPath = null; stopMarchingAnts(); setCanvasSize(sw, sh); compositeLayersToMain(); clearUI(); toolOptions.classList.remove('visible'); toolOptions.innerHTML = ''; pushHistory(); fitToViewport(); renderLayerPanel(); } // fill handler already registered above via dispatcher.registerTool('fill', ...) function floodFill(startX, startY, hexColor, tolerance) { const imageData = activeCtx().getImageData(0, 0, canvasW, canvasH); const d = imageData.data; const w = canvasW, h = canvasH; const fr = parseInt(hexColor.slice(1,3), 16); const fg = parseInt(hexColor.slice(3,5), 16); const fb = parseInt(hexColor.slice(5,7), 16); const idx = (startY * w + startX) * 4; const tr = d[idx], tg = d[idx+1], tb = d[idx+2], ta = d[idx+3]; if (tr === fr && tg === fg && tb === fb && ta === 255) return; const tol2 = tolerance * tolerance; const visited = new Uint8Array(w * h); const { sx: selMinX, sy: selMinY, sw: _sw, sh: _sh } = getSelectionBBox(); const selMaxX = selMinX + _sw, selMaxY = selMinY + _sh; const useLasso = selPath && selPath.length > 2; const stack = [startX, startY]; function match(i) { const dr = d[i] - tr, dg = d[i+1] - tg, db = d[i+2] - tb, da = d[i+3] - ta; return (dr*dr + dg*dg + db*db + da*da) <= tol2 * 4; } while (stack.length > 0) { const y = stack.pop(), x = stack.pop(); const pi = y * w + x; if (x < selMinX || x >= selMaxX || y < selMinY || y >= selMaxY || visited[pi]) continue; if (useLasso && !pointInPolygon(x, y, selPath)) continue; const i4 = pi * 4; if (!match(i4)) continue; visited[pi] = 1; d[i4] = fr; d[i4+1] = fg; d[i4+2] = fb; d[i4+3] = 255; stack.push(x+1, y, x-1, y, x, y+1, x, y-1); } activeCtx().putImageData(imageData, 0, 0); compositeLayersToMain(); } // clone handler already registered above via dispatcher.registerTool('clone', ...) // ===================== ARROW DRAWING ===================== function drawArrow(ctx, x1, y1, x2, y2, width) { const angle = Math.atan2(y2 - y1, x2 - x1); const headLen = Math.max(width * 4, 12); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6)); ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6)); ctx.closePath(); ctx.fill(); } function snapAngle(x1, y1, x2, y2) { const angle = Math.atan2(y2 - y1, x2 - x1); const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4); const dist = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); return { x: x1 + dist * Math.cos(snapped), y: y1 + dist * Math.sin(snapped) }; } // ===================== BRUSH CURSOR ===================== viewport.addEventListener('pointermove', e => { if (!canvasW) return; if ((currentTool === 'brush' || currentTool === 'eraser') && !isPanning) { const p = getPointerCanvas(e); if (!isDrawing || currentTool === 'brush') { redrawUI(); const sp = c2s(p.x, p.y); ctxUI.save(); ctxUI.strokeStyle = currentTool === 'eraser' ? '#ff5252' : fgColor; ctxUI.lineWidth = 1; ctxUI.beginPath(); ctxUI.arc(sp.x, sp.y, brushSize / 2 * zoom, 0, Math.PI * 2); ctxUI.stroke(); ctxUI.restore(); } } }); // ===================== PENDING SHAPE (MOVEABLE + TRANSFORMABLE) ===================== function getPendingBBox(ps) { if (!ps) return null; const ox = ps.ox || 0, oy = ps.oy || 0; const sw = ps.params.strokeWidth || 2; const pad = sw / 2 + 4; if (ps.type === 'line' || ps.type === 'arrow') { const p = ps.params; const headPad = ps.type === 'arrow' ? Math.max(sw * 4, 12) : 0; const xMin = Math.min(p.x1, p.x2) - pad - headPad; const yMin = Math.min(p.y1, p.y2) - pad - headPad; const xMax = Math.max(p.x1, p.x2) + pad + headPad; const yMax = Math.max(p.y1, p.y2) + pad + headPad; return { x: xMin + ox, y: yMin + oy, w: xMax - xMin, h: yMax - yMin }; } if (ps.type === 'rect') { const p = ps.params; const x = Math.min(p.x, p.x + p.w); const y = Math.min(p.y, p.y + p.h); return { x: x + ox - pad, y: y + oy - pad, w: Math.abs(p.w) + 2 * pad, h: Math.abs(p.h) + 2 * pad }; } if (ps.type === 'circle') { const p = ps.params; return { x: p.cx - p.rx + ox - pad, y: p.cy - p.ry + oy - pad, w: 2 * p.rx + 2 * pad, h: 2 * p.ry + 2 * pad }; } if (ps.type === 'text') { const p = ps.params; ctxOverlay.save(); const fontStyle = (p.italic ? 'italic ' : '') + (p.bold ? '700 ' : '400 '); ctxOverlay.font = fontStyle + p.size + 'px ' + p.font; const lines = p.text.split('\n'); let maxW = 0; lines.forEach(line => { maxW = Math.max(maxW, ctxOverlay.measureText(line).width); }); ctxOverlay.restore(); const scaleX = ps.scaleX || 1, scaleY = ps.scaleY || 1; return { x: p.x + ox - 2, y: p.y + oy - 2, w: (maxW + 4) * scaleX, h: (lines.length * p.lineHeight + 4) * scaleY }; } return null; } function getPendingPivot(ps) { const bbox = getPendingBBox(ps); if (!bbox) return { x: ps.ox || 0, y: ps.oy || 0 }; return { x: bbox.x + bbox.w / 2, y: bbox.y + bbox.h / 2 }; } function transformPoint(x, y, pivotX, pivotY, rotation, scaleX, scaleY) { const cos = Math.cos(rotation || 0), sin = Math.sin(rotation || 0); const dx = (x - pivotX) * (scaleX || 1); const dy = (y - pivotY) * (scaleY || 1); return { x: pivotX + dx * cos - dy * sin, y: pivotY + dx * sin + dy * cos }; } function getPendingHandles(ps) { const bbox = getPendingBBox(ps); if (!bbox) return []; const pivot = getPendingPivot(ps); const rot = ps.rotation || 0; const L = bbox.x, R = bbox.x + bbox.w, T = bbox.y, B = bbox.y + bbox.h; const tp = (x, y) => transformPoint(x, y, pivot.x, pivot.y, rot, 1, 1); const handles = [ { ...tp(L, T), id: 'tl' }, { ...tp(R, T), id: 'tr' }, { ...tp(L, B), id: 'bl' }, { ...tp(R, B), id: 'br' }, ]; const topMid = tp((L + R) / 2, T); const rotDist = 30 / zoom; handles.push({ x: topMid.x + Math.cos(rot - Math.PI / 2) * rotDist, y: topMid.y + Math.sin(rot - Math.PI / 2) * rotDist, id: 'rot' }); return handles; } function hitPendingHandle(cx, cy) { if (!pendingShape) return null; const handles = getPendingHandles(pendingShape); const hitR = 12 / zoom; for (const h of handles) { if (Math.hypot(cx - h.x, cy - h.y) <= hitR) return h.id; } return null; } function hitPendingShape(cx, cy) { if (!pendingShape) return false; const bbox = getPendingBBox(pendingShape); if (!bbox) return false; const rot = pendingShape.rotation || 0; if (rot === 0) return cx >= bbox.x && cx <= bbox.x + bbox.w && cy >= bbox.y && cy <= bbox.y + bbox.h; const pivotX = bbox.x + bbox.w / 2, pivotY = bbox.y + bbox.h / 2; const cos = Math.cos(-rot), sin = Math.sin(-rot); const dx = cx - pivotX, dy = cy - pivotY; const nx = pivotX + dx * cos - dy * sin, ny = pivotY + dx * sin + dy * cos; return nx >= bbox.x && nx <= bbox.x + bbox.w && ny >= bbox.y && ny <= bbox.y + bbox.h; } function drawPendingShapeOnCtx(ctx, ps) { if (!ps) return; const ox = ps.ox || 0, oy = ps.oy || 0; const rot = ps.rotation || 0; const scaleX = ps.scaleX || 1, scaleY = ps.scaleY || 1; ctx.save(); if (rot !== 0) { const bbox = getPendingBBox(ps); if (bbox) { const pvX = bbox.x + bbox.w / 2, pvY = bbox.y + bbox.h / 2; ctx.translate(pvX, pvY); ctx.rotate(rot); ctx.translate(-pvX, -pvY); } } ctx.strokeStyle = ps.params.color; ctx.fillStyle = ps.params.color; ctx.lineWidth = ps.params.strokeWidth || 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (ps.type === 'line') { ctx.beginPath(); ctx.moveTo(ps.params.x1 + ox, ps.params.y1 + oy); ctx.lineTo(ps.params.x2 + ox, ps.params.y2 + oy); ctx.stroke(); } else if (ps.type === 'arrow') { drawArrow(ctx, ps.params.x1 + ox, ps.params.y1 + oy, ps.params.x2 + ox, ps.params.y2 + oy, ps.params.strokeWidth); } else if (ps.type === 'rect') { const p = ps.params; if (p.fill) ctx.fillRect(p.x + ox, p.y + oy, p.w, p.h); ctx.strokeRect(p.x + ox, p.y + oy, p.w, p.h); } else if (ps.type === 'circle') { const p = ps.params; ctx.beginPath(); ctx.ellipse(p.cx + ox, p.cy + oy, p.rx, p.ry, 0, 0, Math.PI * 2); if (p.fill) ctx.fill(); ctx.stroke(); } else if (ps.type === 'text') { const p = ps.params; const fontStyle = (p.italic ? 'italic ' : '') + (p.bold ? '700 ' : '400 '); ctx.font = fontStyle + p.size + 'px ' + p.font; ctx.textBaseline = 'top'; const lines = p.text.split('\n'); if (scaleX !== 1 || scaleY !== 1) { ctx.translate(p.x + ox, p.y + oy); ctx.scale(scaleX, scaleY); ctx.translate(-(p.x + ox), -(p.y + oy)); } lines.forEach((line, i) => { ctx.fillText(line, p.x + ox, p.y + oy + i * p.lineHeight); }); } ctx.restore(); } function renderPendingOnOverlay() { clearOverlay(); if (!pendingShape) return; drawPendingShapeOnCtx(ctxOverlay, pendingShape); redrawUI(); } function drawPendingBBoxOnUI() { if (!pendingShape) return; const bbox = getPendingBBox(pendingShape); if (!bbox) return; const rot = pendingShape.rotation || 0; const pivot = getPendingPivot(pendingShape); const ctx = ctxUI; ctx.save(); const L = bbox.x, R = bbox.x + bbox.w, T = bbox.y, B = bbox.y + bbox.h; const tp = (x, y) => { const t = transformPoint(x, y, pivot.x, pivot.y, rot, 1, 1); return c2s(t.x, t.y); }; const tl = tp(L, T), tr = tp(R, T), bl = tp(L, B), br = tp(R, B); ctx.beginPath(); ctx.moveTo(tl.x, tl.y); ctx.lineTo(tr.x, tr.y); ctx.lineTo(br.x, br.y); ctx.lineTo(bl.x, bl.y); ctx.closePath(); ctx.fillStyle = 'rgba(0,84,230,0.07)'; ctx.fill(); ctx.strokeStyle = '#0054e6'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]); ctx.stroke(); ctx.setLineDash([]); const handles = getPendingHandles(pendingShape); const topMidCanvas = transformPoint((L + R) / 2, T, pivot.x, pivot.y, rot, 1, 1); const topMidScreen = c2s(topMidCanvas.x, topMidCanvas.y); handles.forEach(h => { const s = c2s(h.x, h.y); ctx.beginPath(); if (h.id === 'rot') { ctx.moveTo(topMidScreen.x, topMidScreen.y); ctx.lineTo(s.x, s.y); ctx.strokeStyle = '#0054e6'; ctx.lineWidth = 1; ctx.stroke(); ctx.beginPath(); ctx.arc(s.x, s.y, 6, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); ctx.strokeStyle = '#0054e6'; ctx.lineWidth = 1.5; ctx.stroke(); } else { ctx.rect(s.x - 5, s.y - 5, 10, 10); ctx.fillStyle = '#0054e6'; ctx.fill(); ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.stroke(); } }); ctx.restore(); } function commitPendingShape() { if (!pendingShape) return; if (pendingShape.type === 'text') { let textLayer; if (editingTextLayerIndex >= 0 && editingTextLayerIndex < layers.length) { textLayer = layers[editingTextLayerIndex]; textLayer.ctx.clearRect(0, 0, canvasW, canvasH); editingTextLayerIndex = -1; } else { textLayer = createLayer('Текст', canvasW, canvasH); layers.splice(activeLayerIndex + 1, 0, textLayer); activeLayerIndex++; } textLayer.textData = { content: pendingShape.params.text, x: pendingShape.params.x, y: pendingShape.params.y, font: pendingShape.params.font, size: pendingShape.params.size, bold: pendingShape.params.bold, italic: pendingShape.params.italic, color: pendingShape.params.color, lineHeight: pendingShape.params.lineHeight, ox: pendingShape.ox || 0, oy: pendingShape.oy || 0, rotation: pendingShape.rotation || 0, scaleX: pendingShape.scaleX || 1, scaleY: pendingShape.scaleY || 1, }; textLayer.ctx.save(); if (selRect && selRect.w > 5 && selRect.h > 5) { textLayer.ctx.beginPath(); textLayer.ctx.rect(selRect.x, selRect.y, selRect.w, selRect.h); textLayer.ctx.clip(); } drawPendingShapeOnCtx(textLayer.ctx, pendingShape); textLayer.ctx.restore(); } else { const _ctx = activeCtx(); _ctx.save(); if (selRect && selRect.w > 5 && selRect.h > 5) { _ctx.beginPath(); _ctx.rect(selRect.x, selRect.y, selRect.w, selRect.h); _ctx.clip(); } drawPendingShapeOnCtx(_ctx, pendingShape); _ctx.restore(); } clearOverlay(); pendingShape = null; pendingDragging = false; pendingDragStart = null; pendingTransformHandle = null; pendingTransformStart = null; compositeLayersToMain(); pushHistory(); redrawUI(); renderLayerPanel(); toolOptions.classList.remove('visible'); toolOptions.innerHTML = ''; if (currentTool) showToolOptions(currentTool); else if (selRect && selRect.w > 5 && selRect.h > 5) showSelectionOptions(); } function cancelPendingShape() { if (!pendingShape) return; if (editingTextLayerIndex >= 0 && editingTextLayerIndex < layers.length) { const td = layers[editingTextLayerIndex].textData; if (td) { const restorePs = { type: 'text', ox: td.ox, oy: td.oy, rotation: td.rotation, scaleX: td.scaleX, scaleY: td.scaleY, params: { x: td.x, y: td.y, text: td.content, color: td.color, font: td.font, size: td.size, bold: td.bold, italic: td.italic, lineHeight: td.lineHeight } }; layers[editingTextLayerIndex].ctx.clearRect(0, 0, canvasW, canvasH); drawPendingShapeOnCtx(layers[editingTextLayerIndex].ctx, restorePs); compositeLayersToMain(); } editingTextLayerIndex = -1; } pendingShape = null; pendingDragging = false; pendingDragStart = null; pendingTransformHandle = null; pendingTransformStart = null; clearOverlay(); redrawUI(); toolOptions.classList.remove('visible'); toolOptions.innerHTML = ''; if (currentTool) showToolOptions(currentTool); else if (selRect && selRect.w > 5 && selRect.h > 5) showSelectionOptions(); } function findTextLayerAt(cx, cy) { for (let i = layers.length - 1; i >= 0; i--) { const layer = layers[i]; if (!layer.textData || !layer.visible) continue; const td = layer.textData; const tempPs = { type: 'text', ox: td.ox, oy: td.oy, rotation: td.rotation, scaleX: td.scaleX, scaleY: td.scaleY, params: { x: td.x, y: td.y, text: td.content, color: td.color, font: td.font, size: td.size, bold: td.bold, italic: td.italic, lineHeight: td.lineHeight } }; const bbox = getPendingBBox(tempPs); if (!bbox) continue; const rot = td.rotation || 0; let hit; if (rot === 0) { hit = cx >= bbox.x && cx <= bbox.x + bbox.w && cy >= bbox.y && cy <= bbox.y + bbox.h; } else { const pvX = bbox.x + bbox.w / 2, pvY = bbox.y + bbox.h / 2; const cos = Math.cos(-rot), sin = Math.sin(-rot); const dx = cx - pvX, dy = cy - pvY; const nx = pvX + dx * cos - dy * sin, ny = pvY + dx * sin + dy * cos; hit = nx >= bbox.x && nx <= bbox.x + bbox.w && ny >= bbox.y && ny <= bbox.y + bbox.h; } if (hit) return i; } return -1; } function showPendingOptions() { toolOptions.innerHTML = `
`; toolOptions.classList.add('visible'); $('#optPendApply').onclick = commitPendingShape; $('#optPendCancel').onclick = cancelPendingShape; } // Register pending shape handler — intercepts first in dispatcher._dispatch dispatcher.registerPending({ onDown(e) { if (e.button !== 0) return; const p = getPointerCanvas(e); const handle = hitPendingHandle(p.x, p.y); if (handle) { const pivot = getPendingPivot(pendingShape); pendingTransformHandle = handle; pendingTransformStart = { pivotX: pivot.x, pivotY: pivot.y, ox: pendingShape.ox, oy: pendingShape.oy, scaleX: pendingShape.scaleX || 1, scaleY: pendingShape.scaleY || 1, rotation: pendingShape.rotation || 0, handles: getPendingHandles(pendingShape), rotAngle: Math.atan2(p.y - pivot.y, p.x - pivot.x), }; try { viewport.setPointerCapture(e.pointerId); } catch(_) {} viewport.style.cursor = handle === 'rot' ? 'grab' : 'se-resize'; container.style.cursor = handle === 'rot' ? 'grab' : 'se-resize'; return; } if (hitPendingShape(p.x, p.y)) { pendingDragging = true; pendingDragStart = { x: p.x, y: p.y, ox: pendingShape.ox, oy: pendingShape.oy }; try { viewport.setPointerCapture(e.pointerId); } catch(_) {} viewport.style.cursor = 'move'; container.style.cursor = 'move'; return; } // Clicked outside pending shape — commit and re-dispatch commitPendingShape(); dispatcher.onDown(e); }, onMove(e) { if (pendingTransformHandle && pendingShape) { const p = getPointerCanvas(e); if (pendingTransformHandle === 'rot') { const currentAngle = Math.atan2(p.y - pendingTransformStart.pivotY, p.x - pendingTransformStart.pivotX); pendingShape.rotation = pendingTransformStart.rotation + (currentAngle - pendingTransformStart.rotAngle); } else { const origH = pendingTransformStart.handles.find(h => h.id === pendingTransformHandle); const origDist = Math.hypot(origH.x - pendingTransformStart.pivotX, origH.y - pendingTransformStart.pivotY); const newDist = Math.hypot(p.x - pendingTransformStart.pivotX, p.y - pendingTransformStart.pivotY); const factor = origDist > 1 ? newDist / origDist : 1; pendingShape.scaleX = Math.max(0.05, pendingTransformStart.scaleX * factor); pendingShape.scaleY = Math.max(0.05, pendingTransformStart.scaleY * factor); } renderPendingOnOverlay(); return; } if (pendingDragging && pendingShape) { const p = getPointerCanvas(e); pendingShape.ox = pendingDragStart.ox + (p.x - pendingDragStart.x); pendingShape.oy = pendingDragStart.oy + (p.y - pendingDragStart.y); renderPendingOnOverlay(); return; } if (!isDrawing) { const p = getPointerCanvas(e); const handle = hitPendingHandle(p.x, p.y); const cur = handle ? (handle === 'rot' ? 'grab' : 'se-resize') : (hitPendingShape(p.x, p.y) ? 'move' : (currentTool ? 'crosshair' : 'default')); viewport.style.cursor = cur; container.style.cursor = cur; } }, onUp(e) { if (pendingTransformHandle) { pendingTransformHandle = null; pendingTransformStart = null; viewport.style.cursor = 'move'; container.style.cursor = 'move'; return; } if (pendingDragging) { pendingDragging = false; pendingDragStart = null; viewport.style.cursor = 'move'; container.style.cursor = 'move'; return; } } }); // ===================== TEXT TOOL ===================== function showTextOverlay(cx, cy) { const vp = canvasToViewport(cx, cy); textOverlay.style.display = 'block'; textOverlay.style.left = vp.x + 'px'; textOverlay.style.top = vp.y + 'px'; textOverlay.style.transform = `scale(${zoom})`; const sz = Math.max(8, textSize); textArea.style.fontSize = sz + 'px'; textArea.style.fontFamily = textFont === 'sans-serif' ? 'Manrope, sans-serif' : textFont === 'monospace' ? "'JetBrains Mono', monospace" : textFont; textArea.style.fontWeight = textBold ? '700' : '400'; textArea.style.fontStyle = textItalic ? 'italic' : 'normal'; textArea.style.color = fgColor; textArea.style.caretColor = fgColor; textArea.style.minWidth = Math.round(textSize * 4) + 'px'; textArea.textContent = ''; textArea.dataset.cx = cx; textArea.dataset.cy = cy; setTimeout(() => { textArea.focus(); }, 50); } function hideTextOverlay() { textOverlay.style.display = 'none'; textArea.textContent = ''; } function selectAllTextEditor() { const range = document.createRange(); range.selectNodeContents(textArea); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } function commitText() { const text = (textArea.innerText || '').trim().replace(/\n$/, ''); if (!text) { hideTextOverlay(); return; } const cx = +textArea.dataset.cx; const cy = +textArea.dataset.cy; const rotation = parseFloat(textArea.dataset.rotation) || 0; const scaleX = parseFloat(textArea.dataset.scaleX) || 1; const scaleY = parseFloat(textArea.dataset.scaleY) || 1; pendingShape = { type: 'text', ox: 0, oy: 0, rotation, scaleX, scaleY, params: { x: cx, y: cy, text: text, color: fgColor, font: textFont, size: textSize, bold: textBold, italic: textItalic, lineHeight: textSize * 1.3 } }; textArea.dataset.rotation = '0'; textArea.dataset.scaleX = '1'; textArea.dataset.scaleY = '1'; hideTextOverlay(); renderPendingOnOverlay(); showPendingOptions(); } textArea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); const text = (textArea.innerText || '').trim().replace(/\n$/, ''); if (text) { commitText(); } else { hideTextOverlay(); if (pendingShape) commitPendingShape(); } return; } if (e.key === 'Escape') { hideTextOverlay(); if (pendingShape) cancelPendingShape(); return; } e.stopPropagation(); }); textOverlay.addEventListener('pointerdown', e => { e.stopPropagation(); }); textOverlay.addEventListener('click', e => { e.stopPropagation(); }); // ===================== COLOR PICKER ===================== const colorPicker = $('#colorPicker'); const colorSwatch = $('#colorSwatch'); function updateColorSwatch() { colorSwatch.style.background = fgColor; } updateColorSwatch(); colorPicker.addEventListener('input', e => { fgColor = e.target.value; updateColorSwatch(); }); // ===================== EXPORT ===================== $('#btnExport').addEventListener('click', showExportModal); $('#exportCancel').addEventListener('click', hideExportModal); $('#exportConfirm').addEventListener('click', doExport); $('#exportFormat').addEventListener('change', updateExportUI); $('#exportQuality').addEventListener('input', e => { $('#exportQualityVal').textContent = e.target.value; }); function showExportModal() { if (!canvasW) return; $('#exportName').value = fileName; updateExportUI(); $('#exportModal').classList.add('visible'); } function hideExportModal() { $('#exportModal').classList.remove('visible'); } function updateExportUI() { const fmt = $('#exportFormat').value; const showQuality = fmt !== 'png'; $('#qualityRow').style.display = showQuality ? 'flex' : 'none'; const warn = fmt === 'jpeg' && hasTransparency; $('#exportWarning').classList.toggle('visible', warn); } function doExport() { const fmt = $('#exportFormat').value; const quality = +$('#exportQuality').value / 100; const name = $('#exportName').value || 'image'; const ext = fmt === 'jpeg' ? 'jpg' : fmt; const mime = fmt === 'jpeg' ? 'image/jpeg' : fmt === 'webp' ? 'image/webp' : 'image/png'; let exportCanvas = cMain; if (fmt === 'jpeg') { exportCanvas = document.createElement('canvas'); exportCanvas.width = canvasW; exportCanvas.height = canvasH; const ectx = exportCanvas.getContext('2d'); ectx.fillStyle = '#ffffff'; ectx.fillRect(0, 0, canvasW, canvasH); ectx.drawImage(cMain, 0, 0); } exportCanvas.toBlob(blob => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name + '.' + ext; a.click(); URL.revokeObjectURL(url); hideExportModal(); }, mime, quality); } $('#exportModal').addEventListener('click', e => { if (e.target === $('#exportModal')) hideExportModal(); }); // ===================== KEYBOARD SHORTCUTS ===================== document.addEventListener('keydown', e => { if (e.code === 'Space' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); spaceDown = true; if (canvasW) { viewport.style.cursor = 'grab'; container.style.cursor = 'grab'; } return; } if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; const ctrl = e.ctrlKey || e.metaKey; if (ctrl && e.code === 'KeyO') { e.preventDefault(); fileInput.click(); return; } if (ctrl && e.code === 'KeyT') { e.preventDefault(); saveCurrentTabState(); const _nt = createTab('Новый'); activeTabId = _nt.id; layers = []; history = []; historyIndex = -1; canvasW = 0; canvasH = 0; selRect = null; loadTabState(_nt); return; } if (ctrl && e.code === 'KeyW') { e.preventDefault(); closeTab(activeTabId); return; } if (ctrl && e.code === 'KeyS') { e.preventDefault(); showExportModal(); return; } if (ctrl && e.code === 'KeyZ' && !e.shiftKey) { e.preventDefault(); if (pendingShape) { cancelPendingShape(); } else { undo(); } return; } if (ctrl && e.code === 'KeyZ' && e.shiftKey) { e.preventDefault(); redo(); return; } if (ctrl && e.code === 'KeyC' && !e.shiftKey) { e.preventDefault(); if (!canvasW) return; // Copy selection (or whole image) from active layer to internal clipboard const src = layers[activeLayerIndex]; if (selRect && selRect.w > 5 && selRect.h > 5) { const sx = Math.round(clamp(selRect.x, 0, canvasW)); const sy = Math.round(clamp(selRect.y, 0, canvasH)); const sw = Math.round(clamp(selRect.w, 1, canvasW - sx)); const sh = Math.round(clamp(selRect.h, 1, canvasH - sy)); const data = src.ctx.getImageData(sx, sy, sw, sh); internalClipboard = { imageData: data, x: sx, y: sy, w: sw, h: sh }; } else if (selPath && selPath.length > 2) { // Lasso: use bounding box let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; selPath.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); const sx = Math.round(clamp(minX, 0, canvasW)); const sy = Math.round(clamp(minY, 0, canvasH)); const sw = Math.round(clamp(maxX - minX, 1, canvasW - sx)); const sh = Math.round(clamp(maxY - minY, 1, canvasH - sy)); // Create masked copy const tmp = document.createElement('canvas'); tmp.width = sw; tmp.height = sh; const tc = tmp.getContext('2d'); tc.beginPath(); selPath.forEach((p, i) => i === 0 ? tc.moveTo(p.x - sx, p.y - sy) : tc.lineTo(p.x - sx, p.y - sy)); tc.closePath(); tc.clip(); tc.drawImage(src.canvas, -sx, -sy); internalClipboard = { imageData: tc.getImageData(0, 0, sw, sh), x: sx, y: sy, w: sw, h: sh }; } else { // No selection: copy whole layer const data = src.ctx.getImageData(0, 0, canvasW, canvasH); internalClipboard = { imageData: data, x: 0, y: 0, w: canvasW, h: canvasH }; } return; } if (ctrl && e.code === 'KeyX' && !e.shiftKey) { e.preventDefault(); if (!canvasW) return; const src = layers[activeLayerIndex]; if (selRect && selRect.w > 5 && selRect.h > 5) { const sx = Math.round(clamp(selRect.x, 0, canvasW)); const sy = Math.round(clamp(selRect.y, 0, canvasH)); const sw = Math.round(clamp(selRect.w, 1, canvasW - sx)); const sh = Math.round(clamp(selRect.h, 1, canvasH - sy)); internalClipboard = { imageData: src.ctx.getImageData(sx, sy, sw, sh), x: sx, y: sy, w: sw, h: sh }; src.ctx.clearRect(sx, sy, sw, sh); } else if (selPath && selPath.length > 2) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; selPath.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); const sx = Math.round(clamp(minX, 0, canvasW)); const sy = Math.round(clamp(minY, 0, canvasH)); const sw = Math.round(clamp(maxX - minX, 1, canvasW - sx)); const sh = Math.round(clamp(maxY - minY, 1, canvasH - sy)); const tmp = document.createElement('canvas'); tmp.width = sw; tmp.height = sh; const tc = tmp.getContext('2d'); tc.beginPath(); selPath.forEach((p, i) => i === 0 ? tc.moveTo(p.x - sx, p.y - sy) : tc.lineTo(p.x - sx, p.y - sy)); tc.closePath(); tc.clip(); tc.drawImage(src.canvas, -sx, -sy); internalClipboard = { imageData: tc.getImageData(0, 0, sw, sh), x: sx, y: sy, w: sw, h: sh }; // Erase lasso area from layer src.ctx.save(); src.ctx.beginPath(); selPath.forEach((p, i) => i === 0 ? src.ctx.moveTo(p.x, p.y) : src.ctx.lineTo(p.x, p.y)); src.ctx.closePath(); src.ctx.clip(); src.ctx.clearRect(sx, sy, sw, sh); src.ctx.restore(); } else { internalClipboard = { imageData: src.ctx.getImageData(0, 0, canvasW, canvasH), x: 0, y: 0, w: canvasW, h: canvasH }; src.ctx.clearRect(0, 0, canvasW, canvasH); } compositeLayersToMain(); pushHistory(); clearSelection(); return; } if (ctrl && e.code === 'KeyV' && !e.shiftKey) { if (internalClipboard) { e.preventDefault(); // Paste to new layer const layer = createLayer('Вставка', canvasW, canvasH); if (!layer) return; layer.ctx.putImageData(internalClipboard.imageData, internalClipboard.x, internalClipboard.y); layers.splice(activeLayerIndex + 1, 0, layer); activeLayerIndex++; compositeLayersToMain(); renderLayerPanel(); pushHistory(); // Switch to select tool so user can move the pasted content setTool('select'); return; } // If no internal clipboard, let default paste handler (from clipboard API) work return; } if (ctrl && e.key === '0') { e.preventDefault(); fitToViewport(); return; } if (ctrl && e.key === '1') { e.preventDefault(); zoom = 1; panX = (viewport.clientWidth - canvasW) / 2; panY = (viewport.clientHeight - canvasH) / 2; updateTransform(); return; } if (e.key === 'Escape') { if (pendingShape) { cancelPendingShape(); } else if (currentTool) { cancelCurrentTool(); } clearSelection(); return; } if (e.key === 'Enter') { if (pendingShape) { e.preventDefault(); commitPendingShape(); return; } if (selRect && !currentTool) { e.preventDefault(); applySelectionCrop(); return; } if (currentTool === 'crop' && cropRect) { e.preventDefault(); applyCrop(); return; } } if (!canvasW) return; const toolMap = { v:'select', b:'brush', e:'eraser', t:'text', c:'crop', l:'line', u:'rect', o:'circle', a:'arrow', i:'eyedropper', g:'fill', s:'clone', w:'lasso' }; if (toolMap[e.key.toLowerCase()] && !ctrl) { setTool(toolMap[e.key.toLowerCase()]); return; } if (e.code === 'BracketLeft' || e.code === 'BracketRight') { const delta = e.code === 'BracketLeft' ? -2 : 2; if (['brush', 'eraser', 'clone'].includes(currentTool)) { brushSize = Math.max(1, Math.min(100, brushSize + delta)); if ($('#optSize')) { $('#optSize').value = brushSize; } if ($('#optSizeVal')) { $('#optSizeVal').textContent = brushSize; } } else if (['line', 'rect', 'circle', 'arrow'].includes(currentTool)) { strokeWidth = Math.max(1, Math.min(20, strokeWidth + (delta > 0 ? 1 : -1))); if ($('#optStroke')) { $('#optStroke').value = strokeWidth; } if ($('#optStrokeVal')) { $('#optStrokeVal').textContent = strokeWidth; } } return; } }); document.addEventListener('keyup', e => { if (e.code === 'Space') { spaceDown = false; const cur = currentTool ? 'crosshair' : 'default'; viewport.style.cursor = cur; container.style.cursor = cur; } }); // ===================== IMAGE BORDER ===================== function drawImageBorder() { if (!canvasW) return; const ctx = ctxUI; const tl = c2s(0, 0); const br = c2s(canvasW, canvasH); ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.restore(); } // ===================== PIXEL RULERS ===================== function drawRulers() { if (!canvasW) return; const ctx = ctxUI; const vw = viewport.clientWidth; const vh = viewport.clientHeight; const rulerH = 20; ctx.save(); ctx.fillStyle = 'rgba(9,16,32,0.85)'; ctx.fillRect(0, 0, vw, rulerH); ctx.fillRect(0, rulerH, rulerH, vh - rulerH); let step = 100; if (zoom >= 4) step = 10; else if (zoom >= 1.5) step = 25; else if (zoom >= 0.5) step = 50; else if (zoom < 0.2) step = 200; ctx.font = '9px JetBrains Mono, monospace'; ctx.fillStyle = '#666'; ctx.strokeStyle = '#444'; ctx.lineWidth = 0.5; const startX = Math.floor(-panX / zoom / step) * step; const endX = Math.ceil((vw - panX) / zoom / step) * step; for (let cx = startX; cx <= endX; cx += step) { const sx = cx * zoom + panX; if (sx < rulerH || sx > vw) continue; ctx.beginPath(); ctx.moveTo(sx, rulerH - 6); ctx.lineTo(sx, rulerH); ctx.stroke(); ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(cx, sx, rulerH - 7); } const minor = step / 5; if (minor * zoom >= 4) { const ms = Math.floor(-panX / zoom / minor) * minor; const me = Math.ceil((vw - panX) / zoom / minor) * minor; for (let cx = ms; cx <= me; cx += minor) { const sx = cx * zoom + panX; if (sx < rulerH || sx > vw) continue; ctx.beginPath(); ctx.moveTo(sx, rulerH - 3); ctx.lineTo(sx, rulerH); ctx.stroke(); } } const startY = Math.floor(-panY / zoom / step) * step; const endY = Math.ceil((vh - panY) / zoom / step) * step; for (let cy = startY; cy <= endY; cy += step) { const sy = cy * zoom + panY; if (sy < rulerH || sy > vh) continue; ctx.beginPath(); ctx.moveTo(rulerH - 6, sy); ctx.lineTo(rulerH, sy); ctx.stroke(); ctx.save(); ctx.translate(rulerH - 7, sy); ctx.rotate(-Math.PI/2); ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(cy, 0, 0); ctx.restore(); } if (minor * zoom >= 4) { const ms2 = Math.floor(-panY / zoom / minor) * minor; const me2 = Math.ceil((vh - panY) / zoom / minor) * minor; for (let cy = ms2; cy <= me2; cy += minor) { const sy = cy * zoom + panY; if (sy < rulerH || sy > vh) continue; ctx.beginPath(); ctx.moveTo(rulerH - 3, sy); ctx.lineTo(rulerH, sy); ctx.stroke(); } } ctx.fillStyle = 'rgba(9,16,32,0.85)'; ctx.fillRect(0, 0, rulerH, rulerH); ctx.restore(); } // ===================== WINDOW RESIZE ===================== window.addEventListener('resize', () => { resizeUICanvas(); if (canvasW) fitToViewport(); }); // ===================== CONTEXT MENU ===================== const ctxMenu = $('#ctxMenu'); function showCtxMenu(x, y) { const hasImage = canvasW > 0; const hasSel = selRect && selRect.w > 5 && selRect.h > 5; ctxMenu.querySelector('[data-action="undo"]').classList.toggle('disabled', historyIndex <= 0); ctxMenu.querySelector('[data-action="redo"]').classList.toggle('disabled', historyIndex >= history.length - 1); ctxMenu.querySelector('[data-action="crop-sel"]').classList.toggle('disabled', !hasSel); ctxMenu.querySelector('[data-action="resize"]').classList.toggle('disabled', !hasImage); ctxMenu.querySelector('[data-action="rot-cw"]').classList.toggle('disabled', !hasImage); ctxMenu.querySelector('[data-action="rot-ccw"]').classList.toggle('disabled', !hasImage); ctxMenu.querySelector('[data-action="flip-h"]').classList.toggle('disabled', !hasImage); ctxMenu.querySelector('[data-action="flip-v"]').classList.toggle('disabled', !hasImage); ctxMenu.querySelector('[data-action="copy"]').classList.toggle('disabled', !hasImage); ctxMenu.querySelector('[data-action="export"]').classList.toggle('disabled', !hasImage); ctxMenu.style.left = '0'; ctxMenu.style.top = '0'; ctxMenu.classList.add('visible'); const mw = ctxMenu.offsetWidth, mh = ctxMenu.offsetHeight; const vw = window.innerWidth, vh = window.innerHeight; ctxMenu.style.left = Math.min(x, vw - mw - 8) + 'px'; ctxMenu.style.top = Math.min(y, vh - mh - 8) + 'px'; } function hideCtxMenu() { ctxMenu.classList.remove('visible'); } viewport.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e.clientX, e.clientY); }); document.addEventListener('pointerdown', e => { if (!ctxMenu.contains(e.target)) hideCtxMenu(); }); document.addEventListener('keydown', e => { if (ctxMenu.classList.contains('visible') && e.key === 'Escape') { hideCtxMenu(); e.stopPropagation(); } }, true); ctxMenu.addEventListener('click', e => { const item = e.target.closest('.ctx-item'); if (!item || item.classList.contains('disabled')) return; const action = item.dataset.action; hideCtxMenu(); switch (action) { case 'undo': undo(); break; case 'redo': redo(); break; case 'crop-sel': if (selRect) applySelectionCrop(); break; case 'resize': $('#btnResize').click(); break; case 'rot-cw': rotate90(1); break; case 'rot-ccw': rotate90(-1); break; case 'flip-h': flip('h'); break; case 'flip-v': flip('v'); break; case 'fit': fitToViewport(); break; case 'zoom100': zoom = 1; panX = (viewport.clientWidth - canvasW) / 2; panY = (viewport.clientHeight - canvasH) / 2; updateTransform(); break; case 'copy': if (canvasW) cMain.toBlob(b => { navigator.clipboard.write([new ClipboardItem({'image/png': b})]).catch(()=>{}); }); break; case 'paste': navigator.clipboard.read().then(items => { for (const item of items) { for (const type of item.types) { if (type.startsWith('image/')) { item.getType(type).then(blob => { const f = new File([blob], 'pasted.png', {type}); loadImage(f); }); return; } } } }).catch(()=>{}); break; case 'export': showExportModal(); break; } }); // ===================== LAYER PANEL ===================== $('#btnLayers').addEventListener('click', () => { layerPanelVisible = !layerPanelVisible; layerPanel.classList.toggle('visible', layerPanelVisible); $('#btnLayers').classList.toggle('active', layerPanelVisible); if (layerPanelVisible) renderLayerPanel(); }); function renderLayerPanel() { if (!layerPanelVisible && !layerPanel.classList.contains('visible')) { updateStatusLayer(); return; } layerList.innerHTML = ''; for (let i = layers.length - 1; i >= 0; i--) { const layer = layers[i]; const item = document.createElement('div'); item.className = 'layer-item' + (i === activeLayerIndex ? ' active' : ''); item.dataset.index = i; item.draggable = true; const vis = document.createElement('button'); vis.className = 'layer-vis' + (layer.visible ? '' : ' off'); vis.innerHTML = layer.visible ? '👁' : '○'; vis.title = 'Видимость'; vis.addEventListener('click', e => { e.stopPropagation(); layer.visible = !layer.visible; compositeLayersToMain(); renderLayerPanel(); pushHistory(); }); const thumbWrap = document.createElement('div'); thumbWrap.className = 'layer-thumb'; const thumbCanvas = document.createElement('canvas'); thumbCanvas.width = 32; thumbCanvas.height = 32; const tc = thumbCanvas.getContext('2d'); if (layer.canvas.width && layer.canvas.height) { const scale = Math.min(32 / layer.canvas.width, 32 / layer.canvas.height); const w = layer.canvas.width * scale; const h = layer.canvas.height * scale; tc.drawImage(layer.canvas, (32 - w) / 2, (32 - h) / 2, w, h); } thumbWrap.appendChild(thumbCanvas); const info = document.createElement('div'); info.className = 'layer-info'; const nameEl = document.createElement('div'); nameEl.className = 'layer-name'; nameEl.textContent = (layer.textData ? 'T ' : '') + layer.name; if (layer.textData) nameEl.style.color = 'var(--accent)'; const meta = document.createElement('div'); meta.className = 'layer-meta'; meta.textContent = Math.round(layer.opacity * 100) + '%' + (layer.blendMode !== 'source-over' ? ' ' + layer.blendMode : ''); info.appendChild(nameEl); info.appendChild(meta); item.appendChild(vis); item.appendChild(thumbWrap); item.appendChild(info); item.addEventListener('click', () => { activeLayerIndex = i; renderLayerPanel(); }); item.addEventListener('dblclick', e => { e.stopPropagation(); const input = document.createElement('input'); input.className = 'layer-name-input'; input.value = layer.name; nameEl.replaceWith(input); input.focus(); input.select(); const finish = () => { layer.name = input.value.trim() || layer.name; renderLayerPanel(); }; input.addEventListener('blur', finish); input.addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); } if (ev.key === 'Escape') { input.value = layer.name; input.blur(); } ev.stopPropagation(); }); }); item.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', i.toString()); e.dataTransfer.effectAllowed = 'move'; item.style.opacity = '0.5'; }); item.addEventListener('dragend', () => { item.style.opacity = '1'; }); item.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = item.getBoundingClientRect(); const mid = rect.top + rect.height / 2; item.classList.remove('drag-over-top', 'drag-over-bottom'); item.classList.add(e.clientY < mid ? 'drag-over-top' : 'drag-over-bottom'); }); item.addEventListener('dragleave', () => { item.classList.remove('drag-over-top', 'drag-over-bottom'); }); item.addEventListener('drop', e => { e.preventDefault(); item.classList.remove('drag-over-top', 'drag-over-bottom'); const fromIdx = parseInt(e.dataTransfer.getData('text/plain')); const rect = item.getBoundingClientRect(); const mid = rect.top + rect.height / 2; let toIdx = i; if (e.clientY < mid && fromIdx < i) toIdx = i; else if (e.clientY >= mid && fromIdx > i) toIdx = i; else if (e.clientY < mid && fromIdx > i) toIdx = i + 1; else if (e.clientY >= mid && fromIdx < i) toIdx = i - 1; if (fromIdx !== toIdx) moveLayer(fromIdx, toIdx); }); layerList.appendChild(item); } if (layers.length > 0) { const active = layers[activeLayerIndex]; $('#lpBlend').value = active.blendMode; $('#lpOpacity').value = Math.round(active.opacity * 100); $('#lpOpacityVal').textContent = Math.round(active.opacity * 100) + '%'; } $('#lpDel').disabled = layers.length <= 1; $('#lpMerge').disabled = activeLayerIndex <= 0; $('#lpAdd').disabled = layers.length >= MAX_LAYERS; updateStatusLayer(); } // Layer panel buttons $('#lpAdd').addEventListener('click', () => addLayer()); $('#lpDel').addEventListener('click', () => removeLayer(activeLayerIndex)); $('#lpMerge').addEventListener('click', () => mergeDown(activeLayerIndex)); $('#lpFlatten').addEventListener('click', () => flattenLayers()); $('#lpBlend').addEventListener('change', e => { if (layers.length === 0) return; layers[activeLayerIndex].blendMode = e.target.value; compositeLayersToMain(); renderLayerPanel(); pushHistory(); }); $('#lpOpacity').addEventListener('input', e => { if (layers.length === 0) return; layers[activeLayerIndex].opacity = +e.target.value / 100; $('#lpOpacityVal').textContent = e.target.value + '%'; compositeLayersToMain(); }); $('#lpOpacity').addEventListener('change', () => { renderLayerPanel(); pushHistory(); }); layerPanel.addEventListener('pointerdown', e => e.stopPropagation()); layerPanel.addEventListener('keydown', e => e.stopPropagation()); // ===================== LAYER SHORTCUTS ===================== document.addEventListener('keydown', e => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; const ctrl = e.ctrlKey || e.metaKey; if (ctrl && e.shiftKey && e.code === 'KeyN') { e.preventDefault(); if (canvasW) addLayer(); return; } if (ctrl && !e.shiftKey && e.code === 'KeyE') { e.preventDefault(); if (canvasW) mergeDown(activeLayerIndex); return; } if (ctrl && e.shiftKey && e.code === 'KeyE') { e.preventDefault(); if (canvasW) flattenLayers(); return; } }, true); // ===================== SETTINGS PERSISTENCE ===================== const SETTINGS_KEY = 'editor_settings_v1'; function saveSettings() { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify({ brushSize, brushOpacity, strokeWidth, fillShape, fgColor, textFont, textSize, textBold, textItalic, fillTolerance, blurRadius, sharpenAmount })); } catch(_) {} } function loadSettings() { try { const s = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}'); if (s.brushSize) brushSize = s.brushSize; if (s.brushOpacity) brushOpacity = s.brushOpacity; if (s.strokeWidth) strokeWidth = s.strokeWidth; if (typeof s.fillShape === 'boolean') fillShape = s.fillShape; if (s.fgColor) { fgColor = s.fgColor; $('#colorPicker').value = fgColor; updateColorSwatch(); } if (s.textFont) textFont = s.textFont; if (s.textSize) textSize = s.textSize; if (typeof s.textBold === 'boolean') textBold = s.textBold; if (typeof s.textItalic === 'boolean') textItalic = s.textItalic; if (s.fillTolerance !== undefined) fillTolerance = s.fillTolerance; if (s.blurRadius !== undefined) blurRadius = s.blurRadius; if (s.sharpenAmount !== undefined) sharpenAmount = s.sharpenAmount; } catch(_) {} } let _settingsSaveTimer = 0; function scheduleSaveSettings() { clearTimeout(_settingsSaveTimer); _settingsSaveTimer = setTimeout(saveSettings, 500); } colorPicker.addEventListener('input', scheduleSaveSettings); const _settingsObserver = new MutationObserver(() => { ['optSize','optOpacity','optStroke','optTolerance','optBlurR','optSharpAmt'].forEach(id => { const el = document.getElementById(id); if (el && !el.dataset.settingsHooked) { el.dataset.settingsHooked = '1'; el.addEventListener('change', scheduleSaveSettings); } }); }); _settingsObserver.observe(toolOptions, { childList: true }); loadSettings(); // ===================== INIT TABS ===================== (function initTabs() { const first = createTab('Новый'); activeTabId = first.id; renderTabBar(); })(); })();