From 0f4f2cb1b0a22d2baa35ccd434bf14e61d6259ef Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 14 Aug 2025 13:54:10 +0200 Subject: [PATCH] feat: add interactive output area transform handles Implemented drag-to-resize functionality for the output area with visual transform handles on corners and edges. Users can now interactively resize the output area by dragging handles instead of using dialogs, with support for grid snapping and aspect ratio preservation. --- js/CanvasInteractions.js | 191 +++++++++++++++++++++++++++++++++ js/CanvasRenderer.js | 37 +++++++ js/CanvasView.js | 83 +-------------- src/CanvasInteractions.ts | 217 +++++++++++++++++++++++++++++++++++++- src/CanvasRenderer.ts | 43 ++++++++ src/CanvasView.ts | 85 +-------------- 6 files changed, 495 insertions(+), 161 deletions(-) diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index 250619e..290eb4c 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -39,6 +39,8 @@ export class CanvasInteractions { keyMovementInProgress: false, canvasResizeRect: null, canvasMoveRect: null, + outputAreaTransformHandle: null, + outputAreaTransformAnchor: { x: 0, y: 0 }, }; this.originalLayerPositions = new Map(); } @@ -157,6 +159,7 @@ export class CanvasInteractions { this.interaction.canvasMoveRect = null; this.interaction.hasClonedInDrag = false; this.interaction.transformingLayer = null; + this.interaction.outputAreaTransformHandle = null; this.canvas.canvas.style.cursor = 'default'; } handleMouseDown(e) { @@ -168,6 +171,18 @@ export class CanvasInteractions { // Don't render here - mask tool will handle its own drawing return; } + if (this.interaction.mode === 'transformingOutputArea') { + // Check if clicking on output area transform handle + const handle = this.getOutputAreaHandle(coords.world); + if (handle) { + this.startOutputAreaTransform(handle, coords.world); + return; + } + // If clicking outside, exit transform mode + this.interaction.mode = 'none'; + this.canvas.render(); + return; + } if (this.canvas.shapeTool.isActive) { this.canvas.shapeTool.addPoint(coords.world); return; @@ -258,6 +273,14 @@ export class CanvasInteractions { case 'movingCanvas': this.updateCanvasMove(coords.world); break; + case 'transformingOutputArea': + if (this.interaction.outputAreaTransformHandle) { + this.resizeOutputAreaFromHandle(coords.world, e.shiftKey); + } + else { + this.updateOutputAreaTransformCursor(coords.world); + } + break; default: this.updateCursor(coords.world); // Update brush cursor on overlay if mask tool is active @@ -285,6 +308,10 @@ export class CanvasInteractions { if (this.interaction.mode === 'movingCanvas') { this.finalizeCanvasMove(); } + if (this.interaction.mode === 'transformingOutputArea' && this.interaction.outputAreaTransformHandle) { + this.finalizeOutputAreaTransform(); + return; + } // Log layer positions when dragging ends if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) { this.logDragCompletion(coords); @@ -1128,4 +1155,168 @@ export class CanvasInteractions { } await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); } + // New methods for output area transformation + activateOutputAreaTransform() { + // Clear any existing interaction state before starting transform + this.resetInteractionState(); + // Deactivate any active tools that might conflict + if (this.canvas.shapeTool.isActive) { + this.canvas.shapeTool.deactivate(); + } + if (this.canvas.maskTool.isActive) { + this.canvas.maskTool.deactivate(); + } + // Clear selection to avoid confusion + this.canvas.canvasSelection.updateSelection([]); + // Set transform mode + this.interaction.mode = 'transformingOutputArea'; + this.canvas.render(); + } + getOutputAreaHandle(worldCoords) { + const bounds = this.canvas.outputAreaBounds; + const threshold = 10 / this.canvas.viewport.zoom; + // Define handle positions + const handles = { + 'nw': { x: bounds.x, y: bounds.y }, + 'n': { x: bounds.x + bounds.width / 2, y: bounds.y }, + 'ne': { x: bounds.x + bounds.width, y: bounds.y }, + 'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }, + 'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + 's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }, + 'sw': { x: bounds.x, y: bounds.y + bounds.height }, + 'w': { x: bounds.x, y: bounds.y + bounds.height / 2 }, + }; + for (const [name, pos] of Object.entries(handles)) { + const dx = worldCoords.x - pos.x; + const dy = worldCoords.y - pos.y; + if (Math.sqrt(dx * dx + dy * dy) < threshold) { + return name; + } + } + return null; + } + startOutputAreaTransform(handle, worldCoords) { + this.interaction.outputAreaTransformHandle = handle; + this.interaction.dragStart = { ...worldCoords }; + const bounds = this.canvas.outputAreaBounds; + this.interaction.transformOrigin = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + rotation: 0, + centerX: bounds.x + bounds.width / 2, + centerY: bounds.y + bounds.height / 2 + }; + // Set anchor point (opposite corner for resize) + const anchorMap = { + 'nw': { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + 'n': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }, + 'ne': { x: bounds.x, y: bounds.y + bounds.height }, + 'e': { x: bounds.x, y: bounds.y + bounds.height / 2 }, + 'se': { x: bounds.x, y: bounds.y }, + 's': { x: bounds.x + bounds.width / 2, y: bounds.y }, + 'sw': { x: bounds.x + bounds.width, y: bounds.y }, + 'w': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }, + }; + this.interaction.outputAreaTransformAnchor = anchorMap[handle]; + } + resizeOutputAreaFromHandle(worldCoords, isShiftPressed) { + const o = this.interaction.transformOrigin; + if (!o) + return; + const handle = this.interaction.outputAreaTransformHandle; + const anchor = this.interaction.outputAreaTransformAnchor; + let newX = o.x; + let newY = o.y; + let newWidth = o.width; + let newHeight = o.height; + // Calculate new dimensions based on handle + if (handle?.includes('w')) { + const deltaX = worldCoords.x - anchor.x; + newWidth = Math.abs(deltaX); + newX = Math.min(worldCoords.x, anchor.x); + } + if (handle?.includes('e')) { + const deltaX = worldCoords.x - anchor.x; + newWidth = Math.abs(deltaX); + newX = Math.min(worldCoords.x, anchor.x); + } + if (handle?.includes('n')) { + const deltaY = worldCoords.y - anchor.y; + newHeight = Math.abs(deltaY); + newY = Math.min(worldCoords.y, anchor.y); + } + if (handle?.includes('s')) { + const deltaY = worldCoords.y - anchor.y; + newHeight = Math.abs(deltaY); + newY = Math.min(worldCoords.y, anchor.y); + } + // Maintain aspect ratio if shift is held + if (isShiftPressed && o.width > 0 && o.height > 0) { + const aspectRatio = o.width / o.height; + if (handle === 'n' || handle === 's') { + newWidth = newHeight * aspectRatio; + } + else if (handle === 'e' || handle === 'w') { + newHeight = newWidth / aspectRatio; + } + else { + // Corner handles + const proposedRatio = newWidth / newHeight; + if (proposedRatio > aspectRatio) { + newHeight = newWidth / aspectRatio; + } + else { + newWidth = newHeight * aspectRatio; + } + } + } + // Snap to grid if Ctrl is held + if (this.interaction.isCtrlPressed) { + newX = snapToGrid(newX); + newY = snapToGrid(newY); + newWidth = snapToGrid(newWidth); + newHeight = snapToGrid(newHeight); + } + // Apply minimum size + if (newWidth < 10) + newWidth = 10; + if (newHeight < 10) + newHeight = 10; + // Update output area bounds temporarily for preview + this.canvas.outputAreaBounds = { + x: newX, + y: newY, + width: newWidth, + height: newHeight + }; + this.canvas.render(); + } + updateOutputAreaTransformCursor(worldCoords) { + const handle = this.getOutputAreaHandle(worldCoords); + if (handle) { + const cursorMap = { + 'n': 'ns-resize', 's': 'ns-resize', + 'e': 'ew-resize', 'w': 'ew-resize', + 'nw': 'nwse-resize', 'se': 'nwse-resize', + 'ne': 'nesw-resize', 'sw': 'nesw-resize', + }; + this.canvas.canvas.style.cursor = cursorMap[handle] || 'default'; + } + else { + this.canvas.canvas.style.cursor = 'default'; + } + } + finalizeOutputAreaTransform() { + const bounds = this.canvas.outputAreaBounds; + // Update canvas size and mask tool + this.canvas.updateOutputAreaSize(bounds.width, bounds.height); + // Update mask canvas for new output area + this.canvas.maskTool.updateMaskCanvasForOutputArea(); + // Save state + this.canvas.saveState(); + // Reset transform handle but keep transform mode active + this.interaction.outputAreaTransformHandle = null; + } } diff --git a/js/CanvasRenderer.js b/js/CanvasRenderer.js index 38938e4..31c9f3e 100644 --- a/js/CanvasRenderer.js +++ b/js/CanvasRenderer.js @@ -147,6 +147,7 @@ export class CanvasRenderer { this.renderInteractionElements(ctx); this.canvas.shapeTool.render(ctx); this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active + this.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles this.renderLayerInfo(ctx); // Update custom shape menu position and visibility if (this.canvas.outputAreaShape) { @@ -832,4 +833,40 @@ export class CanvasRenderer { // Just ensure it's the right size this.updateOverlaySize(); } + /** + * Draw transform handles for output area when in transform mode + */ + renderOutputAreaTransformHandles(ctx) { + if (this.canvas.canvasInteractions.interaction.mode !== 'transformingOutputArea') { + return; + } + const bounds = this.canvas.outputAreaBounds; + const handleRadius = 5 / this.canvas.viewport.zoom; + // Define handle positions + const handles = { + 'nw': { x: bounds.x, y: bounds.y }, + 'n': { x: bounds.x + bounds.width / 2, y: bounds.y }, + 'ne': { x: bounds.x + bounds.width, y: bounds.y }, + 'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }, + 'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + 's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }, + 'sw': { x: bounds.x, y: bounds.y + bounds.height }, + 'w': { x: bounds.x, y: bounds.y + bounds.height / 2 }, + }; + // Draw handles + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1 / this.canvas.viewport.zoom; + for (const [name, pos] of Object.entries(handles)) { + ctx.beginPath(); + ctx.arc(pos.x, pos.y, handleRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + // Draw a highlight around the output area + ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)'; + ctx.lineWidth = 3 / this.canvas.viewport.zoom; + ctx.setLineDash([]); + ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height); + } } diff --git a/js/CanvasView.js b/js/CanvasView.js index 92171e9..bdc0f65 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -234,86 +234,11 @@ async function createCanvasWidget(node, widget, app) { }), $el("button.painter-button", { textContent: "Output Area Size", - title: "Set the size of the output area", + title: "Transform output area - drag handles to resize", onclick: () => { - const dialog = $el("div.painter-dialog", { - style: { - position: 'fixed', - left: '50%', - top: '50%', - transform: 'translate(-50%, -50%)', - zIndex: '9999' - } - }, [ - $el("div", { - style: { - color: "white", - marginBottom: "10px" - } - }, [ - $el("label", { - style: { - marginRight: "5px" - } - }, [ - $el("span", {}, ["Width: "]) - ]), - $el("input", { - type: "number", - id: "canvas-width", - value: String(canvas.width), - min: "1", - max: "4096" - }) - ]), - $el("div", { - style: { - color: "white", - marginBottom: "10px" - } - }, [ - $el("label", { - style: { - marginRight: "5px" - } - }, [ - $el("span", {}, ["Height: "]) - ]), - $el("input", { - type: "number", - id: "canvas-height", - value: String(canvas.height), - min: "1", - max: "4096" - }) - ]), - $el("div", { - style: { - textAlign: "right" - } - }, [ - $el("button", { - id: "cancel-size", - textContent: "Cancel" - }), - $el("button", { - id: "confirm-size", - textContent: "OK" - }) - ]) - ]); - document.body.appendChild(dialog); - document.getElementById('confirm-size').onclick = () => { - const widthInput = document.getElementById('canvas-width'); - const heightInput = document.getElementById('canvas-height'); - const width = parseInt(widthInput.value) || canvas.width; - const height = parseInt(heightInput.value) || canvas.height; - canvas.setOutputAreaSize(width, height); - document.body.removeChild(dialog); - }; - document.getElementById('cancel-size').onclick = () => { - document.body.removeChild(dialog); - }; + // Activate output area transform mode + canvas.canvasInteractions.activateOutputAreaTransform(); + showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000); } }), $el("button.painter-button.requires-selection", { diff --git a/src/CanvasInteractions.ts b/src/CanvasInteractions.ts index de92bbc..3cdeaae 100644 --- a/src/CanvasInteractions.ts +++ b/src/CanvasInteractions.ts @@ -31,7 +31,7 @@ interface TransformOrigin { } interface InteractionState { - mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape'; + mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape' | 'transformingOutputArea'; panStart: Point; dragStart: Point; transformOrigin: TransformOrigin | null; @@ -49,6 +49,8 @@ interface InteractionState { keyMovementInProgress: boolean; canvasResizeRect: { x: number, y: number, width: number, height: number } | null; canvasMoveRect: { x: number, y: number, width: number, height: number } | null; + outputAreaTransformHandle: string | null; + outputAreaTransformAnchor: Point; } export class CanvasInteractions { @@ -94,6 +96,8 @@ export class CanvasInteractions { keyMovementInProgress: false, canvasResizeRect: null, canvasMoveRect: null, + outputAreaTransformHandle: null, + outputAreaTransformAnchor: { x: 0, y: 0 }, }; this.originalLayerPositions = new Map(); } @@ -238,6 +242,7 @@ export class CanvasInteractions { this.interaction.canvasMoveRect = null; this.interaction.hasClonedInDrag = false; this.interaction.transformingLayer = null; + this.interaction.outputAreaTransformHandle = null; this.canvas.canvas.style.cursor = 'default'; } @@ -252,6 +257,19 @@ export class CanvasInteractions { return; } + if (this.interaction.mode === 'transformingOutputArea') { + // Check if clicking on output area transform handle + const handle = this.getOutputAreaHandle(coords.world); + if (handle) { + this.startOutputAreaTransform(handle, coords.world); + return; + } + // If clicking outside, exit transform mode + this.interaction.mode = 'none'; + this.canvas.render(); + return; + } + if (this.canvas.shapeTool.isActive) { this.canvas.shapeTool.addPoint(coords.world); return; @@ -352,6 +370,13 @@ export class CanvasInteractions { case 'movingCanvas': this.updateCanvasMove(coords.world); break; + case 'transformingOutputArea': + if (this.interaction.outputAreaTransformHandle) { + this.resizeOutputAreaFromHandle(coords.world, e.shiftKey); + } else { + this.updateOutputAreaTransformCursor(coords.world); + } + break; default: this.updateCursor(coords.world); // Update brush cursor on overlay if mask tool is active @@ -384,6 +409,11 @@ export class CanvasInteractions { this.finalizeCanvasMove(); } + if (this.interaction.mode === 'transformingOutputArea' && this.interaction.outputAreaTransformHandle) { + this.finalizeOutputAreaTransform(); + return; + } + // Log layer positions when dragging ends if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) { this.logDragCompletion(coords); @@ -1313,4 +1343,189 @@ export class CanvasInteractions { await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); } + // New methods for output area transformation + public activateOutputAreaTransform(): void { + // Clear any existing interaction state before starting transform + this.resetInteractionState(); + + // Deactivate any active tools that might conflict + if (this.canvas.shapeTool.isActive) { + this.canvas.shapeTool.deactivate(); + } + if (this.canvas.maskTool.isActive) { + this.canvas.maskTool.deactivate(); + } + + // Clear selection to avoid confusion + this.canvas.canvasSelection.updateSelection([]); + + // Set transform mode + this.interaction.mode = 'transformingOutputArea'; + this.canvas.render(); + } + + private getOutputAreaHandle(worldCoords: Point): string | null { + const bounds = this.canvas.outputAreaBounds; + const threshold = 10 / this.canvas.viewport.zoom; + + // Define handle positions + const handles = { + 'nw': { x: bounds.x, y: bounds.y }, + 'n': { x: bounds.x + bounds.width / 2, y: bounds.y }, + 'ne': { x: bounds.x + bounds.width, y: bounds.y }, + 'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }, + 'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + 's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }, + 'sw': { x: bounds.x, y: bounds.y + bounds.height }, + 'w': { x: bounds.x, y: bounds.y + bounds.height / 2 }, + }; + + for (const [name, pos] of Object.entries(handles)) { + const dx = worldCoords.x - pos.x; + const dy = worldCoords.y - pos.y; + if (Math.sqrt(dx * dx + dy * dy) < threshold) { + return name; + } + } + + return null; + } + + private startOutputAreaTransform(handle: string, worldCoords: Point): void { + this.interaction.outputAreaTransformHandle = handle; + this.interaction.dragStart = { ...worldCoords }; + + const bounds = this.canvas.outputAreaBounds; + this.interaction.transformOrigin = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + rotation: 0, + centerX: bounds.x + bounds.width / 2, + centerY: bounds.y + bounds.height / 2 + }; + + // Set anchor point (opposite corner for resize) + const anchorMap: { [key: string]: Point } = { + 'nw': { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + 'n': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }, + 'ne': { x: bounds.x, y: bounds.y + bounds.height }, + 'e': { x: bounds.x, y: bounds.y + bounds.height / 2 }, + 'se': { x: bounds.x, y: bounds.y }, + 's': { x: bounds.x + bounds.width / 2, y: bounds.y }, + 'sw': { x: bounds.x + bounds.width, y: bounds.y }, + 'w': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }, + }; + + this.interaction.outputAreaTransformAnchor = anchorMap[handle]; + } + + private resizeOutputAreaFromHandle(worldCoords: Point, isShiftPressed: boolean): void { + const o = this.interaction.transformOrigin; + if (!o) return; + + const handle = this.interaction.outputAreaTransformHandle; + const anchor = this.interaction.outputAreaTransformAnchor; + + let newX = o.x; + let newY = o.y; + let newWidth = o.width; + let newHeight = o.height; + + // Calculate new dimensions based on handle + if (handle?.includes('w')) { + const deltaX = worldCoords.x - anchor.x; + newWidth = Math.abs(deltaX); + newX = Math.min(worldCoords.x, anchor.x); + } + if (handle?.includes('e')) { + const deltaX = worldCoords.x - anchor.x; + newWidth = Math.abs(deltaX); + newX = Math.min(worldCoords.x, anchor.x); + } + if (handle?.includes('n')) { + const deltaY = worldCoords.y - anchor.y; + newHeight = Math.abs(deltaY); + newY = Math.min(worldCoords.y, anchor.y); + } + if (handle?.includes('s')) { + const deltaY = worldCoords.y - anchor.y; + newHeight = Math.abs(deltaY); + newY = Math.min(worldCoords.y, anchor.y); + } + + // Maintain aspect ratio if shift is held + if (isShiftPressed && o.width > 0 && o.height > 0) { + const aspectRatio = o.width / o.height; + if (handle === 'n' || handle === 's') { + newWidth = newHeight * aspectRatio; + } else if (handle === 'e' || handle === 'w') { + newHeight = newWidth / aspectRatio; + } else { + // Corner handles + const proposedRatio = newWidth / newHeight; + if (proposedRatio > aspectRatio) { + newHeight = newWidth / aspectRatio; + } else { + newWidth = newHeight * aspectRatio; + } + } + } + + // Snap to grid if Ctrl is held + if (this.interaction.isCtrlPressed) { + newX = snapToGrid(newX); + newY = snapToGrid(newY); + newWidth = snapToGrid(newWidth); + newHeight = snapToGrid(newHeight); + } + + // Apply minimum size + if (newWidth < 10) newWidth = 10; + if (newHeight < 10) newHeight = 10; + + // Update output area bounds temporarily for preview + this.canvas.outputAreaBounds = { + x: newX, + y: newY, + width: newWidth, + height: newHeight + }; + + this.canvas.render(); + } + + private updateOutputAreaTransformCursor(worldCoords: Point): void { + const handle = this.getOutputAreaHandle(worldCoords); + + if (handle) { + const cursorMap: { [key: string]: string } = { + 'n': 'ns-resize', 's': 'ns-resize', + 'e': 'ew-resize', 'w': 'ew-resize', + 'nw': 'nwse-resize', 'se': 'nwse-resize', + 'ne': 'nesw-resize', 'sw': 'nesw-resize', + }; + this.canvas.canvas.style.cursor = cursorMap[handle] || 'default'; + } else { + this.canvas.canvas.style.cursor = 'default'; + } + } + + private finalizeOutputAreaTransform(): void { + const bounds = this.canvas.outputAreaBounds; + + // Update canvas size and mask tool + this.canvas.updateOutputAreaSize(bounds.width, bounds.height); + + // Update mask canvas for new output area + this.canvas.maskTool.updateMaskCanvasForOutputArea(); + + // Save state + this.canvas.saveState(); + + // Reset transform handle but keep transform mode active + this.interaction.outputAreaTransformHandle = null; + } + } diff --git a/src/CanvasRenderer.ts b/src/CanvasRenderer.ts index 0c6a368..a4844ae 100644 --- a/src/CanvasRenderer.ts +++ b/src/CanvasRenderer.ts @@ -195,6 +195,7 @@ export class CanvasRenderer { this.renderInteractionElements(ctx); this.canvas.shapeTool.render(ctx); this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active + this.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles this.renderLayerInfo(ctx); // Update custom shape menu position and visibility @@ -1011,4 +1012,46 @@ export class CanvasRenderer { // Just ensure it's the right size this.updateOverlaySize(); } + + /** + * Draw transform handles for output area when in transform mode + */ + renderOutputAreaTransformHandles(ctx: any): void { + if (this.canvas.canvasInteractions.interaction.mode !== 'transformingOutputArea') { + return; + } + + const bounds = this.canvas.outputAreaBounds; + const handleRadius = 5 / this.canvas.viewport.zoom; + + // Define handle positions + const handles = { + 'nw': { x: bounds.x, y: bounds.y }, + 'n': { x: bounds.x + bounds.width / 2, y: bounds.y }, + 'ne': { x: bounds.x + bounds.width, y: bounds.y }, + 'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }, + 'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + 's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }, + 'sw': { x: bounds.x, y: bounds.y + bounds.height }, + 'w': { x: bounds.x, y: bounds.y + bounds.height / 2 }, + }; + + // Draw handles + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1 / this.canvas.viewport.zoom; + + for (const [name, pos] of Object.entries(handles)) { + ctx.beginPath(); + ctx.arc(pos.x, pos.y, handleRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + + // Draw a highlight around the output area + ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)'; + ctx.lineWidth = 3 / this.canvas.viewport.zoom; + ctx.setLineDash([]); + ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height); + } } diff --git a/src/CanvasView.ts b/src/CanvasView.ts index c3f79fa..955f4cc 100644 --- a/src/CanvasView.ts +++ b/src/CanvasView.ts @@ -289,88 +289,11 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp): }), $el("button.painter-button", { textContent: "Output Area Size", - title: "Set the size of the output area", + title: "Transform output area - drag handles to resize", onclick: () => { - const dialog = $el("div.painter-dialog", { - style: { - position: 'fixed', - left: '50%', - top: '50%', - transform: 'translate(-50%, -50%)', - zIndex: '9999' - } - }, [ - $el("div", { - style: { - color: "white", - marginBottom: "10px" - } - }, [ - $el("label", { - style: { - marginRight: "5px" - } - }, [ - $el("span", {}, ["Width: "]) - ]), - $el("input", { - type: "number", - id: "canvas-width", - value: String(canvas.width), - min: "1", - max: "4096" - }) - ]), - $el("div", { - style: { - color: "white", - marginBottom: "10px" - } - }, [ - $el("label", { - style: { - marginRight: "5px" - } - }, [ - $el("span", {}, ["Height: "]) - ]), - $el("input", { - type: "number", - id: "canvas-height", - value: String(canvas.height), - min: "1", - max: "4096" - }) - ]), - $el("div", { - style: { - textAlign: "right" - } - }, [ - $el("button", { - id: "cancel-size", - textContent: "Cancel" - }), - $el("button", { - id: "confirm-size", - textContent: "OK" - }) - ]) - ]); - document.body.appendChild(dialog); - - (document.getElementById('confirm-size') as HTMLButtonElement).onclick = () => { - const widthInput = document.getElementById('canvas-width') as HTMLInputElement; - const heightInput = document.getElementById('canvas-height') as HTMLInputElement; - const width = parseInt(widthInput.value) || canvas.width; - const height = parseInt(heightInput.value) || canvas.height; - canvas.setOutputAreaSize(width, height); - document.body.removeChild(dialog); - }; - - (document.getElementById('cancel-size') as HTMLButtonElement).onclick = () => { - document.body.removeChild(dialog); - }; + // Activate output area transform mode + canvas.canvasInteractions.activateOutputAreaTransform(); + showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000); } }), $el("button.painter-button.requires-selection", {