feat(ui): move events into modules who care about them

This commit is contained in:
psychedelicious 2024-08-26 10:34:59 +10:00
parent 307885f505
commit f86b50d18a
7 changed files with 628 additions and 595 deletions

View File

@ -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(),

View File

@ -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<CanvasManager | null>(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();
};
};

View File

@ -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,

View File

@ -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<WheelEvent>) => {
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<MouseEvent>) => {
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<DragEvent>) => {
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.
*/

View File

@ -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<MouseEvent>) => {
this.render();
};
onStageMouseDown = async (e: KonvaEventObject<MouseEvent>) => {
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<MouseEvent>) => {
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<MouseEvent>) => {
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<MouseEvent>) => {
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<WheelEvent>) => {
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('.') };
};

View File

@ -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<MouseEvent>) {
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);
};
};

View File

@ -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<MouseEvent>): 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<MouseEvent>) {
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.