3602 lines
137 KiB
JavaScript
3602 lines
137 KiB
JavaScript
(function(){
|
||
'use strict';
|
||
|
||
// ===================== DOM =====================
|
||
const $ = s => document.querySelector(s);
|
||
const viewport = $('#viewport');
|
||
const container = $('#canvasContainer');
|
||
const cMain = $('#canvasMain');
|
||
const cOverlay = $('#canvasOverlay');
|
||
const cUI = $('#canvasUI');
|
||
const ctxMain = cMain.getContext('2d');
|
||
const ctxOverlay = cOverlay.getContext('2d');
|
||
const ctxUI = cUI.getContext('2d');
|
||
const dropzone = $('#dropzone');
|
||
const fileInput = $('#fileInput');
|
||
const toolOptions = $('#toolOptions');
|
||
const textOverlay = $('#textOverlay');
|
||
const textArea = $('#textArea');
|
||
const layerPanel = $('#layerPanel');
|
||
const layerList = $('#layerList');
|
||
|
||
// ===================== STATE =====================
|
||
let img = null;
|
||
let fileName = 'image';
|
||
let hasTransparency = false;
|
||
let canvasW = 0, canvasH = 0;
|
||
|
||
// Zoom/Pan
|
||
let zoom = 1;
|
||
let panX = 0, panY = 0;
|
||
let isPanning = false;
|
||
let panStart = {x:0,y:0};
|
||
let spaceDown = false;
|
||
|
||
// Current tool
|
||
let currentTool = null;
|
||
|
||
// History (sync ImageData-based)
|
||
const MAX_HISTORY = 15;
|
||
let history = [];
|
||
let historyIndex = -1;
|
||
|
||
// Drawing state
|
||
let isDrawing = false;
|
||
let drawStart = {x:0,y:0};
|
||
let lastDraw = {x:0,y:0};
|
||
|
||
// Tool settings
|
||
let brushSize = 8;
|
||
let brushOpacity = 100;
|
||
let strokeWidth = 2;
|
||
let fillShape = false;
|
||
let fgColor = '#0054e6';
|
||
|
||
// Crop state
|
||
let cropRect = null;
|
||
let cropDragging = null;
|
||
let cropStart = null;
|
||
let cropAspect = 0;
|
||
|
||
// Internal clipboard for selection copy/paste
|
||
let internalClipboard = null; // { imageData, x, y, w, h }
|
||
|
||
// Layer move state
|
||
let layerMoving = false;
|
||
let layerMoveStart = null;
|
||
let layerMoveSnapshot = null;
|
||
|
||
// Text state
|
||
let textFont = 'sans-serif';
|
||
let textSize = 32;
|
||
let textBold = false;
|
||
let textItalic = false;
|
||
|
||
// Adjustments
|
||
let adjBrightness = 0;
|
||
let adjContrast = 0;
|
||
let adjSaturation = 0;
|
||
|
||
// Resize state
|
||
let resizeW = 0, resizeH = 0, resizeKeepRatio = true, resizeOrigW = 0, resizeOrigH = 0;
|
||
|
||
// Clone stamp state
|
||
let cloneSrc = null;
|
||
let cloneOffset = null;
|
||
let cloneSrcSet = false;
|
||
|
||
// Fill tool
|
||
let fillTolerance = 30;
|
||
|
||
// Blur / Sharpen
|
||
let blurRadius = 5;
|
||
let sharpenAmount = 50;
|
||
|
||
// Selection state
|
||
let selRect = null;
|
||
let selPath = null; // lasso path: [{x,y}, ...]
|
||
let selDragging = false;
|
||
let selDragMode = null;
|
||
let selStart = {x:0, y:0};
|
||
let selStartRect = null;
|
||
// Marching ants animation
|
||
let marchRaf = 0;
|
||
let marchOffset = 0;
|
||
|
||
// Pending shape state
|
||
let pendingShape = null;
|
||
let pendingDragging = false;
|
||
let pendingDragStart = null;
|
||
let pendingTransformHandle = null;
|
||
let pendingTransformStart = null;
|
||
|
||
// Text layer re-editing
|
||
let editingTextLayerIndex = -1;
|
||
|
||
// Tabs
|
||
let tabs = [];
|
||
let activeTabId = 0;
|
||
let tabIdCounter = 0;
|
||
|
||
// Layer system
|
||
const MAX_LAYERS = 10;
|
||
let layers = [];
|
||
let activeLayerIndex = 0;
|
||
let layerIdCounter = 0;
|
||
let layerPanelVisible = false;
|
||
let compositeDirtyRaf = 0;
|
||
|
||
// ===================== HELPERS =====================
|
||
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
||
|
||
function canvasToViewport(cx, cy) {
|
||
return { x: cx * zoom + panX, y: cy * zoom + panY };
|
||
}
|
||
|
||
function viewportToCanvas(vx, vy) {
|
||
return { x: (vx - panX) / zoom, y: (vy - panY) / zoom };
|
||
}
|
||
|
||
function getPointerCanvas(e) {
|
||
const rect = viewport.getBoundingClientRect();
|
||
const vx = e.clientX - rect.left;
|
||
const vy = e.clientY - rect.top;
|
||
return viewportToCanvas(vx, vy);
|
||
}
|
||
|
||
function updateTransform() {
|
||
container.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
|
||
$('#zoomLabel').textContent = Math.round(zoom * 100) + '%';
|
||
$('#statusZoom').textContent = Math.round(zoom * 100) + '%';
|
||
redrawUI();
|
||
}
|
||
|
||
function redrawUI() {
|
||
clearUI();
|
||
drawRulers();
|
||
drawImageBorder();
|
||
if (selPath && selPath.length > 2) drawLassoOutline();
|
||
else if (selRect && selRect.w > 5) drawSelectionRect(false);
|
||
else if (cropRect) drawCropOverlay(false);
|
||
if (pendingShape) drawPendingBBoxOnUI();
|
||
}
|
||
|
||
function fitToViewport() {
|
||
if (!canvasW || !canvasH) return;
|
||
const vw = viewport.clientWidth;
|
||
const vh = viewport.clientHeight;
|
||
const scaleX = (vw - 40) / canvasW;
|
||
const scaleY = (vh - 40) / canvasH;
|
||
zoom = Math.min(scaleX, scaleY, 1);
|
||
panX = (vw - canvasW * zoom) / 2;
|
||
panY = (vh - canvasH * zoom) / 2;
|
||
updateTransform();
|
||
}
|
||
|
||
function setCanvasSize(w, h) {
|
||
canvasW = w; canvasH = h;
|
||
[cMain, cOverlay].forEach(c => { c.width = w; c.height = h; });
|
||
container.style.width = w + 'px';
|
||
container.style.height = h + 'px';
|
||
selRect = null;
|
||
selPath = null;
|
||
stopMarchingAnts();
|
||
pendingShape = null;
|
||
$('#statusDims').textContent = w + ' x ' + h;
|
||
resizeUICanvas();
|
||
}
|
||
|
||
function resizeUICanvas() {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const vw = viewport.clientWidth;
|
||
const vh = viewport.clientHeight;
|
||
cUI.width = vw * dpr;
|
||
cUI.height = vh * dpr;
|
||
cUI.style.width = vw + 'px';
|
||
cUI.style.height = vh + 'px';
|
||
ctxUI.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
}
|
||
|
||
// Convert canvas coords to screen (viewport-local) coords for UI drawing
|
||
function c2s(cx, cy) {
|
||
return { x: cx * zoom + panX, y: cy * zoom + panY };
|
||
}
|
||
|
||
// ===================== TOOL DISPATCHER =====================
|
||
class ToolDispatcher {
|
||
constructor() {
|
||
this._tools = new Map(); // toolName → {onDown, onMove, onUp}
|
||
this._pendingHandler = null;
|
||
this._selectionHandler = null;
|
||
}
|
||
|
||
registerTool(name, handler) { this._tools.set(name, handler); }
|
||
registerPending(handler) { this._pendingHandler = handler; }
|
||
registerSelection(handler) { this._selectionHandler = handler; }
|
||
|
||
_dispatch(type, e) {
|
||
if (!canvasW) return false;
|
||
// pendingShape перехватывает первым
|
||
if (pendingShape && this._pendingHandler && this._pendingHandler[type]) {
|
||
this._pendingHandler[type](e);
|
||
return true;
|
||
}
|
||
// нет инструмента → selection
|
||
if (!currentTool && this._selectionHandler && this._selectionHandler[type]) {
|
||
this._selectionHandler[type](e);
|
||
return true;
|
||
}
|
||
// инструмент
|
||
const h = this._tools.get(currentTool);
|
||
if (h && h[type]) { h[type](e); return true; }
|
||
return false;
|
||
}
|
||
|
||
onDown(e) { this._dispatch('onDown', e); }
|
||
onMove(e) { this._dispatch('onMove', e); }
|
||
onUp(e) { this._dispatch('onUp', e); }
|
||
}
|
||
const dispatcher = new ToolDispatcher();
|
||
|
||
// ===================== LAYER SYSTEM =====================
|
||
function createLayer(name, w, h) {
|
||
if (layers.length >= MAX_LAYERS) return null;
|
||
const c = document.createElement('canvas');
|
||
c.width = w || canvasW;
|
||
c.height = h || canvasH;
|
||
const layer = {
|
||
id: ++layerIdCounter,
|
||
name: name || ('Слой ' + layerIdCounter),
|
||
canvas: c,
|
||
ctx: c.getContext('2d'),
|
||
visible: true,
|
||
opacity: 1,
|
||
blendMode: 'source-over'
|
||
};
|
||
return layer;
|
||
}
|
||
|
||
function getActiveLayer() { return layers[activeLayerIndex]; }
|
||
function activeCtx() { return layers[activeLayerIndex].ctx; }
|
||
function activeCanvas() { return layers[activeLayerIndex].canvas; }
|
||
|
||
function compositeLayersToMain() {
|
||
ctxMain.clearRect(0, 0, canvasW, canvasH);
|
||
for (const layer of layers) {
|
||
if (!layer.visible || layer.opacity === 0) continue;
|
||
ctxMain.save();
|
||
ctxMain.globalAlpha = layer.opacity;
|
||
ctxMain.globalCompositeOperation = layer.blendMode;
|
||
ctxMain.drawImage(layer.canvas, 0, 0);
|
||
ctxMain.restore();
|
||
}
|
||
}
|
||
|
||
function markCompositeDirty() {
|
||
if (compositeDirtyRaf) return;
|
||
compositeDirtyRaf = requestAnimationFrame(() => {
|
||
compositeDirtyRaf = 0;
|
||
compositeLayersToMain();
|
||
});
|
||
}
|
||
|
||
function initLayers(w, h, image) {
|
||
layers = [];
|
||
layerIdCounter = 0;
|
||
activeLayerIndex = 0;
|
||
const layer = createLayer('Фон', w, h);
|
||
if (image) layer.ctx.drawImage(image, 0, 0, w, h);
|
||
layers.push(layer);
|
||
compositeLayersToMain();
|
||
renderLayerPanel();
|
||
}
|
||
|
||
function addLayer(name) {
|
||
if (layers.length >= MAX_LAYERS) return;
|
||
const layer = createLayer(name || ('Слой ' + (layerIdCounter + 1)), canvasW, canvasH);
|
||
layers.splice(activeLayerIndex + 1, 0, layer);
|
||
activeLayerIndex++;
|
||
compositeLayersToMain();
|
||
renderLayerPanel();
|
||
pushHistory();
|
||
}
|
||
|
||
function removeLayer(index) {
|
||
if (layers.length <= 1) return;
|
||
layers.splice(index, 1);
|
||
if (activeLayerIndex >= layers.length) activeLayerIndex = layers.length - 1;
|
||
compositeLayersToMain();
|
||
renderLayerPanel();
|
||
pushHistory();
|
||
}
|
||
|
||
function mergeDown(index) {
|
||
if (index <= 0 || index >= layers.length) return;
|
||
const upper = layers[index];
|
||
const lower = layers[index - 1];
|
||
lower.ctx.save();
|
||
lower.ctx.globalAlpha = upper.opacity;
|
||
lower.ctx.globalCompositeOperation = upper.blendMode;
|
||
lower.ctx.drawImage(upper.canvas, 0, 0);
|
||
lower.ctx.restore();
|
||
layers.splice(index, 1);
|
||
if (activeLayerIndex >= index) activeLayerIndex = Math.max(0, activeLayerIndex - 1);
|
||
compositeLayersToMain();
|
||
renderLayerPanel();
|
||
pushHistory();
|
||
}
|
||
|
||
function flattenLayers() {
|
||
if (layers.length <= 1) return;
|
||
compositeLayersToMain();
|
||
const flat = createLayer('Фон', canvasW, canvasH);
|
||
flat.ctx.drawImage(cMain, 0, 0);
|
||
layers = [flat];
|
||
activeLayerIndex = 0;
|
||
compositeLayersToMain();
|
||
renderLayerPanel();
|
||
pushHistory();
|
||
}
|
||
|
||
function moveLayer(fromIndex, toIndex) {
|
||
if (fromIndex === toIndex || toIndex < 0 || toIndex >= layers.length) return;
|
||
const layer = layers.splice(fromIndex, 1)[0];
|
||
layers.splice(toIndex, 0, layer);
|
||
if (activeLayerIndex === fromIndex) {
|
||
activeLayerIndex = toIndex;
|
||
} else {
|
||
if (fromIndex < activeLayerIndex && toIndex >= activeLayerIndex) activeLayerIndex--;
|
||
else if (fromIndex > activeLayerIndex && toIndex <= activeLayerIndex) activeLayerIndex++;
|
||
}
|
||
compositeLayersToMain();
|
||
renderLayerPanel();
|
||
pushHistory();
|
||
}
|
||
|
||
function updateStatusLayer() {
|
||
const el = $('#statusLayer');
|
||
if (layers.length > 0) {
|
||
el.textContent = layers[activeLayerIndex].name + ' (' + layers.length + ')';
|
||
} else {
|
||
el.textContent = '';
|
||
}
|
||
}
|
||
|
||
// ===================== HISTORY (sync ImageData) =====================
|
||
// Refactoring B: replaced async toBlob/URL.createObjectURL with sync getImageData/putImageData.
|
||
// freeHistoryEntry() removed — no blob URLs to revoke.
|
||
|
||
function pushHistory() {
|
||
while (history.length > historyIndex + 1) history.pop();
|
||
if (history.length >= MAX_HISTORY) { history.shift(); historyIndex--; }
|
||
const snap = {
|
||
layers: layers.map(layer => ({
|
||
imageData: layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height),
|
||
id: layer.id, name: layer.name,
|
||
visible: layer.visible, opacity: layer.opacity, blendMode: layer.blendMode
|
||
})),
|
||
activeIndex: activeLayerIndex,
|
||
w: canvasW, h: canvasH
|
||
};
|
||
history.push(snap);
|
||
historyIndex = history.length - 1;
|
||
updateHistoryButtons();
|
||
}
|
||
|
||
function restoreHistory(index) {
|
||
const entry = history[index];
|
||
if (!entry) return;
|
||
setCanvasSize(entry.w, entry.h);
|
||
layers = [];
|
||
activeLayerIndex = entry.activeIndex;
|
||
entry.layers.forEach(snap => {
|
||
const layer = createLayer(snap.name, entry.w, entry.h);
|
||
layer.id = snap.id;
|
||
layer.visible = snap.visible;
|
||
layer.opacity = snap.opacity;
|
||
layer.blendMode = snap.blendMode;
|
||
layer.ctx.putImageData(snap.imageData, 0, 0);
|
||
layers.push(layer);
|
||
});
|
||
compositeLayersToMain();
|
||
clearOverlay();
|
||
redrawUI();
|
||
renderLayerPanel();
|
||
updateStatusLayer();
|
||
}
|
||
|
||
function undo() {
|
||
if (historyIndex <= 0) return;
|
||
historyIndex--;
|
||
restoreHistory(historyIndex);
|
||
updateHistoryButtons();
|
||
}
|
||
|
||
function redo() {
|
||
if (historyIndex >= history.length - 1) return;
|
||
historyIndex++;
|
||
restoreHistory(historyIndex);
|
||
updateHistoryButtons();
|
||
}
|
||
|
||
function updateHistoryButtons() {
|
||
$('#btnUndo').disabled = historyIndex <= 0;
|
||
$('#btnRedo').disabled = historyIndex >= history.length - 1;
|
||
if (history.length > 0) {
|
||
$('#statusHistory').textContent = (historyIndex + 1) + '/' + history.length;
|
||
}
|
||
}
|
||
|
||
// ===================== CLEAR =====================
|
||
function clearOverlay() { ctxOverlay.clearRect(0, 0, canvasW, canvasH); }
|
||
function clearUI() { ctxUI.save(); ctxUI.setTransform(1,0,0,1,0,0); ctxUI.clearRect(0, 0, cUI.width, cUI.height); ctxUI.restore(); }
|
||
|
||
// ===================== LOAD IMAGE =====================
|
||
function _applyImageToCurrentTab(im, name, transparency) {
|
||
img = im;
|
||
hasTransparency = transparency;
|
||
container.classList.toggle('has-transparency', transparency);
|
||
fileName = name;
|
||
const w = Math.min(im.width, 16384);
|
||
const h = Math.min(im.height, 16384);
|
||
setCanvasSize(w, h);
|
||
initLayers(w, h, im);
|
||
// Refactoring B: removed freeHistoryEntry loop — no blob URLs to revoke
|
||
history = [];
|
||
historyIndex = -1;
|
||
pushHistory();
|
||
fitToViewport();
|
||
enableTools();
|
||
dropzone.classList.add('hidden');
|
||
cancelCurrentTool();
|
||
const tab = tabs.find(t => t.id === activeTabId);
|
||
if (tab) { tab.name = name; tab.hasImage = true; }
|
||
renderTabBar();
|
||
if (!layerPanelVisible) {
|
||
layerPanelVisible = true;
|
||
layerPanel.classList.add('visible');
|
||
$('#btnLayers').classList.add('active');
|
||
renderLayerPanel();
|
||
}
|
||
}
|
||
|
||
function loadImage(file) {
|
||
if (!file || !file.type.startsWith('image/')) return;
|
||
const name = file.name.replace(/\.[^.]+$/, '') || 'image';
|
||
const transparency = file.type === 'image/png' || file.type === 'image/webp';
|
||
if (canvasW > 0) {
|
||
saveCurrentTabState();
|
||
const newTab = createTab(name);
|
||
activeTabId = newTab.id;
|
||
layers = []; history = []; historyIndex = -1; canvasW = 0; canvasH = 0;
|
||
selRect = null;
|
||
}
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
const im = new Image();
|
||
im.onload = () => _applyImageToCurrentTab(im, name, transparency);
|
||
im.src = e.target.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
function loadImageFromDataURL(dataUrl) {
|
||
const name = 'pasted';
|
||
if (canvasW > 0) {
|
||
saveCurrentTabState();
|
||
const newTab = createTab(name);
|
||
activeTabId = newTab.id;
|
||
layers = []; history = []; historyIndex = -1; canvasW = 0; canvasH = 0;
|
||
selRect = null;
|
||
}
|
||
const im = new Image();
|
||
im.onload = () => _applyImageToCurrentTab(im, name, true);
|
||
im.src = dataUrl;
|
||
}
|
||
|
||
function enableTools() {
|
||
document.querySelectorAll('.tb[disabled]').forEach(b => b.disabled = false);
|
||
document.querySelectorAll('#btnExport,#btnRotCCW,#btnRotCW,#btnFlipH,#btnFlipV,#btnResize,#btnAdjust,#btnBlur,#btnSharpen').forEach(b => b.disabled = false);
|
||
document.querySelectorAll('#filtersGroup .tb').forEach(b => b.disabled = false);
|
||
updateHistoryButtons();
|
||
}
|
||
|
||
function disableTools() {
|
||
document.querySelectorAll('.tb[data-tool]').forEach(b => b.disabled = true);
|
||
document.querySelectorAll('#btnExport,#btnRotCCW,#btnRotCW,#btnFlipH,#btnFlipV,#btnResize,#btnAdjust,#btnBlur,#btnSharpen').forEach(b => b.disabled = true);
|
||
document.querySelectorAll('#filtersGroup .tb').forEach(b => b.disabled = true);
|
||
updateHistoryButtons();
|
||
}
|
||
|
||
// ===================== TABS =====================
|
||
function createTab(name) {
|
||
const id = ++tabIdCounter;
|
||
const tab = {
|
||
id, name: name || 'Новый',
|
||
layers: null, activeLayerIndex: 0,
|
||
layerIdCounter: 0,
|
||
history: [], historyIndex: -1,
|
||
canvasW: 0, canvasH: 0,
|
||
zoom: 1, panX: 0, panY: 0,
|
||
selRect: null,
|
||
selPath: null,
|
||
fileName: name || 'image',
|
||
hasTransparency: false,
|
||
hasImage: false,
|
||
};
|
||
tabs.push(tab);
|
||
return tab;
|
||
}
|
||
|
||
function saveCurrentTabState() {
|
||
const tab = tabs.find(t => t.id === activeTabId);
|
||
if (!tab) return;
|
||
tab.layers = layers;
|
||
tab.activeLayerIndex = activeLayerIndex;
|
||
tab.layerIdCounter = layerIdCounter;
|
||
tab.history = history;
|
||
tab.historyIndex = historyIndex;
|
||
tab.canvasW = canvasW;
|
||
tab.canvasH = canvasH;
|
||
tab.zoom = zoom;
|
||
tab.panX = panX;
|
||
tab.panY = panY;
|
||
tab.selRect = selRect;
|
||
tab.selPath = selPath;
|
||
tab.fileName = fileName;
|
||
tab.hasTransparency = hasTransparency;
|
||
tab.hasImage = canvasW > 0;
|
||
}
|
||
|
||
function loadTabState(tab) {
|
||
if (pendingShape) cancelPendingShape();
|
||
cancelCurrentTool();
|
||
hideTextOverlay();
|
||
|
||
activeTabId = tab.id;
|
||
|
||
if (!tab.hasImage || !tab.layers || !tab.layers.length) {
|
||
layers = [];
|
||
activeLayerIndex = 0;
|
||
layerIdCounter = 0;
|
||
history = [];
|
||
historyIndex = -1;
|
||
canvasW = 0; canvasH = 0;
|
||
zoom = 1; panX = 0; panY = 0;
|
||
selRect = null;
|
||
fileName = 'image';
|
||
hasTransparency = false;
|
||
container.classList.remove('has-transparency');
|
||
[cMain, cOverlay].forEach(c => { c.width = 1; c.height = 1; });
|
||
container.style.width = '1px';
|
||
container.style.height = '1px';
|
||
container.style.transform = 'translate(0px,0px) scale(1)';
|
||
$('#zoomLabel').textContent = '100%';
|
||
$('#statusZoom').textContent = '100%';
|
||
$('#statusDims').textContent = 'Нет изображения';
|
||
disableTools();
|
||
dropzone.classList.remove('hidden');
|
||
clearOverlay();
|
||
clearUI();
|
||
renderLayerPanel();
|
||
renderTabBar();
|
||
return;
|
||
}
|
||
|
||
layers = tab.layers;
|
||
activeLayerIndex = tab.activeLayerIndex;
|
||
layerIdCounter = tab.layerIdCounter || 0;
|
||
history = tab.history;
|
||
historyIndex = tab.historyIndex;
|
||
canvasW = tab.canvasW;
|
||
canvasH = tab.canvasH;
|
||
zoom = tab.zoom;
|
||
panX = tab.panX;
|
||
panY = tab.panY;
|
||
selRect = tab.selRect || null;
|
||
selPath = tab.selPath || null;
|
||
if (selPath && selPath.length > 2) startMarchingAnts(); else stopMarchingAnts();
|
||
fileName = tab.fileName;
|
||
hasTransparency = tab.hasTransparency;
|
||
container.classList.toggle('has-transparency', !!hasTransparency);
|
||
|
||
[cMain, cOverlay].forEach(c => { c.width = canvasW; c.height = canvasH; });
|
||
container.style.width = canvasW + 'px';
|
||
container.style.height = canvasH + 'px';
|
||
$('#statusDims').textContent = canvasW + ' x ' + canvasH;
|
||
|
||
compositeLayersToMain();
|
||
enableTools();
|
||
updateTransform();
|
||
dropzone.classList.add('hidden');
|
||
renderLayerPanel();
|
||
renderTabBar();
|
||
}
|
||
|
||
function switchTab(tabId) {
|
||
if (tabId === activeTabId) return;
|
||
saveCurrentTabState();
|
||
const tab = tabs.find(t => t.id === tabId);
|
||
if (tab) loadTabState(tab);
|
||
}
|
||
|
||
function closeTab(tabId) {
|
||
const idx = tabs.findIndex(t => t.id === tabId);
|
||
if (idx === -1) return;
|
||
|
||
if (tabs.length === 1) {
|
||
// Last tab: reset to empty
|
||
// Refactoring B: removed freeHistoryEntry calls — ImageData snapshots are GC'd automatically
|
||
saveCurrentTabState();
|
||
const tab = tabs[0];
|
||
tab.layers = null;
|
||
tab.history = [];
|
||
tab.historyIndex = -1;
|
||
tab.canvasW = 0; tab.canvasH = 0;
|
||
tab.hasImage = false;
|
||
tab.name = 'Новый';
|
||
loadTabState(tab);
|
||
return;
|
||
}
|
||
|
||
if (tabId !== activeTabId) {
|
||
// Refactoring B: removed freeHistoryEntry — no blob URLs to revoke
|
||
tabs.splice(idx, 1);
|
||
renderTabBar();
|
||
} else {
|
||
// Closing active tab: switch first, then remove old
|
||
const newIdx = idx > 0 ? idx - 1 : 1;
|
||
const newTab = tabs[newIdx];
|
||
// Refactoring B: removed freeHistoryEntry loop — ImageData GC'd automatically
|
||
history = [];
|
||
historyIndex = -1;
|
||
layers = [];
|
||
tabs.splice(idx, 1);
|
||
loadTabState(newTab);
|
||
}
|
||
}
|
||
|
||
function renderTabBar() {
|
||
const bar = $('#tabBar');
|
||
bar.innerHTML = '';
|
||
tabs.forEach(tab => {
|
||
const item = document.createElement('div');
|
||
item.className = 'tab-item' + (tab.id === activeTabId ? ' active' : '');
|
||
item.dataset.tabId = tab.id;
|
||
|
||
const name = document.createElement('span');
|
||
name.className = 'tab-name';
|
||
name.textContent = tab.name;
|
||
|
||
const close = document.createElement('button');
|
||
close.className = 'tab-close';
|
||
close.innerHTML = '×';
|
||
close.title = 'Закрыть вкладку';
|
||
close.addEventListener('click', e => { e.stopPropagation(); closeTab(tab.id); });
|
||
|
||
item.appendChild(name);
|
||
item.appendChild(close);
|
||
item.addEventListener('click', () => switchTab(tab.id));
|
||
bar.appendChild(item);
|
||
});
|
||
|
||
const addBtn = document.createElement('button');
|
||
addBtn.className = 'tab-add';
|
||
addBtn.title = 'Новая вкладка (Ctrl+T)';
|
||
addBtn.innerHTML = '<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">🔗</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 ? '👁' : '○';
|
||
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();
|
||
})();
|
||
|
||
})();
|