mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-23 13:32:11 -03:00
Implement crop mode for cropping selected layer
This commit is contained in:
@@ -539,7 +539,10 @@ export class CanvasInteractions {
|
|||||||
width: layer.width, height: layer.height,
|
width: layer.width, height: layer.height,
|
||||||
rotation: layer.rotation,
|
rotation: layer.rotation,
|
||||||
centerX: layer.x + layer.width / 2,
|
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 };
|
this.interaction.dragStart = { ...worldCoords };
|
||||||
if (handle === 'rot') {
|
if (handle === 'rot') {
|
||||||
@@ -692,12 +695,8 @@ export class CanvasInteractions {
|
|||||||
let mouseY = worldCoords.y;
|
let mouseY = worldCoords.y;
|
||||||
if (this.interaction.isCtrlPressed) {
|
if (this.interaction.isCtrlPressed) {
|
||||||
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
||||||
const snappedMouseX = snapToGrid(mouseX);
|
mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
|
||||||
if (Math.abs(mouseX - snappedMouseX) < snapThreshold)
|
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
||||||
mouseX = snappedMouseX;
|
|
||||||
const snappedMouseY = snapToGrid(mouseY);
|
|
||||||
if (Math.abs(mouseY - snappedMouseY) < snapThreshold)
|
|
||||||
mouseY = snappedMouseY;
|
|
||||||
}
|
}
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
|
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 rad = o.rotation * Math.PI / 180;
|
||||||
const cos = Math.cos(rad);
|
const cos = Math.cos(rad);
|
||||||
const sin = Math.sin(rad);
|
const sin = Math.sin(rad);
|
||||||
|
// Vector from anchor to mouse
|
||||||
const vecX = mouseX - anchor.x;
|
const vecX = mouseX - anchor.x;
|
||||||
const vecY = mouseY - anchor.y;
|
const vecY = mouseY - anchor.y;
|
||||||
let newWidth = vecX * cos + vecY * sin;
|
// Rotate vector to align with layer's local coordinates
|
||||||
let newHeight = vecY * cos - vecX * sin;
|
let localVecX = vecX * cos + vecY * sin;
|
||||||
if (isShiftPressed) {
|
let localVecY = vecY * cos - vecX * sin;
|
||||||
const originalAspectRatio = o.width / o.height;
|
// Determine sign based on handle
|
||||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||||
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||||
}
|
localVecX *= signX;
|
||||||
else {
|
localVecY *= signY;
|
||||||
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
// If not a corner handle, keep original dimension
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
if (signX === 0)
|
||||||
newWidth = o.width;
|
localVecX = o.width;
|
||||||
if (signY === 0)
|
if (signY === 0)
|
||||||
newHeight = o.height;
|
localVecY = o.height;
|
||||||
if (newWidth < 10)
|
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
|
||||||
newWidth = 10;
|
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
|
||||||
if (newHeight < 10)
|
// Calculate mouse movement since drag start, in the layer's local coordinate system.
|
||||||
newHeight = 10;
|
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
|
||||||
layer.width = newWidth;
|
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
|
||||||
layer.height = newHeight;
|
const mouseX_local = mouseX - (o.centerX ?? 0);
|
||||||
const deltaW = newWidth - o.width;
|
const mouseY_local = mouseY - (o.centerY ?? 0);
|
||||||
const deltaH = newHeight - o.height;
|
// Rotate mouse delta into the layer's unrotated frame
|
||||||
const shiftX = (deltaW / 2) * signX;
|
const deltaX_world = mouseX_local - dragStartX_local;
|
||||||
const shiftY = (deltaH / 2) * signY;
|
const deltaY_world = mouseY_local - dragStartY_local;
|
||||||
const worldShiftX = shiftX * cos - shiftY * sin;
|
const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
|
||||||
const worldShiftY = shiftX * sin + shiftY * cos;
|
const mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
|
||||||
const newCenterX = o.centerX + worldShiftX;
|
// Convert the on-screen mouse delta to an image-space delta.
|
||||||
const newCenterY = o.centerY + worldShiftY;
|
const screenToImageScaleX = o.originalWidth / o.width;
|
||||||
layer.x = newCenterX - layer.width / 2;
|
const screenToImageScaleY = o.originalHeight / o.height;
|
||||||
layer.y = newCenterY - layer.height / 2;
|
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();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
rotateLayerFromHandle(worldCoords, isShiftPressed) {
|
rotateLayerFromHandle(worldCoords, isShiftPressed) {
|
||||||
|
|||||||
@@ -372,8 +372,24 @@ export class CanvasLayers {
|
|||||||
// Create a temporary canvas for the masked layer
|
// Create a temporary canvas for the masked layer
|
||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
|
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
|
||||||
if (tempCtx) {
|
if (tempCtx) {
|
||||||
// Draw the original image
|
// This logic is now unified to handle both cropped and non-cropped images correctly.
|
||||||
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
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
|
// Apply the distance field mask using destination-in for transparency effect
|
||||||
tempCtx.globalCompositeOperation = 'destination-in';
|
tempCtx.globalCompositeOperation = 'destination-in';
|
||||||
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height);
|
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height);
|
||||||
@@ -384,26 +400,44 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Fallback to normal drawing
|
// Fallback to normal drawing
|
||||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
this._drawLayerImage(ctx, layer);
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Fallback to normal drawing
|
// Fallback to normal drawing
|
||||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
this._drawLayerImage(ctx, layer);
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Normal drawing without blend area effect
|
// Normal drawing without blend area effect
|
||||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
this._drawLayerImage(ctx, layer);
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
}
|
||||||
ctx.restore();
|
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) {
|
getDistanceFieldMaskSync(image, blendArea) {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
let imageCache = this.distanceFieldCache.get(image);
|
let imageCache = this.distanceFieldCache.get(image);
|
||||||
@@ -527,30 +561,47 @@ export class CanvasLayers {
|
|||||||
this.canvas.saveState();
|
this.canvas.saveState();
|
||||||
}
|
}
|
||||||
getHandles(layer) {
|
getHandles(layer) {
|
||||||
const centerX = layer.x + layer.width / 2;
|
const layerCenterX = layer.x + layer.width / 2;
|
||||||
const centerY = layer.y + layer.height / 2;
|
const layerCenterY = layer.y + layer.height / 2;
|
||||||
const rad = layer.rotation * Math.PI / 180;
|
const rad = layer.rotation * Math.PI / 180;
|
||||||
const cos = Math.cos(rad);
|
const cos = Math.cos(rad);
|
||||||
const sin = Math.sin(rad);
|
const sin = Math.sin(rad);
|
||||||
const halfW = layer.width / 2;
|
let handleCenterX, handleCenterY, halfW, halfH;
|
||||||
const halfH = layer.height / 2;
|
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 = {
|
const localHandles = {
|
||||||
'n': { x: 0, y: -halfH },
|
'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH },
|
||||||
'ne': { x: halfW, y: -halfH },
|
'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH },
|
||||||
'e': { x: halfW, y: 0 },
|
's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH },
|
||||||
'se': { x: halfW, y: halfH },
|
'w': { x: -halfW, y: 0 }, 'nw': { 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 }
|
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
|
||||||
};
|
};
|
||||||
const worldHandles = {};
|
const worldHandles = {};
|
||||||
for (const key in localHandles) {
|
for (const key in localHandles) {
|
||||||
const p = localHandles[key];
|
const p = localHandles[key];
|
||||||
worldHandles[key] = {
|
worldHandles[key] = {
|
||||||
x: centerX + (p.x * cos - p.y * sin),
|
x: handleCenterX + (p.x * cos - p.y * sin),
|
||||||
y: centerY + (p.x * sin + p.y * cos)
|
y: handleCenterY + (p.x * sin + p.y * cos)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return worldHandles;
|
return worldHandles;
|
||||||
|
|||||||
@@ -431,38 +431,63 @@ export class CanvasRenderer {
|
|||||||
drawSelectionFrame(ctx, layer) {
|
drawSelectionFrame(ctx, layer) {
|
||||||
const lineWidth = 2 / this.canvas.viewport.zoom;
|
const lineWidth = 2 / this.canvas.viewport.zoom;
|
||||||
const handleRadius = 5 / this.canvas.viewport.zoom;
|
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||||
ctx.strokeStyle = '#00ff00';
|
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||||
ctx.lineWidth = lineWidth;
|
// --- CROP MODE ---
|
||||||
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
|
ctx.lineWidth = lineWidth;
|
||||||
const halfW = layer.width / 2;
|
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
|
||||||
const halfH = layer.height / 2;
|
ctx.strokeStyle = '#007bff';
|
||||||
// Górna krawędź
|
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
|
||||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
// Prawa krawędź
|
ctx.setLineDash([]);
|
||||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
// 2. Draw solid blue line for the crop bounds
|
||||||
// Dolna krawędź
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
// Lewa krawędź
|
const s = layer.cropBounds;
|
||||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||||
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
|
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||||
ctx.setLineDash([]);
|
const cropRectW = s.width * layerScaleX;
|
||||||
ctx.beginPath();
|
const cropRectH = s.height * layerScaleY;
|
||||||
ctx.moveTo(0, -layer.height / 2);
|
ctx.strokeStyle = '#007bff'; // Solid blue
|
||||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
|
||||||
ctx.stroke();
|
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
|
||||||
// Rysuj uchwyty
|
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);
|
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.strokeStyle = '#000000';
|
ctx.strokeStyle = '#000000';
|
||||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||||
for (const key in handles) {
|
for (const key in handles) {
|
||||||
|
// Skip rotation handle in crop mode
|
||||||
|
if (layer.cropMode && key === 'rot')
|
||||||
|
continue;
|
||||||
const point = handles[key];
|
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 localX = point.x - (layer.x + layer.width / 2);
|
||||||
const localY = point.y - (layer.y + layer.height / 2);
|
const localY = point.y - (layer.y + layer.height / 2);
|
||||||
const rad = -layer.rotation * Math.PI / 180;
|
const rad = -layer.rotation * Math.PI / 180;
|
||||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
||||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
||||||
|
ctx.beginPath();
|
||||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|||||||
@@ -293,6 +293,43 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
]),
|
]),
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
$el("div.painter-button-group", {}, [
|
$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", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Rotate +90°",
|
textContent: "Rotate +90°",
|
||||||
title: "Rotate selected layer(s) by +90 degrees",
|
title: "Rotate selected layer(s) by +90 degrees",
|
||||||
|
|||||||
@@ -51,6 +51,32 @@
|
|||||||
border-color: #3a76d6;
|
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 {
|
.painter-button.success {
|
||||||
border-color: #4ae27a;
|
border-color: #4ae27a;
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
|
|||||||
@@ -626,7 +626,10 @@ export class CanvasInteractions {
|
|||||||
width: layer.width, height: layer.height,
|
width: layer.width, height: layer.height,
|
||||||
rotation: layer.rotation,
|
rotation: layer.rotation,
|
||||||
centerX: layer.x + layer.width / 2,
|
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};
|
this.interaction.dragStart = {...worldCoords};
|
||||||
|
|
||||||
@@ -797,66 +800,137 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
if (this.interaction.isCtrlPressed) {
|
if (this.interaction.isCtrlPressed) {
|
||||||
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
||||||
const snappedMouseX = snapToGrid(mouseX);
|
mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
|
||||||
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX;
|
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
||||||
const snappedMouseY = snapToGrid(mouseY);
|
|
||||||
if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
||||||
|
|
||||||
const handle = this.interaction.resizeHandle;
|
const handle = this.interaction.resizeHandle;
|
||||||
const anchor = this.interaction.resizeAnchor;
|
const anchor = this.interaction.resizeAnchor;
|
||||||
|
|
||||||
const rad = o.rotation * Math.PI / 180;
|
const rad = o.rotation * Math.PI / 180;
|
||||||
const cos = Math.cos(rad);
|
const cos = Math.cos(rad);
|
||||||
const sin = Math.sin(rad);
|
const sin = Math.sin(rad);
|
||||||
|
|
||||||
|
// Vector from anchor to mouse
|
||||||
const vecX = mouseX - anchor.x;
|
const vecX = mouseX - anchor.x;
|
||||||
const vecY = mouseY - anchor.y;
|
const vecY = mouseY - anchor.y;
|
||||||
|
|
||||||
let newWidth = vecX * cos + vecY * sin;
|
// Rotate vector to align with layer's local coordinates
|
||||||
let newHeight = vecY * cos - vecX * sin;
|
let localVecX = vecX * cos + vecY * sin;
|
||||||
|
let localVecY = vecY * cos - vecX * sin;
|
||||||
|
|
||||||
if (isShiftPressed) {
|
// Determine sign based on handle
|
||||||
const originalAspectRatio = o.width / o.height;
|
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||||
|
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||||
|
|
||||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
localVecX *= signX;
|
||||||
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
localVecY *= signY;
|
||||||
} 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();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -433,8 +433,31 @@ export class CanvasLayers {
|
|||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
|
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
|
||||||
|
|
||||||
if (tempCtx) {
|
if (tempCtx) {
|
||||||
// Draw the original image
|
// This logic is now unified to handle both cropped and non-cropped images correctly.
|
||||||
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
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
|
// Apply the distance field mask using destination-in for transparency effect
|
||||||
tempCtx.globalCompositeOperation = 'destination-in';
|
tempCtx.globalCompositeOperation = 'destination-in';
|
||||||
@@ -446,26 +469,53 @@ export class CanvasLayers {
|
|||||||
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to normal drawing
|
// Fallback to normal drawing
|
||||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
this._drawLayerImage(ctx, layer);
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to normal drawing
|
// Fallback to normal drawing
|
||||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
this._drawLayerImage(ctx, layer);
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal drawing without blend area effect
|
// Normal drawing without blend area effect
|
||||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
this._drawLayerImage(ctx, layer);
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
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 {
|
private getDistanceFieldMaskSync(image: HTMLImageElement, blendArea: number): HTMLCanvasElement | null {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
let imageCache = this.distanceFieldCache.get(image);
|
let imageCache = this.distanceFieldCache.get(image);
|
||||||
@@ -606,23 +656,45 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getHandles(layer: Layer): Record<string, Point> {
|
getHandles(layer: Layer): Record<string, Point> {
|
||||||
const centerX = layer.x + layer.width / 2;
|
const layerCenterX = layer.x + layer.width / 2;
|
||||||
const centerY = layer.y + layer.height / 2;
|
const layerCenterY = layer.y + layer.height / 2;
|
||||||
const rad = layer.rotation * Math.PI / 180;
|
const rad = layer.rotation * Math.PI / 180;
|
||||||
const cos = Math.cos(rad);
|
const cos = Math.cos(rad);
|
||||||
const sin = Math.sin(rad);
|
const sin = Math.sin(rad);
|
||||||
|
|
||||||
const halfW = layer.width / 2;
|
let handleCenterX, handleCenterY, halfW, halfH;
|
||||||
const halfH = layer.height / 2;
|
|
||||||
|
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<string, Point> = {
|
const localHandles: Record<string, Point> = {
|
||||||
'n': { x: 0, y: -halfH },
|
'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH },
|
||||||
'ne': { x: halfW, y: -halfH },
|
'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH },
|
||||||
'e': { x: halfW, y: 0 },
|
's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH },
|
||||||
'se': { x: halfW, y: halfH },
|
'w': { x: -halfW, y: 0 }, 'nw': { 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 }
|
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -630,8 +702,8 @@ export class CanvasLayers {
|
|||||||
for (const key in localHandles) {
|
for (const key in localHandles) {
|
||||||
const p = localHandles[key];
|
const p = localHandles[key];
|
||||||
worldHandles[key] = {
|
worldHandles[key] = {
|
||||||
x: centerX + (p.x * cos - p.y * sin),
|
x: handleCenterX + (p.x * cos - p.y * sin),
|
||||||
y: centerY + (p.x * sin + p.y * cos)
|
y: handleCenterY + (p.x * sin + p.y * cos)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return worldHandles;
|
return worldHandles;
|
||||||
|
|||||||
@@ -532,38 +532,66 @@ export class CanvasRenderer {
|
|||||||
drawSelectionFrame(ctx: any, layer: any) {
|
drawSelectionFrame(ctx: any, layer: any) {
|
||||||
const lineWidth = 2 / this.canvas.viewport.zoom;
|
const lineWidth = 2 / this.canvas.viewport.zoom;
|
||||||
const handleRadius = 5 / 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)
|
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||||
const halfW = layer.width / 2;
|
// --- CROP MODE ---
|
||||||
const halfH = layer.height / 2;
|
ctx.lineWidth = lineWidth;
|
||||||
|
|
||||||
// Górna krawędź
|
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
|
||||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
ctx.strokeStyle = '#007bff';
|
||||||
// Prawa krawędź
|
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
|
||||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
// Dolna krawędź
|
ctx.setLineDash([]);
|
||||||
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)
|
// 2. Draw solid blue line for the crop bounds
|
||||||
ctx.setLineDash([]);
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
ctx.beginPath();
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
ctx.moveTo(0, -layer.height / 2);
|
const s = layer.cropBounds;
|
||||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Rysuj uchwyty
|
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);
|
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.strokeStyle = '#000000';
|
ctx.strokeStyle = '#000000';
|
||||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||||
|
|
||||||
for (const key in handles) {
|
for (const key in handles) {
|
||||||
|
// Skip rotation handle in crop mode
|
||||||
|
if (layer.cropMode && key === 'rot') continue;
|
||||||
|
|
||||||
const point = handles[key];
|
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 localX = point.x - (layer.x + layer.width / 2);
|
||||||
const localY = point.y - (layer.y + layer.height / 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 rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
||||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|||||||
@@ -326,6 +326,47 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
|
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
$el("div.painter-button-group", {}, [
|
$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", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Rotate +90°",
|
textContent: "Rotate +90°",
|
||||||
title: "Rotate selected layer(s) by +90 degrees",
|
title: "Rotate selected layer(s) by +90 degrees",
|
||||||
|
|||||||
@@ -51,6 +51,32 @@
|
|||||||
border-color: #3a76d6;
|
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 {
|
.painter-button.success {
|
||||||
border-color: #4ae27a;
|
border-color: #4ae27a;
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export interface Layer {
|
|||||||
flipH?: boolean;
|
flipH?: boolean;
|
||||||
flipV?: boolean;
|
flipV?: boolean;
|
||||||
blendArea?: number;
|
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 {
|
export interface ComfyNode {
|
||||||
|
|||||||
Reference in New Issue
Block a user