diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayerAdapter.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayerAdapter.ts index af9d8462bb..30f3c4f6be 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayerAdapter.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayerAdapter.ts @@ -3,11 +3,15 @@ import { deepClone } from 'common/util/deepClone'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasObjectRenderer } from 'features/controlLayers/konva/CanvasObjectRenderer'; import { CanvasTransformer } from 'features/controlLayers/konva/CanvasTransformer'; +import { getLastPointOfLine } from 'features/controlLayers/konva/util'; import type { + CanvasBrushLineState, CanvasControlLayerState, CanvasEntityIdentifier, + CanvasEraserLineState, CanvasRasterLayerState, CanvasV2State, + Coordinate, Rect, } from 'features/controlLayers/store/types'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; @@ -180,6 +184,19 @@ export class CanvasLayerAdapter { return stableHash(arg); }; + getLastPointOfLastLine = (type: CanvasBrushLineState['type'] | CanvasEraserLineState['type']): Coordinate | null => { + const lastObject = this.state.objects[this.state.objects.length - 1]; + if (!lastObject) { + return null; + } + + if (lastObject.type === type) { + return getLastPointOfLine(lastObject.points); + } + + return null; + }; + logDebugInfo(msg = 'Debug info') { const info = { repr: this.repr(), diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts index 09d641decb..b4131ddb05 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts @@ -19,7 +19,6 @@ import type { CanvasLayerAdapter } from './CanvasLayerAdapter'; import type { CanvasMaskAdapter } from './CanvasMaskAdapter'; import { CanvasPreviewModule } from './CanvasPreviewModule'; import { CanvasStateApiModule } from './CanvasStateApiModule'; -import { setStageEventHandlers } from './events'; export const $canvasManager = atom(null); const TYPE = 'manager'; @@ -110,7 +109,6 @@ export class CanvasManager { this.stateApi.$currentFill.set(this.stateApi.getCurrentFill()); this.stateApi.$selectedEntity.set(this.stateApi.getSelectedEntity()); - const cleanupEventHandlers = setStageEventHandlers(this); const cleanupStage = this.stage.initialize(); const cleanupStore = this.store.subscribe(this.renderer.render); @@ -122,7 +120,6 @@ export class CanvasManager { this.background.destroy(); this.preview.destroy(); cleanupStore(); - cleanupEventHandlers(); cleanupStage(); }; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasMaskAdapter.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasMaskAdapter.ts index 3573a79a17..ec2a50501e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasMaskAdapter.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasMaskAdapter.ts @@ -3,11 +3,15 @@ import { deepClone } from 'common/util/deepClone'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasObjectRenderer } from 'features/controlLayers/konva/CanvasObjectRenderer'; import { CanvasTransformer } from 'features/controlLayers/konva/CanvasTransformer'; +import { getLastPointOfLine } from 'features/controlLayers/konva/util'; import type { + CanvasBrushLineState, CanvasEntityIdentifier, + CanvasEraserLineState, CanvasInpaintMaskState, CanvasRegionalGuidanceState, CanvasV2State, + Coordinate, Rect, } from 'features/controlLayers/store/types'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; @@ -136,6 +140,19 @@ export class CanvasMaskAdapter { this.konva.layer.visible(isEnabled); }; + getLastPointOfLastLine = (type: CanvasBrushLineState['type'] | CanvasEraserLineState['type']): Coordinate | null => { + const lastObject = this.state.objects[this.state.objects.length - 1]; + if (!lastObject) { + return null; + } + + if (lastObject.type === type) { + return getLastPointOfLine(lastObject.points); + } + + return null; + }; + repr = () => { return { id: this.id, diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts index 624027d177..995751de10 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts @@ -1,8 +1,10 @@ import type { SerializableObject } from 'common/types'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { CANVAS_SCALE_BY } from 'features/controlLayers/konva/constants'; import { getPrefixedId, getRectUnion } from 'features/controlLayers/konva/util'; import type { Coordinate, Dimensions, Rect } from 'features/controlLayers/store/types'; import type Konva from 'konva'; +import type { KonvaEventObject } from 'konva/lib/Node'; import { clamp } from 'lodash-es'; import type { Logger } from 'roarr'; @@ -27,6 +29,18 @@ export class CanvasStageModule { this.konva = { stage }; } + setEventListeners = () => { + this.konva.stage.on('wheel', this.onStageMouseWheel); + this.konva.stage.on('dragmove', this.onStageDragMove); + this.konva.stage.on('dragend', this.onStageDragEnd); + + return () => { + this.konva.stage.off('wheel', this.onStageMouseWheel); + this.konva.stage.off('dragmove', this.onStageDragMove); + this.konva.stage.off('dragend', this.onStageDragEnd); + }; + }; + initialize = () => { this.log.debug('Initializing stage'); this.konva.stage.container(this.container); @@ -34,11 +48,13 @@ export class CanvasStageModule { resizeObserver.observe(this.container); this.fitStageToContainer(); this.fitLayersToStage(); + const cleanupListeners = this.setEventListeners(); return () => { this.log.debug('Destroying stage'); resizeObserver.disconnect(); this.konva.stage.destroy(); + cleanupListeners(); }; }; @@ -172,6 +188,54 @@ export class CanvasStageModule { }); }; + onStageMouseWheel = (e: KonvaEventObject) => { + e.evt.preventDefault(); + + if (e.evt.ctrlKey || e.evt.metaKey) { + return; + } + + // We need the absolute cursor position - not the scaled position + const cursorPos = this.konva.stage.getPointerPosition(); + + if (cursorPos) { + // When wheeling on trackpad, e.evt.ctrlKey is true - in that case, let's reverse the direction + const delta = e.evt.ctrlKey ? -e.evt.deltaY : e.evt.deltaY; + const scale = this.manager.stage.getScale() * CANVAS_SCALE_BY ** delta; + this.manager.stage.setScale(scale, cursorPos); + } + }; + + onStageDragMove = (e: KonvaEventObject) => { + if (e.target !== this.konva.stage) { + return; + } + + this.manager.stateApi.$stageAttrs.set({ + // Stage position should always be an integer, else we get fractional pixels which are blurry + x: Math.floor(this.konva.stage.x()), + y: Math.floor(this.konva.stage.y()), + width: this.konva.stage.width(), + height: this.konva.stage.height(), + scale: this.konva.stage.scaleX(), + }); + }; + + onStageDragEnd = (e: KonvaEventObject) => { + if (e.target !== this.konva.stage) { + return; + } + + this.manager.stateApi.$stageAttrs.set({ + // Stage position should always be an integer, else we get fractional pixels which are blurry + x: Math.floor(this.konva.stage.x()), + y: Math.floor(this.konva.stage.y()), + width: this.konva.stage.width(), + height: this.konva.stage.height(), + scale: this.konva.stage.scaleX(), + }); + }; + /** * Gets the scale of the stage. The stage is always scaled uniformly in x and y. */ diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts index a01c0d9cd4..4123b9b190 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts @@ -2,11 +2,33 @@ import type { SerializableObject } from 'common/types'; import { rgbaColorToString, rgbColorToString } from 'common/util/colorCodeTransformers'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import type { CanvasPreviewModule } from 'features/controlLayers/konva/CanvasPreviewModule'; -import { BRUSH_BORDER_INNER_COLOR, BRUSH_BORDER_OUTER_COLOR } from 'features/controlLayers/konva/constants'; -import { alignCoordForTool, getPrefixedId } from 'features/controlLayers/konva/util'; -import type { Tool } from 'features/controlLayers/store/types'; +import { + BRUSH_BORDER_INNER_COLOR, + BRUSH_BORDER_OUTER_COLOR, + BRUSH_SPACING_TARGET_SCALE, +} from 'features/controlLayers/konva/constants'; +import { + alignCoordForTool, + calculateNewBrushSizeFromWheelDelta, + getIsPrimaryMouseDown, + getLastPointOfLine, + getPrefixedId, + getScaledCursorPosition, + offsetCoord, + validateCandidatePoint, +} from 'features/controlLayers/konva/util'; +import type { + CanvasControlLayerState, + CanvasInpaintMaskState, + CanvasRasterLayerState, + CanvasRegionalGuidanceState, + Coordinate, + RgbColor, + Tool, +} from 'features/controlLayers/store/types'; import { isDrawableEntity } from 'features/controlLayers/store/types'; import Konva from 'konva'; +import type { KonvaEventObject } from 'konva/lib/Node'; import type { Logger } from 'roarr'; export class CanvasToolModule { @@ -25,6 +47,7 @@ export class CanvasToolModule { log: Logger; konva: { + stage: Konva.Stage; group: Konva.Group; brush: { group: Konva.Group; @@ -67,6 +90,7 @@ export class CanvasToolModule { this.path = this.manager.path.concat(this.id); this.log = this.manager.buildLogger(this.getLoggingContext); this.konva = { + stage: this.manager.stage.konva.stage, group: new Konva.Group({ name: `${this.type}:group`, listening: false }), brush: { group: new Konva.Group({ name: `${this.type}:brush_group`, listening: false }), @@ -218,6 +242,10 @@ export class CanvasToolModule { this.render(); }) ); + + const cleanupListeners = this.setEventListeners(); + + this.subscriptions.add(cleanupListeners); } destroy = () => { @@ -277,7 +305,7 @@ export class CanvasToolModule { stage.setIsDraggable(tool === 'view'); - if (!cursorPos || renderedEntityCount === 0 || !isDrawable) { + if (!cursorPos || renderedEntityCount === 0) { // We can bail early if the mouse isn't over the stage or there are no layers this.konva.group.visible(false); } else { @@ -421,6 +449,445 @@ export class CanvasToolModule { } } + syncLastCursorPos = (): Coordinate | null => { + const pos = getScaledCursorPosition(this.konva.stage); + if (!pos) { + return null; + } + this.manager.stateApi.$lastCursorPos.set(pos); + return pos; + }; + + getColorUnderCursor = (): RgbColor | null => { + const pos = this.konva.stage.getPointerPosition(); + if (!pos) { + return null; + } + const ctx = this.konva.stage + .toCanvas({ x: pos.x, y: pos.y, width: 1, height: 1, imageSmoothingEnabled: false }) + .getContext('2d'); + + if (!ctx) { + return null; + } + + const [r, g, b, _a] = ctx.getImageData(0, 0, 1, 1).data; + + if (r === undefined || g === undefined || b === undefined) { + return null; + } + + return { r, g, b }; + }; + + getClip( + entity: CanvasRegionalGuidanceState | CanvasControlLayerState | CanvasRasterLayerState | CanvasInpaintMaskState + ) { + const settings = this.manager.stateApi.getSettings(); + + if (settings.clipToBbox) { + const { x, y, width, height } = this.manager.stateApi.getBbox().rect; + return { + x: x - entity.position.x, + y: y - entity.position.y, + width, + height, + }; + } else { + const { x, y } = this.manager.stage.getPosition(); + const scale = this.manager.stage.getScale(); + const { width, height } = this.manager.stage.getSize(); + return { + x: -x / scale - entity.position.x, + y: -y / scale - entity.position.y, + width: width / scale, + height: height / scale, + }; + } + } + + setEventListeners = (): (() => void) => { + this.konva.stage.on('mouseenter', this.onStageMouseEnter); + this.konva.stage.on('mousedown', this.onStageMouseDown); + this.konva.stage.on('mouseup', this.onStageMouseUp); + this.konva.stage.on('mousemove', this.onStageMouseMove); + this.konva.stage.on('mouseleave', this.onStageMouseLeave); + this.konva.stage.on('wheel', this.onStageMouseWheel); + + window.addEventListener('keydown', this.onKeyDown); + window.addEventListener('keyup', this.onKeyUp); + + return () => { + this.konva.stage.off('mouseenter', this.onStageMouseEnter); + this.konva.stage.off('mousedown', this.onStageMouseDown); + this.konva.stage.off('mouseup', this.onStageMouseUp); + this.konva.stage.off('mousemove', this.onStageMouseMove); + this.konva.stage.off('mouseleave', this.onStageMouseLeave); + + this.konva.stage.off('wheel', this.onStageMouseWheel); + window.removeEventListener('keydown', this.onKeyDown); + window.removeEventListener('keyup', this.onKeyUp); + }; + }; + + onStageMouseEnter = (_: KonvaEventObject) => { + this.render(); + }; + + onStageMouseDown = async (e: KonvaEventObject) => { + this.manager.stateApi.$isMouseDown.set(true); + const toolState = this.manager.stateApi.getToolState(); + const pos = this.syncLastCursorPos(); + const selectedEntity = this.manager.stateApi.getSelectedEntity(); + + if (toolState.selected === 'colorPicker') { + const color = this.getColorUnderCursor(); + if (color) { + this.manager.stateApi.$colorUnderCursor.set(color); + } + if (color) { + this.manager.stateApi.setFill({ ...toolState.fill, ...color }); + } + this.render(); + } else { + const isDrawable = selectedEntity?.state.isEnabled; + if (pos && isDrawable && !this.manager.stateApi.$spaceKey.get() && getIsPrimaryMouseDown(e)) { + this.manager.stateApi.$lastMouseDownPos.set(pos); + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); + + if (toolState.selected === 'brush') { + const lastLinePoint = selectedEntity.adapter.getLastPointOfLastLine('brush_line'); + const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); + if (e.evt.shiftKey && lastLinePoint) { + // Create a straight line from the last line point + if (selectedEntity.adapter.renderer.bufferState) { + selectedEntity.adapter.renderer.commitBuffer(); + } + + await selectedEntity.adapter.renderer.setBuffer({ + id: getPrefixedId('brush_line'), + type: 'brush_line', + points: [ + // The last point of the last line is already normalized to the entity's coordinates + lastLinePoint.x, + lastLinePoint.y, + alignedPoint.x, + alignedPoint.y, + ], + strokeWidth: toolState.brush.width, + color: this.manager.stateApi.getCurrentFill(), + clip: this.getClip(selectedEntity.state), + }); + } else { + if (selectedEntity.adapter.renderer.bufferState) { + selectedEntity.adapter.renderer.commitBuffer(); + } + await selectedEntity.adapter.renderer.setBuffer({ + id: getPrefixedId('brush_line'), + type: 'brush_line', + points: [alignedPoint.x, alignedPoint.y], + strokeWidth: toolState.brush.width, + color: this.manager.stateApi.getCurrentFill(), + clip: this.getClip(selectedEntity.state), + }); + } + this.manager.stateApi.$lastAddedPoint.set(alignedPoint); + } + + if (toolState.selected === 'eraser') { + const lastLinePoint = selectedEntity.adapter.getLastPointOfLastLine('eraser_line'); + const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); + if (e.evt.shiftKey && lastLinePoint) { + // Create a straight line from the last line point + if (selectedEntity.adapter.renderer.bufferState) { + selectedEntity.adapter.renderer.commitBuffer(); + } + await selectedEntity.adapter.renderer.setBuffer({ + id: getPrefixedId('eraser_line'), + type: 'eraser_line', + points: [ + // The last point of the last line is already normalized to the entity's coordinates + lastLinePoint.x, + lastLinePoint.y, + alignedPoint.x, + alignedPoint.y, + ], + strokeWidth: toolState.eraser.width, + clip: this.getClip(selectedEntity.state), + }); + } else { + if (selectedEntity.adapter.renderer.bufferState) { + selectedEntity.adapter.renderer.commitBuffer(); + } + await selectedEntity.adapter.renderer.setBuffer({ + id: getPrefixedId('eraser_line'), + type: 'eraser_line', + points: [alignedPoint.x, alignedPoint.y], + strokeWidth: toolState.eraser.width, + clip: this.getClip(selectedEntity.state), + }); + } + this.manager.stateApi.$lastAddedPoint.set(alignedPoint); + } + + if (toolState.selected === 'rect') { + if (selectedEntity.adapter.renderer.bufferState) { + selectedEntity.adapter.renderer.commitBuffer(); + } + await selectedEntity.adapter.renderer.setBuffer({ + id: getPrefixedId('rect'), + type: 'rect', + rect: { x: Math.round(normalizedPoint.x), y: Math.round(normalizedPoint.y), width: 0, height: 0 }, + color: this.manager.stateApi.getCurrentFill(), + }); + } + } + } + }; + + onStageMouseUp = (_: KonvaEventObject) => { + this.manager.stateApi.$isMouseDown.set(false); + const pos = this.manager.stateApi.$lastCursorPos.get(); + const selectedEntity = this.manager.stateApi.getSelectedEntity(); + const isDrawable = selectedEntity?.state.isEnabled; + + if (pos && isDrawable && !this.manager.stateApi.$spaceKey.get()) { + const toolState = this.manager.stateApi.getToolState(); + + if (toolState.selected === 'brush') { + const drawingBuffer = selectedEntity.adapter.renderer.bufferState; + if (drawingBuffer?.type === 'brush_line') { + selectedEntity.adapter.renderer.commitBuffer(); + } else { + selectedEntity.adapter.renderer.clearBuffer(); + } + } + + if (toolState.selected === 'eraser') { + const drawingBuffer = selectedEntity.adapter.renderer.bufferState; + if (drawingBuffer?.type === 'eraser_line') { + selectedEntity.adapter.renderer.commitBuffer(); + } else { + selectedEntity.adapter.renderer.clearBuffer(); + } + } + + if (toolState.selected === 'rect') { + const drawingBuffer = selectedEntity.adapter.renderer.bufferState; + if (drawingBuffer?.type === 'rect') { + selectedEntity.adapter.renderer.commitBuffer(); + } else { + selectedEntity.adapter.renderer.clearBuffer(); + } + } + + this.manager.stateApi.$lastMouseDownPos.set(null); + } + this.render(); + }; + + onStageMouseMove = async (e: KonvaEventObject) => { + const toolState = this.manager.stateApi.getToolState(); + const pos = this.syncLastCursorPos(); + const selectedEntity = this.manager.stateApi.getSelectedEntity(); + + if (toolState.selected === 'colorPicker') { + const color = this.getColorUnderCursor(); + if (color) { + this.manager.stateApi.$colorUnderCursor.set(color); + } + } else { + const isDrawable = selectedEntity?.state.isEnabled; + if (pos && isDrawable && !this.manager.stateApi.$spaceKey.get() && getIsPrimaryMouseDown(e)) { + if (toolState.selected === 'brush') { + const drawingBuffer = selectedEntity.adapter.renderer.bufferState; + if (drawingBuffer) { + if (drawingBuffer.type === 'brush_line') { + const lastPoint = getLastPointOfLine(drawingBuffer.points); + const minDistance = toolState.brush.width * BRUSH_SPACING_TARGET_SCALE; + if (lastPoint && validateCandidatePoint(pos, lastPoint, minDistance)) { + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); + const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); + // Do not add duplicate points + if (lastPoint.x !== alignedPoint.x || lastPoint.y !== alignedPoint.y) { + drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + this.manager.stateApi.$lastAddedPoint.set(alignedPoint); + } + } + } else { + selectedEntity.adapter.renderer.clearBuffer(); + } + } else { + if (selectedEntity.adapter.renderer.bufferState) { + selectedEntity.adapter.renderer.commitBuffer(); + } + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); + const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); + await selectedEntity.adapter.renderer.setBuffer({ + id: getPrefixedId('brush_line'), + type: 'brush_line', + points: [alignedPoint.x, alignedPoint.y], + strokeWidth: toolState.brush.width, + color: this.manager.stateApi.getCurrentFill(), + clip: this.getClip(selectedEntity.state), + }); + this.manager.stateApi.$lastAddedPoint.set(alignedPoint); + } + } + + if (toolState.selected === 'eraser') { + const drawingBuffer = selectedEntity.adapter.renderer.bufferState; + if (drawingBuffer) { + if (drawingBuffer.type === 'eraser_line') { + const lastPoint = getLastPointOfLine(drawingBuffer.points); + const minDistance = toolState.eraser.width * BRUSH_SPACING_TARGET_SCALE; + if (lastPoint && validateCandidatePoint(pos, lastPoint, minDistance)) { + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); + const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); + // Do not add duplicate points + if (lastPoint.x !== alignedPoint.x || lastPoint.y !== alignedPoint.y) { + drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + this.manager.stateApi.$lastAddedPoint.set(alignedPoint); + } + } + } else { + selectedEntity.adapter.renderer.clearBuffer(); + } + } else { + if (selectedEntity.adapter.renderer.bufferState) { + selectedEntity.adapter.renderer.commitBuffer(); + } + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); + const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); + await selectedEntity.adapter.renderer.setBuffer({ + id: getPrefixedId('eraser_line'), + type: 'eraser_line', + points: [alignedPoint.x, alignedPoint.y], + strokeWidth: toolState.eraser.width, + clip: this.getClip(selectedEntity.state), + }); + this.manager.stateApi.$lastAddedPoint.set(alignedPoint); + } + } + + if (toolState.selected === 'rect') { + const drawingBuffer = selectedEntity.adapter.renderer.bufferState; + if (drawingBuffer) { + if (drawingBuffer.type === 'rect') { + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); + drawingBuffer.rect.width = Math.round(normalizedPoint.x - drawingBuffer.rect.x); + drawingBuffer.rect.height = Math.round(normalizedPoint.y - drawingBuffer.rect.y); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + } else { + selectedEntity.adapter.renderer.clearBuffer(); + } + } + } + } + } + + this.render(); + }; + + onStageMouseLeave = async (e: KonvaEventObject) => { + const pos = this.syncLastCursorPos(); + this.manager.stateApi.$lastCursorPos.set(null); + this.manager.stateApi.$lastMouseDownPos.set(null); + const selectedEntity = this.manager.stateApi.getSelectedEntity(); + const toolState = this.manager.stateApi.getToolState(); + const isDrawable = selectedEntity?.state.isEnabled; + + if (pos && isDrawable && !this.manager.stateApi.$spaceKey.get() && getIsPrimaryMouseDown(e)) { + const drawingBuffer = selectedEntity.adapter.renderer.bufferState; + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); + if (toolState.selected === 'brush' && drawingBuffer?.type === 'brush_line') { + const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); + drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + selectedEntity.adapter.renderer.commitBuffer(); + } else if (toolState.selected === 'eraser' && drawingBuffer?.type === 'eraser_line') { + const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); + drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + selectedEntity.adapter.renderer.commitBuffer(); + } else if (toolState.selected === 'rect' && drawingBuffer?.type === 'rect') { + drawingBuffer.rect.width = Math.round(normalizedPoint.x - drawingBuffer.rect.x); + drawingBuffer.rect.height = Math.round(normalizedPoint.y - drawingBuffer.rect.y); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + selectedEntity.adapter.renderer.commitBuffer(); + } + } + + this.render(); + }; + + onStageMouseWheel = (e: KonvaEventObject) => { + e.evt.preventDefault(); + + if (!e.evt.ctrlKey && !e.evt.metaKey) { + return; + } + + const toolState = this.manager.stateApi.getToolState(); + + let delta = e.evt.deltaY; + + if (toolState.invertScroll) { + delta = -delta; + } + + // Holding ctrl or meta while scrolling changes the brush size + if (toolState.selected === 'brush') { + this.manager.stateApi.setBrushWidth(calculateNewBrushSizeFromWheelDelta(toolState.brush.width, delta)); + } else if (toolState.selected === 'eraser') { + this.manager.stateApi.setEraserWidth(calculateNewBrushSizeFromWheelDelta(toolState.eraser.width, delta)); + } + + this.render(); + }; + + onKeyDown = (e: KeyboardEvent) => { + if (e.repeat) { + return; + } + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + if (e.key === 'Escape') { + // Cancel shape drawing on escape + const selectedEntity = this.manager.stateApi.getSelectedEntity(); + if (selectedEntity) { + selectedEntity.adapter.renderer.clearBuffer(); + this.manager.stateApi.$lastMouseDownPos.set(null); + } + } else if (e.key === ' ') { + // Select the view tool on space key down + this.manager.stateApi.setToolBuffer(this.manager.stateApi.getToolState().selected); + this.manager.stateApi.setTool('view'); + this.manager.stateApi.$spaceKey.set(true); + this.manager.stateApi.$lastCursorPos.set(null); + this.manager.stateApi.$lastMouseDownPos.set(null); + } + }; + + onKeyUp = (e: KeyboardEvent) => { + if (e.repeat) { + return; + } + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + if (e.key === ' ') { + // Revert the tool to the previous tool on space key up + const toolBuffer = this.manager.stateApi.getToolState().selectedBuffer; + this.manager.stateApi.setTool(toolBuffer ?? 'move'); + this.manager.stateApi.setToolBuffer(null); + this.manager.stateApi.$spaceKey.set(false); + } + }; + getLoggingContext = (): SerializableObject => { return { ...this.manager.getLoggingContext(), path: this.path.join('.') }; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts deleted file mode 100644 index 4bab491f06..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts +++ /dev/null @@ -1,588 +0,0 @@ -import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; -import { - alignCoordForTool, - getPrefixedId, - getScaledCursorPosition, - offsetCoord, -} from 'features/controlLayers/konva/util'; -import type { - CanvasControlLayerState, - CanvasInpaintMaskState, - CanvasRasterLayerState, - CanvasRegionalGuidanceState, - CanvasV2State, - Coordinate, - RgbColor, - Tool, -} from 'features/controlLayers/store/types'; -import type Konva from 'konva'; -import type { KonvaEventObject } from 'konva/lib/Node'; -import { clamp } from 'lodash-es'; - -import { BRUSH_SPACING_TARGET_SCALE, CANVAS_SCALE_BY } from './constants'; - -/** - * Updates the last cursor position atom with the current cursor position, returning the new position or `null` if the - * cursor is not over the stage. - * @param stage The konva stage - * @param setLastCursorPos The callback to store the cursor pos - */ -const updateLastCursorPos = ( - stage: Konva.Stage, - setLastCursorPos: CanvasManager['stateApi']['$lastCursorPos']['set'] -) => { - const pos = getScaledCursorPosition(stage); - if (!pos) { - return null; - } - setLastCursorPos(pos); - return pos; -}; - -const calculateNewBrushSize = (brushSize: number, delta: number) => { - // This equation was derived by fitting a curve to the desired brush sizes and deltas - // see https://github.com/invoke-ai/InvokeAI/pull/5542#issuecomment-1915847565 - const targetDelta = Math.sign(delta) * 0.7363 * Math.pow(1.0394, brushSize); - // This needs to be clamped to prevent the delta from getting too large - const finalDelta = clamp(targetDelta, -20, 20); - // The new brush size is also clamped to prevent it from getting too large or small - const newBrushSize = clamp(brushSize + finalDelta, 1, 500); - - return newBrushSize; -}; - -const getNextPoint = ( - currentPos: Coordinate, - toolState: CanvasV2State['tool'], - lastAddedPoint: Coordinate | null -): Coordinate | null => { - // Continue the last line - const minSpacingPx = - toolState.selected === 'brush' - ? toolState.brush.width * BRUSH_SPACING_TARGET_SCALE - : toolState.eraser.width * BRUSH_SPACING_TARGET_SCALE; - - if (lastAddedPoint) { - // Dispatching redux events impacts perf substantially - using brush spacing keeps dispatches to a reasonable number - if (Math.hypot(lastAddedPoint.x - currentPos.x, lastAddedPoint.y - currentPos.y) < minSpacingPx) { - return null; - } - } - - return currentPos; -}; - -const getLastPointOfLine = (points: number[]): Coordinate | null => { - if (points.length < 2) { - return null; - } - const x = points[points.length - 2]; - const y = points[points.length - 1]; - if (x === undefined || y === undefined) { - return null; - } - return { x, y }; -}; - -const getLastPointOfLastLineOfEntity = ( - entity: CanvasRasterLayerState | CanvasControlLayerState | CanvasRegionalGuidanceState | CanvasInpaintMaskState, - tool: Tool -): Coordinate | null => { - const lastObject = entity.objects[entity.objects.length - 1]; - - if (!lastObject) { - return null; - } - - if ( - !( - (lastObject.type === 'brush_line' && tool === 'brush') || - (lastObject.type === 'eraser_line' && tool === 'eraser') - ) - ) { - // If the last object type and current tool do not match, we cannot continue the line - return null; - } - - if (lastObject.points.length < 2) { - return null; - } - const x = lastObject.points[lastObject.points.length - 2]; - const y = lastObject.points[lastObject.points.length - 1]; - if (x === undefined || y === undefined) { - return null; - } - return { x, y }; -}; - -const getColorUnderCursor = (stage: Konva.Stage): RgbColor | null => { - const pos = stage.getPointerPosition(); - if (!pos) { - return null; - } - const ctx = stage - .toCanvas({ x: pos.x, y: pos.y, width: 1, height: 1, imageSmoothingEnabled: false }) - .getContext('2d'); - if (!ctx) { - return null; - } - const [r, g, b, _a] = ctx.getImageData(0, 0, 1, 1).data; - if (r === undefined || g === undefined || b === undefined) { - return null; - } - - return { r, g, b }; -}; - -export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { - const stage = manager.stage.konva.stage; - const { - getToolState, - setTool, - setToolBuffer, - $isMouseDown, - $lastMouseDownPos, - $lastCursorPos, - $lastAddedPoint, - $stageAttrs, - $spaceKey, - getBbox, - getSettings, - setBrushWidth, - setEraserWidth, - getCurrentFill, - getSelectedEntity, - } = manager.stateApi; - - function getIsPrimaryMouseDown(e: KonvaEventObject) { - return e.evt.buttons === 1; - } - - function getClip( - entity: CanvasRegionalGuidanceState | CanvasControlLayerState | CanvasRasterLayerState | CanvasInpaintMaskState - ) { - const settings = getSettings(); - const bboxRect = getBbox().rect; - - if (settings.clipToBbox) { - return { - x: bboxRect.x - entity.position.x, - y: bboxRect.y - entity.position.y, - width: bboxRect.width, - height: bboxRect.height, - }; - } else { - return { - x: -stage.x() / stage.scaleX() - entity.position.x, - y: -stage.y() / stage.scaleY() - entity.position.y, - width: stage.width() / stage.scaleX(), - height: stage.height() / stage.scaleY(), - }; - } - } - - //#region mouseenter - stage.on('mouseenter', () => { - manager.preview.tool.render(); - }); - - //#region mousedown - stage.on('mousedown', async (e) => { - $isMouseDown.set(true); - const toolState = getToolState(); - const pos = updateLastCursorPos(stage, $lastCursorPos.set); - const selectedEntity = getSelectedEntity(); - - if (toolState.selected === 'colorPicker') { - const color = getColorUnderCursor(stage); - if (color) { - manager.stateApi.$colorUnderCursor.set(color); - } - if (color) { - manager.stateApi.setFill({ ...toolState.fill, ...color }); - } - manager.preview.tool.render(); - } else { - const isDrawable = selectedEntity?.state.isEnabled; - if (pos && isDrawable && !$spaceKey.get() && getIsPrimaryMouseDown(e)) { - $lastMouseDownPos.set(pos); - const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); - - if (toolState.selected === 'brush') { - const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity.state, toolState.selected); - const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); - if (e.evt.shiftKey && lastLinePoint) { - // Create a straight line from the last line point - if (selectedEntity.adapter.renderer.bufferState) { - selectedEntity.adapter.renderer.commitBuffer(); - } - - await selectedEntity.adapter.renderer.setBuffer({ - id: getPrefixedId('brush_line'), - type: 'brush_line', - points: [ - // The last point of the last line is already normalized to the entity's coordinates - lastLinePoint.x, - lastLinePoint.y, - alignedPoint.x, - alignedPoint.y, - ], - strokeWidth: toolState.brush.width, - color: getCurrentFill(), - clip: getClip(selectedEntity.state), - }); - } else { - if (selectedEntity.adapter.renderer.bufferState) { - selectedEntity.adapter.renderer.commitBuffer(); - } - await selectedEntity.adapter.renderer.setBuffer({ - id: getPrefixedId('brush_line'), - type: 'brush_line', - points: [alignedPoint.x, alignedPoint.y], - strokeWidth: toolState.brush.width, - color: getCurrentFill(), - clip: getClip(selectedEntity.state), - }); - } - $lastAddedPoint.set(alignedPoint); - } - - if (toolState.selected === 'eraser') { - const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity.state, toolState.selected); - const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); - if (e.evt.shiftKey && lastLinePoint) { - // Create a straight line from the last line point - if (selectedEntity.adapter.renderer.bufferState) { - selectedEntity.adapter.renderer.commitBuffer(); - } - await selectedEntity.adapter.renderer.setBuffer({ - id: getPrefixedId('eraser_line'), - type: 'eraser_line', - points: [ - // The last point of the last line is already normalized to the entity's coordinates - lastLinePoint.x, - lastLinePoint.y, - alignedPoint.x, - alignedPoint.y, - ], - strokeWidth: toolState.eraser.width, - clip: getClip(selectedEntity.state), - }); - } else { - if (selectedEntity.adapter.renderer.bufferState) { - selectedEntity.adapter.renderer.commitBuffer(); - } - await selectedEntity.adapter.renderer.setBuffer({ - id: getPrefixedId('eraser_line'), - type: 'eraser_line', - points: [alignedPoint.x, alignedPoint.y], - strokeWidth: toolState.eraser.width, - clip: getClip(selectedEntity.state), - }); - } - $lastAddedPoint.set(alignedPoint); - } - - if (toolState.selected === 'rect') { - if (selectedEntity.adapter.renderer.bufferState) { - selectedEntity.adapter.renderer.commitBuffer(); - } - await selectedEntity.adapter.renderer.setBuffer({ - id: getPrefixedId('rect'), - type: 'rect', - rect: { x: Math.round(normalizedPoint.x), y: Math.round(normalizedPoint.y), width: 0, height: 0 }, - color: getCurrentFill(), - }); - } - } - } - }); - - //#region mouseup - stage.on('mouseup', () => { - $isMouseDown.set(false); - const pos = $lastCursorPos.get(); - const selectedEntity = getSelectedEntity(); - const isDrawable = selectedEntity?.state.isEnabled; - if (pos && isDrawable && !$spaceKey.get()) { - const toolState = getToolState(); - - if (toolState.selected === 'brush') { - const drawingBuffer = selectedEntity.adapter.renderer.bufferState; - if (drawingBuffer?.type === 'brush_line') { - selectedEntity.adapter.renderer.commitBuffer(); - } else { - selectedEntity.adapter.renderer.clearBuffer(); - } - } - - if (toolState.selected === 'eraser') { - const drawingBuffer = selectedEntity.adapter.renderer.bufferState; - if (drawingBuffer?.type === 'eraser_line') { - selectedEntity.adapter.renderer.commitBuffer(); - } else { - selectedEntity.adapter.renderer.clearBuffer(); - } - } - - if (toolState.selected === 'rect') { - const drawingBuffer = selectedEntity.adapter.renderer.bufferState; - if (drawingBuffer?.type === 'rect') { - selectedEntity.adapter.renderer.commitBuffer(); - } else { - selectedEntity.adapter.renderer.clearBuffer(); - } - } - - $lastMouseDownPos.set(null); - } - manager.preview.tool.render(); - }); - - //#region mousemove - stage.on('mousemove', async (e) => { - const toolState = getToolState(); - const pos = updateLastCursorPos(stage, $lastCursorPos.set); - const selectedEntity = getSelectedEntity(); - - if (toolState.selected === 'colorPicker') { - const color = getColorUnderCursor(stage); - if (color) { - manager.stateApi.$colorUnderCursor.set(color); - } - } else { - const isDrawable = selectedEntity?.state.isEnabled; - if (pos && isDrawable && !$spaceKey.get() && getIsPrimaryMouseDown(e)) { - if (toolState.selected === 'brush') { - const drawingBuffer = selectedEntity.adapter.renderer.bufferState; - if (drawingBuffer) { - if (drawingBuffer.type === 'brush_line') { - const lastPoint = getLastPointOfLine(drawingBuffer.points); - const nextPoint = getNextPoint(pos, toolState, lastPoint); - if (lastPoint && nextPoint) { - const normalizedPoint = offsetCoord(nextPoint, selectedEntity.state.position); - const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); - // Do not add duplicate points - if (lastPoint.x !== alignedPoint.x || lastPoint.y !== alignedPoint.y) { - drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); - await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); - $lastAddedPoint.set(alignedPoint); - } - } - } else { - selectedEntity.adapter.renderer.clearBuffer(); - } - } else { - if (selectedEntity.adapter.renderer.bufferState) { - selectedEntity.adapter.renderer.commitBuffer(); - } - const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); - const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); - await selectedEntity.adapter.renderer.setBuffer({ - id: getPrefixedId('brush_line'), - type: 'brush_line', - points: [alignedPoint.x, alignedPoint.y], - strokeWidth: toolState.brush.width, - color: getCurrentFill(), - clip: getClip(selectedEntity.state), - }); - $lastAddedPoint.set(alignedPoint); - } - } - - if (toolState.selected === 'eraser') { - const drawingBuffer = selectedEntity.adapter.renderer.bufferState; - if (drawingBuffer) { - if (drawingBuffer.type === 'eraser_line') { - const lastPoint = getLastPointOfLine(drawingBuffer.points); - const nextPoint = getNextPoint(pos, toolState, lastPoint); - if (lastPoint && nextPoint) { - const normalizedPoint = offsetCoord(nextPoint, selectedEntity.state.position); - const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); - // Do not add duplicate points - if (lastPoint.x !== alignedPoint.x || lastPoint.y !== alignedPoint.y) { - drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); - await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); - $lastAddedPoint.set(alignedPoint); - } - } - } else { - selectedEntity.adapter.renderer.clearBuffer(); - } - } else { - if (selectedEntity.adapter.renderer.bufferState) { - selectedEntity.adapter.renderer.commitBuffer(); - } - const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); - const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); - await selectedEntity.adapter.renderer.setBuffer({ - id: getPrefixedId('eraser_line'), - type: 'eraser_line', - points: [alignedPoint.x, alignedPoint.y], - strokeWidth: toolState.eraser.width, - clip: getClip(selectedEntity.state), - }); - $lastAddedPoint.set(alignedPoint); - } - } - - if (toolState.selected === 'rect') { - const drawingBuffer = selectedEntity.adapter.renderer.bufferState; - if (drawingBuffer) { - if (drawingBuffer.type === 'rect') { - const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); - drawingBuffer.rect.width = Math.round(normalizedPoint.x - drawingBuffer.rect.x); - drawingBuffer.rect.height = Math.round(normalizedPoint.y - drawingBuffer.rect.y); - await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); - } else { - selectedEntity.adapter.renderer.clearBuffer(); - } - } - } - } - } - - manager.preview.tool.render(); - }); - - //#region mouseleave - stage.on('mouseleave', async (e) => { - const pos = updateLastCursorPos(stage, $lastCursorPos.set); - $lastCursorPos.set(null); - $lastMouseDownPos.set(null); - const selectedEntity = getSelectedEntity(); - const toolState = getToolState(); - const isDrawable = selectedEntity?.state.isEnabled; - - if (pos && isDrawable && !$spaceKey.get() && getIsPrimaryMouseDown(e)) { - const drawingBuffer = selectedEntity.adapter.renderer.bufferState; - const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); - if (toolState.selected === 'brush' && drawingBuffer?.type === 'brush_line') { - const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); - drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); - await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); - selectedEntity.adapter.renderer.commitBuffer(); - } else if (toolState.selected === 'eraser' && drawingBuffer?.type === 'eraser_line') { - const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); - drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); - await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); - selectedEntity.adapter.renderer.commitBuffer(); - } else if (toolState.selected === 'rect' && drawingBuffer?.type === 'rect') { - drawingBuffer.rect.width = Math.round(normalizedPoint.x - drawingBuffer.rect.x); - drawingBuffer.rect.height = Math.round(normalizedPoint.y - drawingBuffer.rect.y); - await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); - selectedEntity.adapter.renderer.commitBuffer(); - } - } - - manager.preview.tool.render(); - }); - - //#region wheel - stage.on('wheel', (e) => { - e.evt.preventDefault(); - - if (e.evt.ctrlKey || e.evt.metaKey) { - const toolState = getToolState(); - let delta = e.evt.deltaY; - if (toolState.invertScroll) { - delta = -delta; - } - // Holding ctrl or meta while scrolling changes the brush size - if (toolState.selected === 'brush') { - setBrushWidth(calculateNewBrushSize(toolState.brush.width, delta)); - } else if (toolState.selected === 'eraser') { - setEraserWidth(calculateNewBrushSize(toolState.eraser.width, delta)); - } - } else { - // We need the absolute cursor position - not the scaled position - const cursorPos = stage.getPointerPosition(); - if (cursorPos) { - // When wheeling on trackpad, e.evt.ctrlKey is true - in that case, let's reverse the direction - const delta = e.evt.ctrlKey ? -e.evt.deltaY : e.evt.deltaY; - const scale = manager.stage.getScale() * CANVAS_SCALE_BY ** delta; - manager.stage.setScale(scale, cursorPos); - } - } - manager.preview.tool.render(); - }); - - //#region dragmove - stage.on('dragmove', (e) => { - if (e.target !== stage) { - return; - } - $stageAttrs.set({ - x: Math.floor(stage.x()), - y: Math.floor(stage.y()), - width: stage.width(), - height: stage.height(), - scale: stage.scaleX(), - }); - }); - - //#region dragend - stage.on('dragend', (e) => { - if (e.target !== stage) { - return; - } // Stage position should always be an integer, else we get fractional pixels which are blurry - $stageAttrs.set({ - x: Math.floor(stage.x()), - y: Math.floor(stage.y()), - width: stage.width(), - height: stage.height(), - scale: stage.scaleX(), - }); - manager.preview.tool.render(); - }); - - //#region key - const onKeyDown = (e: KeyboardEvent) => { - if (e.repeat) { - return; - } - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } - if (e.key === 'Escape') { - // Cancel shape drawing on escape - const selectedEntity = getSelectedEntity(); - if (selectedEntity) { - selectedEntity.adapter.renderer.clearBuffer(); - $lastMouseDownPos.set(null); - } - } else if (e.key === ' ') { - // Select the view tool on space key down - setToolBuffer(getToolState().selected); - setTool('view'); - $spaceKey.set(true); - $lastCursorPos.set(null); - $lastMouseDownPos.set(null); - } - }; - window.addEventListener('keydown', onKeyDown); - - const onKeyUp = (e: KeyboardEvent) => { - if (e.repeat) { - return; - } - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } - if (e.key === ' ') { - // Revert the tool to the previous tool on space key up - const toolBuffer = getToolState().selectedBuffer; - setTool(toolBuffer ?? 'move'); - setToolBuffer(null); - $spaceKey.set(false); - } - manager.preview.tool.render(); - }; - window.addEventListener('keyup', onKeyUp); - - return () => { - stage.off('mousedown mouseup mousemove mouseenter mouseleave wheel dragend'); - window.removeEventListener('keydown', onKeyDown); - window.removeEventListener('keyup', onKeyUp); - }; -}; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts index 003448929c..eea14e02ba 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts @@ -2,6 +2,7 @@ import type { CanvasEntityIdentifier, Coordinate, Rect } from 'features/controlL import type Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import type { Vector2d } from 'konva/lib/types'; +import { clamp } from 'lodash-es'; import { customAlphabet } from 'nanoid'; import { assert } from 'tsafe'; @@ -129,6 +130,64 @@ export const getIsMouseDown = (e: KonvaEventObject): boolean => e.ev */ export const getIsFocused = (stage: Konva.Stage): boolean => stage.container().contains(document.activeElement); +/** + * Gets the last point of a line as a coordinate. + * @param points An array of numbers representing points as [x1, y1, x2, y2, ...] + * @returns The last point of the line as a coordinate, or null if the line has less than 1 point + */ +export const getLastPointOfLine = (points: number[]): Coordinate | null => { + if (points.length < 2) { + return null; + } + const x = points[points.length - 2]; + const y = points[points.length - 1]; + if (x === undefined || y === undefined) { + return null; + } + return { x, y }; +}; + +export function getIsPrimaryMouseDown(e: KonvaEventObject) { + return e.evt.buttons === 1; +} + +/** + * Calculates the new brush size based on the current brush size and the wheel delta from a mouse wheel event. + * @param brushSize The current brush size + * @param delta The wheel delta + * @returns + */ +export const calculateNewBrushSizeFromWheelDelta = (brushSize: number, delta: number) => { + // This equation was derived by fitting a curve to the desired brush sizes and deltas + // see https://github.com/invoke-ai/InvokeAI/pull/5542#issuecomment-1915847565 + const targetDelta = Math.sign(delta) * 0.7363 * Math.pow(1.0394, brushSize); + // This needs to be clamped to prevent the delta from getting too large + const finalDelta = clamp(targetDelta, -20, 20); + // The new brush size is also clamped to prevent it from getting too large or small + const newBrushSize = clamp(brushSize + finalDelta, 1, 500); + + return newBrushSize; +}; + +/** + * Validates a candidate point by checking if it is at least `minDistance` away from the last point. + * @param candidatePoint The candidate point + * @param lastPoint The last point + * @param minDistance The minimum distance between points + * @returns + */ +export const validateCandidatePoint = ( + candidatePoint: Coordinate, + lastPoint: Coordinate | null, + minDistance: number +): boolean => { + if (!lastPoint) { + return true; + } + + return Math.hypot(lastPoint.x - candidatePoint.x, lastPoint.y - candidatePoint.y) >= minDistance; +}; + /** * Simple util to map an object to its id property. Serves as a minor optimization to avoid recreating a map callback * every time we need to map an object to its id, which happens very often.