Files
InvokeAI/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBboxModule.ts
2024-09-06 22:56:24 +10:00

352 lines
14 KiB
TypeScript

import {
roundDownToMultiple,
roundToMultiple,
roundToMultipleMin,
roundUpToMultiple,
} from 'common/util/roundDownToMultiple';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectBbox } from 'features/controlLayers/store/selectors';
import type { Coordinate, Rect } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
import { assert } from 'tsafe';
const ALL_ANCHORS: string[] = [
'top-left',
'top-center',
'top-right',
'middle-right',
'middle-left',
'bottom-left',
'bottom-center',
'bottom-right',
];
const CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
const NO_ANCHORS: string[] = [];
/**
* Renders the bounding box. The bounding box can be transformed by the user.
*/
export class CanvasBboxModule extends CanvasModuleBase {
readonly type = 'bbox';
readonly id: string;
readonly path: string[];
readonly parent: CanvasManager;
readonly manager: CanvasManager;
readonly log: Logger;
subscriptions: Set<() => void> = new Set();
/**
* The Konva objects that make up the bbox:
* - A group to hold all the objects
* - A transformer to allow the bbox to be transformed
* - A transparent rect so the transformer has something to transform
*/
konva: {
group: Konva.Group;
transformer: Konva.Transformer;
proxyRect: Konva.Rect;
};
/**
* Buffer to store the last aspect ratio of the bbox. When the users holds shift while transforming the bbox, this is
* used to lock the aspect ratio.
*/
$aspectRatioBuffer = atom(1);
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
this.parent = manager;
this.manager = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating bbox module');
this.konva = {
group: new Konva.Group({ name: `${this.type}:group`, listening: true }),
// We will use a Konva.Transformer for the generation bbox. Transformers need some shape to transform, so we will
// create a transparent rect for this purpose.
proxyRect: new Konva.Rect({
name: `${this.type}:rect`,
listening: false,
strokeEnabled: false,
draggable: true,
}),
transformer: new Konva.Transformer({
name: `${this.type}:transformer`,
borderDash: [5, 5],
borderStroke: 'rgba(212,216,234,1)',
borderEnabled: true,
rotateEnabled: false,
keepRatio: false,
ignoreStroke: true,
listening: false,
flipEnabled: false,
anchorFill: 'rgba(212,216,234,1)',
anchorStroke: 'rgb(42,42,42)',
anchorSize: 12,
anchorCornerRadius: 3,
shiftBehavior: 'none', // we will implement our own shift behavior
centeredScaling: false,
anchorStyleFunc: this.anchorStyleFunc,
anchorDragBoundFunc: this.anchorDragBoundFunc,
}),
};
this.konva.proxyRect.on('dragmove', this.onDragMove);
this.konva.transformer.on('transform', this.onTransform);
this.konva.transformer.on('transformend', this.onTransformEnd);
// The transformer will always be transforming the proxy rect
this.konva.transformer.nodes([this.konva.proxyRect]);
this.konva.group.add(this.konva.proxyRect);
this.konva.group.add(this.konva.transformer);
// We will listen to the tool state to determine if the bbox should be visible or not.
this.subscriptions.add(this.manager.tool.$tool.listen(this.render));
// Also listen to redux state to update the bbox's position and dimensions.
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectBbox, this.render));
}
initialize = () => {
this.log.debug('Initializing module');
// We need to retain a copy of the bbox state because
const { width, height } = this.manager.stateApi.runSelector(selectBbox).rect;
// Update the aspect ratio buffer with the initial aspect ratio
this.$aspectRatioBuffer.set(width / height);
this.render();
};
/**
* Renders the bbox. The bbox is only visible when the tool is set to 'bbox'.
*/
render = () => {
this.log.trace('Rendering');
const { x, y, width, height } = this.manager.stateApi.runSelector(selectBbox).rect;
const tool = this.manager.tool.$tool.get();
this.konva.group.visible(true);
// We need to reach up to the preview layer to enable/disable listening so that the bbox can be interacted with.
// If the mangaer is busy, we disable listening so the bbox cannot be interacted with.
this.manager.konva.previewLayer.listening(tool === 'bbox' && !this.manager.$isBusy.get());
this.konva.proxyRect.setAttrs({
x,
y,
width,
height,
scaleX: 1,
scaleY: 1,
listening: tool === 'bbox',
});
this.konva.transformer.setAttrs({
listening: tool === 'bbox',
enabledAnchors: tool === 'bbox' ? ALL_ANCHORS : NO_ANCHORS,
});
};
destroy = () => {
this.log.trace('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions.clear();
this.konva.group.destroy();
};
/**
* Handles the dragmove event on the bbox rect:
* - Snaps the bbox position to the grid (determined by ctrl/meta key)
* - Pushes the new bbox rect into app state
*/
onDragMove = () => {
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
const bbox = this.manager.stateApi.getBbox();
const bboxRect: Rect = {
...bbox.rect,
x: roundToMultiple(this.konva.proxyRect.x(), gridSize),
y: roundToMultiple(this.konva.proxyRect.y(), gridSize),
};
this.konva.proxyRect.setAttrs(bboxRect);
if (bbox.rect.x !== bboxRect.x || bbox.rect.y !== bboxRect.y) {
this.manager.stateApi.setGenerationBbox(bboxRect);
}
};
/**
* Handles the transform event on the bbox transformer:
* - Snaps the bbox dimensions to the grid (determined by ctrl/meta key)
* - Centered scaling when alt is held
* - Aspect ratio locking when shift is held
* - Pushes the new bbox rect into app state
* - Syncs the aspect ratio buffer
*/
onTransform = () => {
// In the transform callback, we calculate the bbox's new dims and pos and update the konva object.
// Some special handling is needed depending on the anchor being dragged.
const anchor = this.konva.transformer.getActiveAnchor();
if (!anchor) {
// Pretty sure we should always have an anchor here?
return;
}
const alt = this.manager.stateApi.$altKey.get();
const ctrl = this.manager.stateApi.$ctrlKey.get();
const meta = this.manager.stateApi.$metaKey.get();
const shift = this.manager.stateApi.$shiftKey.get();
// Grid size depends on the modifier keys
let gridSize = ctrl || meta ? 8 : 64;
// Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the
// new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if
// we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes.
// Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid.
if (this.manager.stateApi.$altKey.get()) {
gridSize = gridSize * 2;
}
// The coords should be correct per the anchorDragBoundFunc.
let x = this.konva.proxyRect.x();
let y = this.konva.proxyRect.y();
// Konva transforms by scaling the dims, not directly changing width and height. At this point, the width and height
// *have not changed*, only the scale has changed. To get the final height, we need to scale the dims and then snap
// them to the grid.
let width = roundToMultipleMin(this.konva.proxyRect.width() * this.konva.proxyRect.scaleX(), gridSize);
let height = roundToMultipleMin(this.konva.proxyRect.height() * this.konva.proxyRect.scaleY(), gridSize);
// If shift is held and we are resizing from a corner, retain aspect ratio - needs special handling. We skip this
// if alt/opt is held - this requires math too big for my brain.
if (shift && CORNER_ANCHORS.includes(anchor) && !alt) {
// Fit the bbox to the last aspect ratio
let fittedWidth = Math.sqrt(width * height * this.$aspectRatioBuffer.get());
let fittedHeight = fittedWidth / this.$aspectRatioBuffer.get();
fittedWidth = roundToMultipleMin(fittedWidth, gridSize);
fittedHeight = roundToMultipleMin(fittedHeight, gridSize);
// We need to adjust the x and y coords to have the resize occur from the right origin.
if (anchor === 'top-left') {
// The transform origin is the bottom-right anchor. Both x and y need to be updated.
x = x - (fittedWidth - width);
y = y - (fittedHeight - height);
}
if (anchor === 'top-right') {
// The transform origin is the bottom-left anchor. Only y needs to be updated.
y = y - (fittedHeight - height);
}
if (anchor === 'bottom-left') {
// The transform origin is the top-right anchor. Only x needs to be updated.
x = x - (fittedWidth - width);
}
// Update the width and height to the fitted dims.
width = fittedWidth;
height = fittedHeight;
}
const bboxRect = {
x: Math.round(x),
y: Math.round(y),
width,
height,
};
// Update the bboxRect's attrs directly with the new transform, and reset its scale to 1.
this.konva.proxyRect.setAttrs({ ...bboxRect, scaleX: 1, scaleY: 1 });
// Update the bbox in internal state.
this.manager.stateApi.setGenerationBbox(bboxRect);
// Update the aspect ratio buffer whenever the shift key is not held - this allows for a nice UX where you can start
// a transform, get the right aspect ratio, then hold shift to lock it in.
if (!shift) {
this.$aspectRatioBuffer.set(bboxRect.width / bboxRect.height);
}
};
/**
* Handles the transformend event on the bbox transformer:
* - Updates the aspect ratio buffer with the new aspect ratio
*/
onTransformEnd = () => {
// Always update the aspect ratio buffer when the transform ends, so if the next transform starts with shift held,
// we have the correct aspect ratio to start from.
this.$aspectRatioBuffer.set(this.konva.proxyRect.width() / this.konva.proxyRect.height());
};
/**
* This function is called for each anchor on the transformer. It sets the style of the anchor based on its name.
* We make the x/y resize anchors little bars.
*/
anchorStyleFunc = (anchor: Konva.Rect): void => {
if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) {
anchor.height(8);
anchor.offsetY(4);
anchor.width(30);
anchor.offsetX(15);
}
if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) {
anchor.height(30);
anchor.offsetY(15);
anchor.width(8);
anchor.offsetX(4);
}
};
fitToLayers = (): void => {
const visibleRect = this.manager.stage.getVisibleRect();
// Can't fit the bbox to nothing
if (visibleRect.height === 0 || visibleRect.width === 0) {
return;
}
// Determine the bbox size that fits within the visible rect. The bbox must be at least 64px in width and height,
// and its width and height must be multiples of 8px.
const gridSize = 8;
// To be conservative, we will round up the x and y to the nearest grid size, and round down the width and height.
// This ensures the bbox is never _larger_ than the visible rect. If the bbox is larger than the visible, we
// will always trigger the outpainting workflow, which is not what the user wants.
const x = roundUpToMultiple(visibleRect.x, gridSize);
const y = roundUpToMultiple(visibleRect.y, gridSize);
const width = roundDownToMultiple(visibleRect.width, gridSize);
const height = roundDownToMultiple(visibleRect.height, gridSize);
this.manager.stateApi.setGenerationBbox({ x, y, width, height });
};
/**
* This function is called for each anchor on the transformer. It sets the drag bounds for the anchor based on the
* stage's position and the grid size. Care is taken to ensure the anchor snaps to the grid correctly.
*/
anchorDragBoundFunc = (oldAbsPos: Coordinate, newAbsPos: Coordinate): Coordinate => {
// This function works with absolute position - that is, a position in "physical" pixels on the screen, as opposed
// to konva's internal coordinate system.
const stage = this.konva.transformer.getStage();
assert(stage, 'Stage must exist');
// We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid.
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
// Because we are working in absolute coordinates, we need to scale the grid size by the stage scale.
const scaledGridSize = gridSize * stage.scaleX();
// To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position.
const stageAbsPos = stage.getAbsolutePosition();
// The offset is the remainder of the stage's absolute position divided by the scaled grid size.
const offsetX = stageAbsPos.x % scaledGridSize;
const offsetY = stageAbsPos.y % scaledGridSize;
// Finally, calculate the position by rounding to the grid and adding the offset.
return {
x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX,
y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY,
};
};
}