wa-dev-tools/public/editor.js
2026-03-21 22:06:15 +03:00

3602 lines
137 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(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 = '&times;';
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 = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>';
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 = `<label>Перемещение слоя — перетаскивайте содержимое</label>`;
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 `<label>${label}</label><input type="range" id="${id}" min="${min}" max="${max}" value="${value}"><span id="${id}Val" style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted);width:30px">${value}</span>`;
}
function showToolOptions(tool) {
toolOptions.innerHTML = '';
toolOptions.classList.add('visible');
if (tool === 'brush' || tool === 'eraser') {
toolOptions.innerHTML = `
${buildRangeRow('optSize', 'Размер', 1, 100, brushSize)}
<div class="opt-sep"></div>
${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)}
<div class="opt-sep"></div>
<button class="opt-toggle ${fillShape?'on':''}" id="optFill">Заливка</button>
`;
$('#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 = `
<label>Пропорции</label>
<select id="optCropRatio">
<option value="0">Свободно</option>
<option value="${1/1}">1:1</option>
<option value="${4/3}">4:3</option>
<option value="${16/9}">16:9</option>
<option value="${3/2}">3:2</option>
<option value="${2/3}">2:3</option>
<option value="${9/16}">9:16</option>
</select>
<div class="opt-sep"></div>
<button class="opt-btn primary" id="optCropApply">Применить</button>
<button class="opt-btn" id="optCropCancel">Отмена</button>
`;
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 = `
<label>Шрифт</label>
<select id="optFont">
<option value="sans-serif">Sans</option>
<option value="serif">Serif</option>
<option value="monospace">Mono</option>
<option value="Manrope,sans-serif">Manrope</option>
<option value="'JetBrains Mono',monospace">JetBrains</option>
</select>
<label>Размер</label><input type="number" id="optTextSize" min="8" max="200" value="${textSize}">
<div class="opt-sep"></div>
<button class="opt-toggle ${textBold?'on':''}" id="optBold" style="font-weight:700">B</button>
<button class="opt-toggle ${textItalic?'on':''}" id="optItalic" style="font-style:italic">I</button>
`;
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 = `<label>Нажмите на холст чтобы выбрать цвет</label>`;
}
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)}
<div class="opt-sep"></div>
${buildRangeRow('optOpacity', 'Прозр.', 1, 100, brushOpacity)}
<div class="opt-sep"></div>
<label id="cloneHint" style="color:var(--accent)">Alt+клик — выбрать источник</label>
`;
$('#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 = `
<label>Яркость</label><input type="range" id="optBright" min="-100" max="100" value="0"><span id="optBrightVal" style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted);width:30px">0</span>
<div class="opt-sep"></div>
<label>Контраст</label><input type="range" id="optContrast" min="-100" max="100" value="0"><span id="optContrastVal" style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted);width:30px">0</span>
<div class="opt-sep"></div>
<label>Насыщ.</label><input type="range" id="optSatur" min="-100" max="100" value="0"><span id="optSaturVal" style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted);width:30px">0</span>
<div class="opt-sep"></div>
<button class="opt-btn primary" id="optAdjApply">Применить</button>
<button class="opt-btn" id="optAdjCancel">Отмена</button>
`;
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 = `
<label>Радиус</label><input type="range" id="optBlurR" min="1" max="20" value="${blurRadius}"><span id="optBlurRVal" style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted);width:24px">${blurRadius}</span>
<div class="opt-sep"></div>
<button class="opt-btn primary" id="optBlurApply">Применить</button>
<button class="opt-btn" id="optBlurCancel">Отмена</button>
`;
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 = `
<label>Сила</label><input type="range" id="optSharpAmt" min="1" max="100" value="${sharpenAmount}"><span id="optSharpAmtVal" style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted);width:24px">${sharpenAmount}</span>
<div class="opt-sep"></div>
<button class="opt-btn primary" id="optSharpApply">Применить</button>
<button class="opt-btn" id="optSharpCancel">Отмена</button>
`;
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 = `
<label>Ш</label><input type="number" id="optResW" min="1" max="16384" value="${resizeW}" style="width:100px;">
<button class="opt-toggle on" id="optResLink" title="Keep aspect ratio">&#x1f517;</button>
<label>В</label><input type="number" id="optResH" min="1" max="16384" value="${resizeH}" style="width:100px;">
<div class="opt-sep"></div>
<button class="opt-btn primary" id="optResApply">Применить</button>
<button class="opt-btn" id="optResCancel">Отмена</button>
`;
$('#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 = `
<label>${Math.round(selRect.w)} x ${Math.round(selRect.h)}</label>
<div class="opt-sep"></div>
<button class="opt-btn primary" id="optSelCrop">Обрезать</button>
<button class="opt-btn" id="optSelCancel">Отмена</button>
`;
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 = `
<label>${sw} × ${sh}</label>
<div class="opt-sep"></div>
<button class="opt-btn primary" id="optLassoCrop">Обрезать</button>
<button class="opt-btn" id="optLassoCancel">Отмена</button>
`;
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 = `
<label style="color:var(--accent)">Перемещение / Трансформация</label>
<div class="opt-sep"></div>
<button class="opt-btn primary" id="optPendApply">Применить (Enter)</button>
<button class="opt-btn" id="optPendCancel">Отмена (Esc)</button>
`;
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 ? '&#x1f441;' : '&#x25CB;';
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();
})();
})();