diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index 7c14084..dcbba34 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -539,7 +539,10 @@ export class CanvasInteractions { width: layer.width, height: layer.height, rotation: layer.rotation, centerX: layer.x + layer.width / 2, - centerY: layer.y + layer.height / 2 + centerY: layer.y + layer.height / 2, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined }; this.interaction.dragStart = { ...worldCoords }; if (handle === 'rot') { @@ -692,12 +695,8 @@ export class CanvasInteractions { let mouseY = worldCoords.y; if (this.interaction.isCtrlPressed) { const snapThreshold = 10 / this.canvas.viewport.zoom; - const snappedMouseX = snapToGrid(mouseX); - if (Math.abs(mouseX - snappedMouseX) < snapThreshold) - mouseX = snappedMouseX; - const snappedMouseY = snapToGrid(mouseY); - if (Math.abs(mouseY - snappedMouseY) < snapThreshold) - mouseY = snappedMouseY; + mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX; + mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY; } const o = this.interaction.transformOrigin; if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) @@ -707,43 +706,113 @@ export class CanvasInteractions { const rad = o.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); + // Vector from anchor to mouse const vecX = mouseX - anchor.x; const vecY = mouseY - anchor.y; - let newWidth = vecX * cos + vecY * sin; - let newHeight = vecY * cos - vecX * sin; - if (isShiftPressed) { - const originalAspectRatio = o.width / o.height; - if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) { - newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; - } - else { - newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio; - } - } - let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0); - let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0); - newWidth *= signX; - newHeight *= signY; + // Rotate vector to align with layer's local coordinates + let localVecX = vecX * cos + vecY * sin; + let localVecY = vecY * cos - vecX * sin; + // Determine sign based on handle + const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0); + const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0); + localVecX *= signX; + localVecY *= signY; + // If not a corner handle, keep original dimension if (signX === 0) - newWidth = o.width; + localVecX = o.width; if (signY === 0) - newHeight = o.height; - if (newWidth < 10) - newWidth = 10; - if (newHeight < 10) - newHeight = 10; - layer.width = newWidth; - layer.height = newHeight; - const deltaW = newWidth - o.width; - const deltaH = newHeight - o.height; - const shiftX = (deltaW / 2) * signX; - const shiftY = (deltaH / 2) * signY; - const worldShiftX = shiftX * cos - shiftY * sin; - const worldShiftY = shiftX * sin + shiftY * cos; - const newCenterX = o.centerX + worldShiftX; - const newCenterY = o.centerY + worldShiftY; - layer.x = newCenterX - layer.width / 2; - layer.y = newCenterY - layer.height / 2; + localVecY = o.height; + if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) { + // CROP MODE: Calculate delta based on mouse movement and apply to cropBounds. + // Calculate mouse movement since drag start, in the layer's local coordinate system. + const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0); + const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0); + const mouseX_local = mouseX - (o.centerX ?? 0); + const mouseY_local = mouseY - (o.centerY ?? 0); + // Rotate mouse delta into the layer's unrotated frame + const deltaX_world = mouseX_local - dragStartX_local; + const deltaY_world = mouseY_local - dragStartY_local; + const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin; + const mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin; + // Convert the on-screen mouse delta to an image-space delta. + const screenToImageScaleX = o.originalWidth / o.width; + const screenToImageScaleY = o.originalHeight / o.height; + const delta_image_x = mouseDeltaX_local * screenToImageScaleX; + const delta_image_y = mouseDeltaY_local * screenToImageScaleY; + let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag + // Apply the image-space delta to the appropriate edges of the crop bounds + if (handle?.includes('w')) { + newCropBounds.x += delta_image_x; + newCropBounds.width -= delta_image_x; + } + if (handle?.includes('e')) { + newCropBounds.width += delta_image_x; + } + if (handle?.includes('n')) { + newCropBounds.y += delta_image_y; + newCropBounds.height -= delta_image_y; + } + if (handle?.includes('s')) { + newCropBounds.height += delta_image_y; + } + // Clamp crop bounds to stay within the original image and maintain minimum size + if (newCropBounds.width < 1) { + if (handle?.includes('w')) + newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1; + newCropBounds.width = 1; + } + if (newCropBounds.height < 1) { + if (handle?.includes('n')) + newCropBounds.y = o.cropBounds.y + o.cropBounds.height - 1; + newCropBounds.height = 1; + } + if (newCropBounds.x < 0) { + newCropBounds.width += newCropBounds.x; + newCropBounds.x = 0; + } + if (newCropBounds.y < 0) { + newCropBounds.height += newCropBounds.y; + newCropBounds.y = 0; + } + if (newCropBounds.x + newCropBounds.width > o.originalWidth) { + newCropBounds.width = o.originalWidth - newCropBounds.x; + } + if (newCropBounds.y + newCropBounds.height > o.originalHeight) { + newCropBounds.height = o.originalHeight - newCropBounds.y; + } + layer.cropBounds = newCropBounds; + } + else { + // TRANSFORM MODE: Resize the layer's main transform frame + let newWidth = localVecX; + let newHeight = localVecY; + if (isShiftPressed) { + const originalAspectRatio = o.width / o.height; + if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) { + newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; + } + else { + newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio; + } + } + if (newWidth < 10) + newWidth = 10; + if (newHeight < 10) + newHeight = 10; + layer.width = newWidth; + layer.height = newHeight; + // Update position to keep anchor point fixed + const deltaW = layer.width - o.width; + const deltaH = layer.height - o.height; + const shiftX = (deltaW / 2) * signX; + const shiftY = (deltaH / 2) * signY; + const worldShiftX = shiftX * cos - shiftY * sin; + const worldShiftY = shiftX * sin + shiftY * cos; + const newCenterX = o.centerX + worldShiftX; + const newCenterY = o.centerY + worldShiftY; + layer.x = newCenterX - layer.width / 2; + layer.y = newCenterY - layer.height / 2; + } this.canvas.render(); } rotateLayerFromHandle(worldCoords, isShiftPressed) { diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 343674e..e53fe7d 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -372,8 +372,24 @@ export class CanvasLayers { // Create a temporary canvas for the masked layer const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height); if (tempCtx) { - // Draw the original image - tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); + // This logic is now unified to handle both cropped and non-cropped images correctly. + const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight }; + if (!layer.originalWidth || !layer.originalHeight) { + tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); + } + else { + const layerScaleX = layer.width / layer.originalWidth; + const layerScaleY = layer.height / layer.originalHeight; + const dWidth = s.width * layerScaleX; + const dHeight = s.height * layerScaleY; + // The destination is the top-left of the temp canvas, plus the scaled offset of the crop area. + const dX = s.x * layerScaleX; + const dY = s.y * layerScaleY; + // We draw into a temp canvas of size layer.width x layer.height. + // The destination rect must be positioned correctly within this temp canvas. + // The dX/dY here are offsets from the top-left of the transform frame. + tempCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, dX, dY, dWidth, dHeight); + } // Apply the distance field mask using destination-in for transparency effect tempCtx.globalCompositeOperation = 'destination-in'; tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height); @@ -384,26 +400,44 @@ export class CanvasLayers { } else { // Fallback to normal drawing - ctx.globalCompositeOperation = layer.blendMode || 'normal'; - ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; - ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + this._drawLayerImage(ctx, layer); } } else { // Fallback to normal drawing - ctx.globalCompositeOperation = layer.blendMode || 'normal'; - ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; - ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + this._drawLayerImage(ctx, layer); } } else { // Normal drawing without blend area effect - ctx.globalCompositeOperation = layer.blendMode || 'normal'; - ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; - ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + this._drawLayerImage(ctx, layer); } ctx.restore(); } + _drawLayerImage(ctx, layer) { + ctx.globalCompositeOperation = layer.blendMode || 'normal'; + ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; + // Use cropBounds if they exist, otherwise use the full image dimensions as the source + const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight }; + if (!layer.originalWidth || !layer.originalHeight) { + // Fallback for older layers without original dimensions or if data is missing + ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + return; + } + // Calculate the on-screen scale of the layer's transform frame + const layerScaleX = layer.width / layer.originalWidth; + const layerScaleY = layer.height / layer.originalHeight; + // Calculate the on-screen size of the cropped portion + const dWidth = s.width * layerScaleX; + const dHeight = s.height * layerScaleY; + // Calculate the on-screen position of the top-left of the cropped portion. + // This is relative to the layer's center (the context's 0,0). + const dX = (-layer.width / 2) + (s.x * layerScaleX); + const dY = (-layer.height / 2) + (s.y * layerScaleY); + ctx.drawImage(layer.image, s.x, s.y, s.width, s.height, // source rect (from original image) + dX, dY, dWidth, dHeight // destination rect (scaled and positioned within the transform frame) + ); + } getDistanceFieldMaskSync(image, blendArea) { // Check cache first let imageCache = this.distanceFieldCache.get(image); @@ -527,30 +561,47 @@ export class CanvasLayers { this.canvas.saveState(); } getHandles(layer) { - const centerX = layer.x + layer.width / 2; - const centerY = layer.y + layer.height / 2; + const layerCenterX = layer.x + layer.width / 2; + const layerCenterY = layer.y + layer.height / 2; const rad = layer.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - const halfW = layer.width / 2; - const halfH = layer.height / 2; + let handleCenterX, handleCenterY, halfW, halfH; + if (layer.cropMode && layer.cropBounds && layer.originalWidth) { + // CROP MODE: Handles are relative to the cropped area + const layerScaleX = layer.width / layer.originalWidth; + const layerScaleY = layer.height / layer.originalHeight; + const cropRectW = layer.cropBounds.width * layerScaleX; + const cropRectH = layer.cropBounds.height * layerScaleY; + // Center of the CROP rectangle in the layer's local, un-rotated space + const cropCenterX_local = (-layer.width / 2) + ((layer.cropBounds.x + layer.cropBounds.width / 2) * layerScaleX); + const cropCenterY_local = (-layer.height / 2) + ((layer.cropBounds.y + layer.cropBounds.height / 2) * layerScaleY); + // Rotate this local center to find the world-space center of the crop rect + handleCenterX = layerCenterX + (cropCenterX_local * cos - cropCenterY_local * sin); + handleCenterY = layerCenterY + (cropCenterX_local * sin + cropCenterY_local * cos); + halfW = cropRectW / 2; + halfH = cropRectH / 2; + } + else { + // TRANSFORM MODE: Handles are relative to the full layer transform frame + handleCenterX = layerCenterX; + handleCenterY = layerCenterY; + halfW = layer.width / 2; + halfH = layer.height / 2; + } const localHandles = { - 'n': { x: 0, y: -halfH }, - 'ne': { x: halfW, y: -halfH }, - 'e': { x: halfW, y: 0 }, - 'se': { x: halfW, y: halfH }, - 's': { x: 0, y: halfH }, - 'sw': { x: -halfW, y: halfH }, - 'w': { x: -halfW, y: 0 }, - 'nw': { x: -halfW, y: -halfH }, + 'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH }, + 'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH }, + 's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH }, + 'w': { x: -halfW, y: 0 }, 'nw': { x: -halfW, y: -halfH }, 'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom } }; const worldHandles = {}; for (const key in localHandles) { const p = localHandles[key]; worldHandles[key] = { - x: centerX + (p.x * cos - p.y * sin), - y: centerY + (p.x * sin + p.y * cos) + x: handleCenterX + (p.x * cos - p.y * sin), + y: handleCenterY + (p.x * sin + p.y * cos) }; } return worldHandles; diff --git a/js/CanvasRenderer.js b/js/CanvasRenderer.js index 46af668..9070099 100644 --- a/js/CanvasRenderer.js +++ b/js/CanvasRenderer.js @@ -431,38 +431,63 @@ export class CanvasRenderer { drawSelectionFrame(ctx, layer) { const lineWidth = 2 / this.canvas.viewport.zoom; const handleRadius = 5 / this.canvas.viewport.zoom; - ctx.strokeStyle = '#00ff00'; - ctx.lineWidth = lineWidth; - // Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia) - const halfW = layer.width / 2; - const halfH = layer.height / 2; - // Górna krawędź - this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer); - // Prawa krawędź - this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer); - // Dolna krawędź - this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer); - // Lewa krawędź - this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer); - // Rysuj linię do uchwytu rotacji (zawsze ciągła) - ctx.setLineDash([]); - ctx.beginPath(); - ctx.moveTo(0, -layer.height / 2); - ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom); - ctx.stroke(); - // Rysuj uchwyty + if (layer.cropMode && layer.cropBounds && layer.originalWidth) { + // --- CROP MODE --- + ctx.lineWidth = lineWidth; + // 1. Draw dashed blue line for the full transform frame (the "original size" container) + ctx.strokeStyle = '#007bff'; + ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]); + ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height); + ctx.setLineDash([]); + // 2. Draw solid blue line for the crop bounds + const layerScaleX = layer.width / layer.originalWidth; + const layerScaleY = layer.height / layer.originalHeight; + const s = layer.cropBounds; + const cropRectX = (-layer.width / 2) + (s.x * layerScaleX); + const cropRectY = (-layer.height / 2) + (s.y * layerScaleY); + const cropRectW = s.width * layerScaleX; + const cropRectH = s.height * layerScaleY; + ctx.strokeStyle = '#007bff'; // Solid blue + this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top + this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right + this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom + this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left + } + else { + // --- TRANSFORM MODE --- + ctx.strokeStyle = '#00ff00'; // Green + ctx.lineWidth = lineWidth; + const halfW = layer.width / 2; + const halfH = layer.height / 2; + // Draw adaptive solid green line for transform frame + this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer); + this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer); + this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer); + this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer); + // Draw line to rotation handle + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(0, -halfH); + ctx.lineTo(0, -halfH - 20 / this.canvas.viewport.zoom); + ctx.stroke(); + } + // --- DRAW HANDLES (Unified Logic) --- const handles = this.canvas.canvasLayers.getHandles(layer); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1 / this.canvas.viewport.zoom; for (const key in handles) { + // Skip rotation handle in crop mode + if (layer.cropMode && key === 'rot') + continue; const point = handles[key]; - ctx.beginPath(); + // The handle position is already in world space, we need it in the layer's rotated space const localX = point.x - (layer.x + layer.width / 2); const localY = point.y - (layer.y + layer.height / 2); const rad = -layer.rotation * Math.PI / 180; const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad); const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad); + ctx.beginPath(); ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); diff --git a/js/CanvasView.js b/js/CanvasView.js index 0e098a0..0320c95 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -293,6 +293,43 @@ async function createCanvasWidget(node, widget, app) { ]), $el("div.painter-separator"), $el("div.painter-button-group", {}, [ + $el("button.painter-button.requires-selection", { + id: `crop-mode-btn-${node.id}`, + textContent: "Crop Mode", + title: "Toggle crop mode for selected layer(s)", + onclick: () => { + const cropBtn = controlPanel.querySelector(`#crop-mode-btn-${node.id}`); + const selectedLayers = canvas.canvasSelection.selectedLayers; + if (selectedLayers.length === 0) + return; + // Toggle crop mode for all selected layers + const firstLayer = selectedLayers[0]; + const newCropMode = !firstLayer.cropMode; + selectedLayers.forEach((layer) => { + layer.cropMode = newCropMode; + // Initialize crop bounds if entering crop mode + if (newCropMode && !layer.cropBounds) { + layer.cropBounds = { + x: 0, + y: 0, + width: layer.originalWidth, + height: layer.originalHeight + }; + } + }); + // Update button appearance + if (newCropMode) { + cropBtn.classList.add('primary'); + cropBtn.title = "Exit crop mode for selected layer(s)"; + } + else { + cropBtn.classList.remove('primary'); + cropBtn.title = "Toggle crop mode for selected layer(s)"; + } + canvas.saveState(); + canvas.render(); + } + }), $el("button.painter-button.requires-selection", { textContent: "Rotate +90°", title: "Rotate selected layer(s) by +90 degrees", diff --git a/js/css/canvas_view.css b/js/css/canvas_view.css index 3528069..f4ce039 100644 --- a/js/css/canvas_view.css +++ b/js/css/canvas_view.css @@ -51,6 +51,32 @@ border-color: #3a76d6; } +/* Crop mode button styling */ +.painter-button#crop-mode-btn { + background-color: #444; + border-color: #555; + color: #fff; + transition: all 0.2s ease-in-out; +} + +.painter-button#crop-mode-btn.primary { + background-color: #0080ff; + border-color: #0070e0; + color: #fff; + box-shadow: 0 0 8px rgba(0, 128, 255, 0.3); +} + +.painter-button#crop-mode-btn.primary:hover { + background-color: #1090ff; + border-color: #0080ff; + box-shadow: 0 0 12px rgba(0, 128, 255, 0.4); +} + +.painter-button#crop-mode-btn:hover { + background-color: #555; + border-color: #666; +} + .painter-button.success { border-color: #4ae27a; background-color: #444; diff --git a/src/CanvasInteractions.ts b/src/CanvasInteractions.ts index 5b5cbd8..ad23764 100644 --- a/src/CanvasInteractions.ts +++ b/src/CanvasInteractions.ts @@ -626,7 +626,10 @@ export class CanvasInteractions { width: layer.width, height: layer.height, rotation: layer.rotation, centerX: layer.x + layer.width / 2, - centerY: layer.y + layer.height / 2 + centerY: layer.y + layer.height / 2, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined }; this.interaction.dragStart = {...worldCoords}; @@ -797,66 +800,137 @@ export class CanvasInteractions { if (this.interaction.isCtrlPressed) { const snapThreshold = 10 / this.canvas.viewport.zoom; - const snappedMouseX = snapToGrid(mouseX); - if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX; - const snappedMouseY = snapToGrid(mouseY); - if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY; + mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX; + mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY; } const o = this.interaction.transformOrigin; if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return; + const handle = this.interaction.resizeHandle; const anchor = this.interaction.resizeAnchor; - const rad = o.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); + // Vector from anchor to mouse const vecX = mouseX - anchor.x; const vecY = mouseY - anchor.y; - let newWidth = vecX * cos + vecY * sin; - let newHeight = vecY * cos - vecX * sin; + // Rotate vector to align with layer's local coordinates + let localVecX = vecX * cos + vecY * sin; + let localVecY = vecY * cos - vecX * sin; - if (isShiftPressed) { - const originalAspectRatio = o.width / o.height; + // Determine sign based on handle + const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0); + const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0); + + localVecX *= signX; + localVecY *= signY; - if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) { - newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; - } else { - newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio; + // If not a corner handle, keep original dimension + if (signX === 0) localVecX = o.width; + if (signY === 0) localVecY = o.height; + + if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) { + // CROP MODE: Calculate delta based on mouse movement and apply to cropBounds. + + // Calculate mouse movement since drag start, in the layer's local coordinate system. + const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0); + const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0); + const mouseX_local = mouseX - (o.centerX ?? 0); + const mouseY_local = mouseY - (o.centerY ?? 0); + + // Rotate mouse delta into the layer's unrotated frame + const deltaX_world = mouseX_local - dragStartX_local; + const deltaY_world = mouseY_local - dragStartY_local; + const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin; + const mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin; + + // Convert the on-screen mouse delta to an image-space delta. + const screenToImageScaleX = o.originalWidth / o.width; + const screenToImageScaleY = o.originalHeight / o.height; + + const delta_image_x = mouseDeltaX_local * screenToImageScaleX; + const delta_image_y = mouseDeltaY_local * screenToImageScaleY; + + let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag + + // Apply the image-space delta to the appropriate edges of the crop bounds + if (handle?.includes('w')) { + newCropBounds.x += delta_image_x; + newCropBounds.width -= delta_image_x; } + if (handle?.includes('e')) { + newCropBounds.width += delta_image_x; + } + if (handle?.includes('n')) { + newCropBounds.y += delta_image_y; + newCropBounds.height -= delta_image_y; + } + if (handle?.includes('s')) { + newCropBounds.height += delta_image_y; + } + + // Clamp crop bounds to stay within the original image and maintain minimum size + if (newCropBounds.width < 1) { + if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width -1; + newCropBounds.width = 1; + } + if (newCropBounds.height < 1) { + if (handle?.includes('n')) newCropBounds.y = o.cropBounds.y + o.cropBounds.height - 1; + newCropBounds.height = 1; + } + if (newCropBounds.x < 0) { + newCropBounds.width += newCropBounds.x; + newCropBounds.x = 0; + } + if (newCropBounds.y < 0) { + newCropBounds.height += newCropBounds.y; + newCropBounds.y = 0; + } + if (newCropBounds.x + newCropBounds.width > o.originalWidth) { + newCropBounds.width = o.originalWidth - newCropBounds.x; + } + if (newCropBounds.y + newCropBounds.height > o.originalHeight) { + newCropBounds.height = o.originalHeight - newCropBounds.y; + } + + layer.cropBounds = newCropBounds; + + } else { + // TRANSFORM MODE: Resize the layer's main transform frame + let newWidth = localVecX; + let newHeight = localVecY; + + if (isShiftPressed) { + const originalAspectRatio = o.width / o.height; + if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) { + newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; + } else { + newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio; + } + } + + if (newWidth < 10) newWidth = 10; + if (newHeight < 10) newHeight = 10; + + layer.width = newWidth; + layer.height = newHeight; + + // Update position to keep anchor point fixed + const deltaW = layer.width - o.width; + const deltaH = layer.height - o.height; + const shiftX = (deltaW / 2) * signX; + const shiftY = (deltaH / 2) * signY; + const worldShiftX = shiftX * cos - shiftY * sin; + const worldShiftY = shiftX * sin + shiftY * cos; + const newCenterX = o.centerX + worldShiftX; + const newCenterY = o.centerY + worldShiftY; + layer.x = newCenterX - layer.width / 2; + layer.y = newCenterY - layer.height / 2; } - let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0); - let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0); - - newWidth *= signX; - newHeight *= signY; - - if (signX === 0) newWidth = o.width; - if (signY === 0) newHeight = o.height; - - if (newWidth < 10) newWidth = 10; - if (newHeight < 10) newHeight = 10; - - layer.width = newWidth; - layer.height = newHeight; - - const deltaW = newWidth - o.width; - const deltaH = newHeight - o.height; - - const shiftX = (deltaW / 2) * signX; - const shiftY = (deltaH / 2) * signY; - - const worldShiftX = shiftX * cos - shiftY * sin; - const worldShiftY = shiftX * sin + shiftY * cos; - - const newCenterX = o.centerX + worldShiftX; - const newCenterY = o.centerY + worldShiftY; - - layer.x = newCenterX - layer.width / 2; - layer.y = newCenterY - layer.height / 2; this.canvas.render(); } diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index f7a4ad6..d0f2e8e 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -433,8 +433,31 @@ export class CanvasLayers { const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height); if (tempCtx) { - // Draw the original image - tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); + // This logic is now unified to handle both cropped and non-cropped images correctly. + const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight }; + + if (!layer.originalWidth || !layer.originalHeight) { + tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); + } else { + const layerScaleX = layer.width / layer.originalWidth; + const layerScaleY = layer.height / layer.originalHeight; + + const dWidth = s.width * layerScaleX; + const dHeight = s.height * layerScaleY; + + // The destination is the top-left of the temp canvas, plus the scaled offset of the crop area. + const dX = s.x * layerScaleX; + const dY = s.y * layerScaleY; + + // We draw into a temp canvas of size layer.width x layer.height. + // The destination rect must be positioned correctly within this temp canvas. + // The dX/dY here are offsets from the top-left of the transform frame. + tempCtx.drawImage( + layer.image, + s.x, s.y, s.width, s.height, + dX, dY, dWidth, dHeight + ); + } // Apply the distance field mask using destination-in for transparency effect tempCtx.globalCompositeOperation = 'destination-in'; @@ -446,26 +469,53 @@ export class CanvasLayers { ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height); } else { // Fallback to normal drawing - ctx.globalCompositeOperation = layer.blendMode as any || 'normal'; - ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; - ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + this._drawLayerImage(ctx, layer); } } else { // Fallback to normal drawing - ctx.globalCompositeOperation = layer.blendMode as any || 'normal'; - ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; - ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + this._drawLayerImage(ctx, layer); } } else { // Normal drawing without blend area effect - ctx.globalCompositeOperation = layer.blendMode as any || 'normal'; - ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; - ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + this._drawLayerImage(ctx, layer); } ctx.restore(); } + private _drawLayerImage(ctx: CanvasRenderingContext2D, layer: Layer): void { + ctx.globalCompositeOperation = layer.blendMode as any || 'normal'; + ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; + + // Use cropBounds if they exist, otherwise use the full image dimensions as the source + const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight }; + + if (!layer.originalWidth || !layer.originalHeight) { + // Fallback for older layers without original dimensions or if data is missing + ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + return; + } + + // Calculate the on-screen scale of the layer's transform frame + const layerScaleX = layer.width / layer.originalWidth; + const layerScaleY = layer.height / layer.originalHeight; + + // Calculate the on-screen size of the cropped portion + const dWidth = s.width * layerScaleX; + const dHeight = s.height * layerScaleY; + + // Calculate the on-screen position of the top-left of the cropped portion. + // This is relative to the layer's center (the context's 0,0). + const dX = (-layer.width / 2) + (s.x * layerScaleX); + const dY = (-layer.height / 2) + (s.y * layerScaleY); + + ctx.drawImage( + layer.image, + s.x, s.y, s.width, s.height, // source rect (from original image) + dX, dY, dWidth, dHeight // destination rect (scaled and positioned within the transform frame) + ); + } + private getDistanceFieldMaskSync(image: HTMLImageElement, blendArea: number): HTMLCanvasElement | null { // Check cache first let imageCache = this.distanceFieldCache.get(image); @@ -606,23 +656,45 @@ export class CanvasLayers { } getHandles(layer: Layer): Record { - const centerX = layer.x + layer.width / 2; - const centerY = layer.y + layer.height / 2; + const layerCenterX = layer.x + layer.width / 2; + const layerCenterY = layer.y + layer.height / 2; const rad = layer.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - const halfW = layer.width / 2; - const halfH = layer.height / 2; + let handleCenterX, handleCenterY, halfW, halfH; + + if (layer.cropMode && layer.cropBounds && layer.originalWidth) { + // CROP MODE: Handles are relative to the cropped area + const layerScaleX = layer.width / layer.originalWidth; + const layerScaleY = layer.height / layer.originalHeight; + + const cropRectW = layer.cropBounds.width * layerScaleX; + const cropRectH = layer.cropBounds.height * layerScaleY; + + // Center of the CROP rectangle in the layer's local, un-rotated space + const cropCenterX_local = (-layer.width / 2) + ((layer.cropBounds.x + layer.cropBounds.width / 2) * layerScaleX); + const cropCenterY_local = (-layer.height / 2) + ((layer.cropBounds.y + layer.cropBounds.height / 2) * layerScaleY); + + // Rotate this local center to find the world-space center of the crop rect + handleCenterX = layerCenterX + (cropCenterX_local * cos - cropCenterY_local * sin); + handleCenterY = layerCenterY + (cropCenterX_local * sin + cropCenterY_local * cos); + + halfW = cropRectW / 2; + halfH = cropRectH / 2; + } else { + // TRANSFORM MODE: Handles are relative to the full layer transform frame + handleCenterX = layerCenterX; + handleCenterY = layerCenterY; + halfW = layer.width / 2; + halfH = layer.height / 2; + } + const localHandles: Record = { - 'n': { x: 0, y: -halfH }, - 'ne': { x: halfW, y: -halfH }, - 'e': { x: halfW, y: 0 }, - 'se': { x: halfW, y: halfH }, - 's': { x: 0, y: halfH }, - 'sw': { x: -halfW, y: halfH }, - 'w': { x: -halfW, y: 0 }, - 'nw': { x: -halfW, y: -halfH }, + 'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH }, + 'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH }, + 's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH }, + 'w': { x: -halfW, y: 0 }, 'nw': { x: -halfW, y: -halfH }, 'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom } }; @@ -630,8 +702,8 @@ export class CanvasLayers { for (const key in localHandles) { const p = localHandles[key]; worldHandles[key] = { - x: centerX + (p.x * cos - p.y * sin), - y: centerY + (p.x * sin + p.y * cos) + x: handleCenterX + (p.x * cos - p.y * sin), + y: handleCenterY + (p.x * sin + p.y * cos) }; } return worldHandles; diff --git a/src/CanvasRenderer.ts b/src/CanvasRenderer.ts index 83f781e..e47a71d 100644 --- a/src/CanvasRenderer.ts +++ b/src/CanvasRenderer.ts @@ -532,38 +532,66 @@ export class CanvasRenderer { drawSelectionFrame(ctx: any, layer: any) { const lineWidth = 2 / this.canvas.viewport.zoom; const handleRadius = 5 / this.canvas.viewport.zoom; - ctx.strokeStyle = '#00ff00'; - ctx.lineWidth = lineWidth; + + if (layer.cropMode && layer.cropBounds && layer.originalWidth) { + // --- CROP MODE --- + ctx.lineWidth = lineWidth; + + // 1. Draw dashed blue line for the full transform frame (the "original size" container) + ctx.strokeStyle = '#007bff'; + ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]); + ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height); + ctx.setLineDash([]); + + // 2. Draw solid blue line for the crop bounds + const layerScaleX = layer.width / layer.originalWidth; + const layerScaleY = layer.height / layer.originalHeight; + const s = layer.cropBounds; + + const cropRectX = (-layer.width / 2) + (s.x * layerScaleX); + const cropRectY = (-layer.height / 2) + (s.y * layerScaleY); + const cropRectW = s.width * layerScaleX; + const cropRectH = s.height * layerScaleY; + + ctx.strokeStyle = '#007bff'; // Solid blue + this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top + this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right + this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom + this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left + + } else { + // --- TRANSFORM MODE --- + ctx.strokeStyle = '#00ff00'; // Green + ctx.lineWidth = lineWidth; + const halfW = layer.width / 2; + const halfH = layer.height / 2; + + // Draw adaptive solid green line for transform frame + this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer); + this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer); + this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer); + this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer); + + // Draw line to rotation handle + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(0, -halfH); + ctx.lineTo(0, -halfH - 20 / this.canvas.viewport.zoom); + ctx.stroke(); + } - // Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia) - const halfW = layer.width / 2; - const halfH = layer.height / 2; - - // Górna krawędź - this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer); - // Prawa krawędź - this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer); - // Dolna krawędź - this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer); - // Lewa krawędź - this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer); - - // Rysuj linię do uchwytu rotacji (zawsze ciągła) - ctx.setLineDash([]); - ctx.beginPath(); - ctx.moveTo(0, -layer.height / 2); - ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom); - ctx.stroke(); - - // Rysuj uchwyty + // --- DRAW HANDLES (Unified Logic) --- const handles = this.canvas.canvasLayers.getHandles(layer); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1 / this.canvas.viewport.zoom; for (const key in handles) { + // Skip rotation handle in crop mode + if (layer.cropMode && key === 'rot') continue; + const point = handles[key]; - ctx.beginPath(); + // The handle position is already in world space, we need it in the layer's rotated space const localX = point.x - (layer.x + layer.width / 2); const localY = point.y - (layer.y + layer.height / 2); @@ -571,6 +599,7 @@ export class CanvasRenderer { const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad); const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad); + ctx.beginPath(); ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); diff --git a/src/CanvasView.ts b/src/CanvasView.ts index 0292e4f..dfa2e7c 100644 --- a/src/CanvasView.ts +++ b/src/CanvasView.ts @@ -326,6 +326,47 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp): $el("div.painter-separator"), $el("div.painter-button-group", {}, [ + $el("button.painter-button.requires-selection", { + id: `crop-mode-btn-${node.id}`, + textContent: "Crop Mode", + title: "Toggle crop mode for selected layer(s)", + onclick: () => { + const cropBtn = controlPanel.querySelector(`#crop-mode-btn-${node.id}`) as HTMLButtonElement; + const selectedLayers = canvas.canvasSelection.selectedLayers; + + if (selectedLayers.length === 0) return; + + // Toggle crop mode for all selected layers + const firstLayer = selectedLayers[0]; + const newCropMode = !firstLayer.cropMode; + + selectedLayers.forEach((layer: Layer) => { + layer.cropMode = newCropMode; + + // Initialize crop bounds if entering crop mode + if (newCropMode && !layer.cropBounds) { + layer.cropBounds = { + x: 0, + y: 0, + width: layer.originalWidth, + height: layer.originalHeight + }; + } + }); + + // Update button appearance + if (newCropMode) { + cropBtn.classList.add('primary'); + cropBtn.title = "Exit crop mode for selected layer(s)"; + } else { + cropBtn.classList.remove('primary'); + cropBtn.title = "Toggle crop mode for selected layer(s)"; + } + + canvas.saveState(); + canvas.render(); + } + }), $el("button.painter-button.requires-selection", { textContent: "Rotate +90°", title: "Rotate selected layer(s) by +90 degrees", diff --git a/src/css/canvas_view.css b/src/css/canvas_view.css index 3528069..f4ce039 100644 --- a/src/css/canvas_view.css +++ b/src/css/canvas_view.css @@ -51,6 +51,32 @@ border-color: #3a76d6; } +/* Crop mode button styling */ +.painter-button#crop-mode-btn { + background-color: #444; + border-color: #555; + color: #fff; + transition: all 0.2s ease-in-out; +} + +.painter-button#crop-mode-btn.primary { + background-color: #0080ff; + border-color: #0070e0; + color: #fff; + box-shadow: 0 0 8px rgba(0, 128, 255, 0.3); +} + +.painter-button#crop-mode-btn.primary:hover { + background-color: #1090ff; + border-color: #0080ff; + box-shadow: 0 0 12px rgba(0, 128, 255, 0.4); +} + +.painter-button#crop-mode-btn:hover { + background-color: #555; + border-color: #666; +} + .painter-button.success { border-color: #4ae27a; background-color: #444; diff --git a/src/types.ts b/src/types.ts index 23fa59d..b74d366 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,13 @@ export interface Layer { flipH?: boolean; flipV?: boolean; blendArea?: number; + cropMode?: boolean; // czy warstwa jest w trybie crop + cropBounds?: { // granice przycinania + x: number; // offset od lewej krawędzi obrazu + y: number; // offset od górnej krawędzi obrazu + width: number; // szerokość widocznego obszaru + height: number; // wysokość widocznego obszaru + }; } export interface ComfyNode {