mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
tidy(ui): file organisation
This commit is contained in:
parent
5ca48a8a5f
commit
aee2aad959
@ -1,12 +1,6 @@
|
|||||||
import { getImageDataTransparency } from 'common/util/arrayBuffer';
|
import { getImageDataTransparency } from 'common/util/arrayBuffer';
|
||||||
import { CanvasBackground } from 'features/controlLayers/konva/renderers/background';
|
import { CanvasBackground } from 'features/controlLayers/konva/renderers/background';
|
||||||
import {
|
import { CanvasPreview } from 'features/controlLayers/konva/renderers/preview';
|
||||||
CanvasBbox,
|
|
||||||
CanvasDocumentSizeOverlay,
|
|
||||||
CanvasPreview,
|
|
||||||
CanvasStagingArea,
|
|
||||||
CanvasTool,
|
|
||||||
} from 'features/controlLayers/konva/renderers/preview';
|
|
||||||
import { konvaNodeToBlob, konvaNodeToImageData, previewBlob } from 'features/controlLayers/konva/util';
|
import { konvaNodeToBlob, konvaNodeToImageData, previewBlob } from 'features/controlLayers/konva/util';
|
||||||
import type {
|
import type {
|
||||||
BrushLineAddedArg,
|
BrushLineAddedArg,
|
||||||
@ -30,10 +24,14 @@ import { getImageDTO as defaultGetImageDTO, uploadImage as defaultUploadImage }
|
|||||||
import type { ImageCategory, ImageDTO } from 'services/api/types';
|
import type { ImageCategory, ImageDTO } from 'services/api/types';
|
||||||
import { assert } from 'tsafe';
|
import { assert } from 'tsafe';
|
||||||
|
|
||||||
|
import { CanvasBbox } from './renderers/bbox';
|
||||||
import { CanvasControlAdapter } from './renderers/controlAdapters';
|
import { CanvasControlAdapter } from './renderers/controlAdapters';
|
||||||
|
import { CanvasDocumentSizeOverlay } from './renderers/documentSizeOverlay';
|
||||||
import { CanvasInpaintMask } from './renderers/inpaintMask';
|
import { CanvasInpaintMask } from './renderers/inpaintMask';
|
||||||
import { CanvasLayer } from './renderers/layers';
|
import { CanvasLayer } from './renderers/layers';
|
||||||
import { CanvasRegion } from './renderers/regions';
|
import { CanvasRegion } from './renderers/regions';
|
||||||
|
import { CanvasStagingArea } from './renderers/stagingArea';
|
||||||
|
import { CanvasTool } from './renderers/tool';
|
||||||
|
|
||||||
export type StateApi = {
|
export type StateApi = {
|
||||||
getToolState: () => CanvasV2State['tool'];
|
getToolState: () => CanvasV2State['tool'];
|
||||||
@ -157,10 +155,10 @@ export class KonvaNodeManager {
|
|||||||
const { entities } = this.stateApi.getLayersState();
|
const { entities } = this.stateApi.getLayersState();
|
||||||
const toolState = this.stateApi.getToolState();
|
const toolState = this.stateApi.getToolState();
|
||||||
|
|
||||||
for (const adapter of this.layers.values()) {
|
for (const canvasLayer of this.layers.values()) {
|
||||||
if (!entities.find((l) => l.id === adapter.id)) {
|
if (!entities.find((l) => l.id === canvasLayer.id)) {
|
||||||
adapter.destroy();
|
canvasLayer.destroy();
|
||||||
this.layers.delete(adapter.id);
|
this.layers.delete(canvasLayer.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,10 +180,10 @@ export class KonvaNodeManager {
|
|||||||
const selectedEntity = this.stateApi.getSelectedEntity();
|
const selectedEntity = this.stateApi.getSelectedEntity();
|
||||||
|
|
||||||
// Destroy the konva nodes for nonexistent entities
|
// Destroy the konva nodes for nonexistent entities
|
||||||
for (const adapter of this.regions.values()) {
|
for (const canvasRegion of this.regions.values()) {
|
||||||
if (!entities.find((rg) => rg.id === adapter.id)) {
|
if (!entities.find((rg) => rg.id === canvasRegion.id)) {
|
||||||
adapter.destroy();
|
canvasRegion.destroy();
|
||||||
this.regions.delete(adapter.id);
|
this.regions.delete(canvasRegion.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,10 +210,10 @@ export class KonvaNodeManager {
|
|||||||
renderControlAdapters() {
|
renderControlAdapters() {
|
||||||
const { entities } = this.stateApi.getControlAdaptersState();
|
const { entities } = this.stateApi.getControlAdaptersState();
|
||||||
|
|
||||||
for (const adapter of this.controlAdapters.values()) {
|
for (const canvasControlAdapter of this.controlAdapters.values()) {
|
||||||
if (!entities.find((ca) => ca.id === adapter.id)) {
|
if (!entities.find((ca) => ca.id === canvasControlAdapter.id)) {
|
||||||
adapter.destroy();
|
canvasControlAdapter.destroy();
|
||||||
this.controlAdapters.delete(adapter.id);
|
this.controlAdapters.delete(canvasControlAdapter.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,244 +1,236 @@
|
|||||||
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
|
import { roundToMultiple, roundToMultipleMin } from 'common/util/roundDownToMultiple';
|
||||||
import {
|
import {
|
||||||
CA_LAYER_IMAGE_NAME,
|
PREVIEW_GENERATION_BBOX_DUMMY_RECT,
|
||||||
LAYER_BBOX_NAME,
|
PREVIEW_GENERATION_BBOX_GROUP,
|
||||||
RASTER_LAYER_OBJECT_GROUP_NAME,
|
PREVIEW_GENERATION_BBOX_TRANSFORMER
|
||||||
RG_LAYER_OBJECT_GROUP_NAME,
|
|
||||||
} from 'features/controlLayers/konva/naming';
|
} from 'features/controlLayers/konva/naming';
|
||||||
import { createBboxRect } from 'features/controlLayers/konva/renderers/objects';
|
import type { CanvasV2State } from 'features/controlLayers/store/types';
|
||||||
import { imageDataToDataURL } from 'features/controlLayers/konva/util';
|
|
||||||
import type {
|
|
||||||
BboxChangedArg,
|
|
||||||
CanvasEntity,
|
|
||||||
ControlAdapterEntity,
|
|
||||||
LayerEntity,
|
|
||||||
RegionEntity,
|
|
||||||
} from 'features/controlLayers/store/types';
|
|
||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import type { IRect } from 'konva/lib/types';
|
import type { IRect } from 'konva/lib/types';
|
||||||
|
import { atom } from 'nanostores';
|
||||||
import { assert } from 'tsafe';
|
import { assert } from 'tsafe';
|
||||||
|
|
||||||
/**
|
|
||||||
* Logic to create and render bounding boxes for layers.
|
|
||||||
* Some utils are included for calculating bounding boxes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type Extents = {
|
export class CanvasBbox {
|
||||||
minX: number;
|
group: Konva.Group;
|
||||||
minY: number;
|
rect: Konva.Rect;
|
||||||
maxX: number;
|
transformer: Konva.Transformer;
|
||||||
maxY: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GET_CLIENT_RECT_CONFIG = { skipTransform: true };
|
ALL_ANCHORS: string[] = [
|
||||||
|
'top-left',
|
||||||
|
'top-center',
|
||||||
|
'top-right',
|
||||||
|
'middle-right',
|
||||||
|
'middle-left',
|
||||||
|
'bottom-left',
|
||||||
|
'bottom-center',
|
||||||
|
'bottom-right',
|
||||||
|
];
|
||||||
|
CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||||||
|
NO_ANCHORS: string[] = [];
|
||||||
|
|
||||||
/**
|
constructor(
|
||||||
* Get the bounding box of an image.
|
getBbox: () => IRect,
|
||||||
* @param imageData The ImageData object to get the bounding box of.
|
onBboxTransformed: (bbox: IRect) => void,
|
||||||
* @returns The minimum and maximum x and y values of the image's bounding box, or null if the image has no pixels.
|
getShiftKey: () => boolean,
|
||||||
*/
|
getCtrlKey: () => boolean,
|
||||||
const getImageDataBbox = (imageData: ImageData): Extents | null => {
|
getMetaKey: () => boolean,
|
||||||
const { data, width, height } = imageData;
|
getAltKey: () => boolean
|
||||||
let minX = width;
|
) {
|
||||||
let minY = height;
|
// Create a stash to hold onto the last aspect ratio of the bbox - this allows for locking the aspect ratio when
|
||||||
let maxX = -1;
|
// transforming the bbox.
|
||||||
let maxY = -1;
|
const bbox = getBbox();
|
||||||
let alpha = 0;
|
const $aspectRatioBuffer = atom(bbox.width / bbox.height);
|
||||||
let isEmpty = true;
|
|
||||||
|
|
||||||
for (let y = 0; y < height; y++) {
|
// Use a transformer for the generation bbox. Transformers need some shape to transform, we will use a fully
|
||||||
for (let x = 0; x < width; x++) {
|
// transparent rect for this purpose.
|
||||||
alpha = data[(y * width + x) * 4 + 3] ?? 0;
|
this.group = new Konva.Group({ id: PREVIEW_GENERATION_BBOX_GROUP, listening: false });
|
||||||
if (alpha > 0) {
|
this.rect = new Konva.Rect({
|
||||||
isEmpty = false;
|
id: PREVIEW_GENERATION_BBOX_DUMMY_RECT,
|
||||||
if (x < minX) {
|
listening: false,
|
||||||
minX = x;
|
strokeEnabled: false,
|
||||||
}
|
draggable: true,
|
||||||
if (x > maxX) {
|
...getBbox(),
|
||||||
maxX = x;
|
});
|
||||||
}
|
this.rect.on('dragmove', () => {
|
||||||
if (y < minY) {
|
const gridSize = getCtrlKey() || getMetaKey() ? 8 : 64;
|
||||||
minY = y;
|
const oldBbox = getBbox();
|
||||||
}
|
const newBbox: IRect = {
|
||||||
if (y > maxY) {
|
...oldBbox,
|
||||||
maxY = y;
|
x: roundToMultiple(this.rect.x(), gridSize),
|
||||||
}
|
y: roundToMultiple(this.rect.y(), gridSize),
|
||||||
|
};
|
||||||
|
this.rect.setAttrs(newBbox);
|
||||||
|
if (oldBbox.x !== newBbox.x || oldBbox.y !== newBbox.y) {
|
||||||
|
onBboxTransformed(newBbox);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return isEmpty ? null : { minX, minY, maxX, maxY };
|
this.transformer = new Konva.Transformer({
|
||||||
};
|
id: PREVIEW_GENERATION_BBOX_TRANSFORMER,
|
||||||
|
borderDash: [5, 5],
|
||||||
/**
|
borderStroke: 'rgba(212,216,234,1)',
|
||||||
* Clones a regional guidance konva layer onto an offscreen stage/canvas. This allows the pixel data for a given layer
|
borderEnabled: true,
|
||||||
* to be captured, manipulated or analyzed without interference from other layers.
|
rotateEnabled: false,
|
||||||
* @param layer The konva layer to clone.
|
keepRatio: false,
|
||||||
* @param filterChildren A callback to filter out unwanted children
|
ignoreStroke: true,
|
||||||
* @returns The cloned stage and layer.
|
listening: false,
|
||||||
*/
|
flipEnabled: false,
|
||||||
const getIsolatedLayerClone = (
|
anchorFill: 'rgba(212,216,234,1)',
|
||||||
layer: Konva.Layer,
|
anchorStroke: 'rgb(42,42,42)',
|
||||||
filterChildren: (node: Konva.Node) => boolean
|
anchorSize: 12,
|
||||||
): { stageClone: Konva.Stage; layerClone: Konva.Layer } => {
|
anchorCornerRadius: 3,
|
||||||
const stage = layer.getStage();
|
shiftBehavior: 'none', // we will implement our own shift behavior
|
||||||
|
centeredScaling: false,
|
||||||
// Construct an offscreen canvas with the same dimensions as the layer's stage.
|
anchorStyleFunc: (anchor) => {
|
||||||
const offscreenStageContainer = document.createElement('div');
|
// Make the x/y resize anchors little bars
|
||||||
const stageClone = new Konva.Stage({
|
if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) {
|
||||||
container: offscreenStageContainer,
|
anchor.height(8);
|
||||||
x: stage.x(),
|
anchor.offsetY(4);
|
||||||
y: stage.y(),
|
anchor.width(30);
|
||||||
width: stage.width(),
|
anchor.offsetX(15);
|
||||||
height: stage.height(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clone the layer and filter out unwanted children.
|
|
||||||
const layerClone = layer.clone();
|
|
||||||
stageClone.add(layerClone);
|
|
||||||
|
|
||||||
for (const child of layerClone.getChildren()) {
|
|
||||||
if (filterChildren(child) && child.hasChildren()) {
|
|
||||||
// We need to cache the group to ensure it composites out eraser strokes correctly
|
|
||||||
child.opacity(1);
|
|
||||||
child.cache();
|
|
||||||
} else {
|
|
||||||
// Filter out unwanted children.
|
|
||||||
child.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { stageClone, layerClone };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the bounding box of a regional prompt konva layer. This function has special handling for regional prompt layers.
|
|
||||||
* @param layer The konva layer to get the bounding box of.
|
|
||||||
* @param preview Whether to open a new tab displaying the rendered layer, which is used to calculate the bbox.
|
|
||||||
*/
|
|
||||||
const getLayerBboxPixels = (
|
|
||||||
layer: Konva.Layer,
|
|
||||||
filterChildren: (node: Konva.Node) => boolean,
|
|
||||||
preview: boolean = false
|
|
||||||
): IRect | null => {
|
|
||||||
// To calculate the layer's bounding box, we must first export it to a pixel array, then do some math.
|
|
||||||
//
|
|
||||||
// Though it is relatively fast, we can't use Konva's `getClientRect`. It programmatically determines the rect
|
|
||||||
// by calculating the extents of individual shapes from their "vector" shape data.
|
|
||||||
//
|
|
||||||
// This doesn't work when some shapes are drawn with composite operations that "erase" pixels, like eraser lines.
|
|
||||||
// These shapes' extents are still calculated as if they were solid, leading to a bounding box that is too large.
|
|
||||||
const { stageClone, layerClone } = getIsolatedLayerClone(layer, filterChildren);
|
|
||||||
|
|
||||||
// Get a worst-case rect using the relatively fast `getClientRect`.
|
|
||||||
const layerRect = layerClone.getClientRect();
|
|
||||||
if (layerRect.width === 0 || layerRect.height === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Capture the image data with the above rect.
|
|
||||||
const layerImageData = stageClone
|
|
||||||
.toCanvas(layerRect)
|
|
||||||
.getContext('2d')
|
|
||||||
?.getImageData(0, 0, layerRect.width, layerRect.height);
|
|
||||||
assert(layerImageData, "Unable to get layer's image data");
|
|
||||||
|
|
||||||
if (preview) {
|
|
||||||
openBase64ImageInTab([{ base64: imageDataToDataURL(layerImageData), caption: layer.id() }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the layer's bounding box.
|
|
||||||
const layerBbox = getImageDataBbox(layerImageData);
|
|
||||||
|
|
||||||
if (!layerBbox) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Correct the bounding box to be relative to the layer's position.
|
|
||||||
const correctedLayerBbox = {
|
|
||||||
x: layerBbox.minX - Math.floor(stageClone.x()) + layerRect.x - Math.floor(layer.x()),
|
|
||||||
y: layerBbox.minY - Math.floor(stageClone.y()) + layerRect.y - Math.floor(layer.y()),
|
|
||||||
width: layerBbox.maxX - layerBbox.minX,
|
|
||||||
height: layerBbox.maxY - layerBbox.minY,
|
|
||||||
};
|
|
||||||
|
|
||||||
return correctedLayerBbox;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the bounding box of a konva layer. This function is faster than `getLayerBboxPixels` but less accurate. It
|
|
||||||
* should only be used when there are no eraser strokes or shapes in the layer.
|
|
||||||
* @param layer The konva layer to get the bounding box of.
|
|
||||||
* @returns The bounding box of the layer.
|
|
||||||
*/
|
|
||||||
export const getLayerBboxFast = (layer: Konva.Layer): IRect => {
|
|
||||||
const bbox = layer.getClientRect(GET_CLIENT_RECT_CONFIG);
|
|
||||||
return {
|
|
||||||
x: Math.floor(bbox.x),
|
|
||||||
y: Math.floor(bbox.y),
|
|
||||||
width: Math.floor(bbox.width),
|
|
||||||
height: Math.floor(bbox.height),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterRGChildren = (node: Konva.Node): boolean => node.name() === RG_LAYER_OBJECT_GROUP_NAME;
|
|
||||||
const filterLayerChildren = (node: Konva.Node): boolean => node.name() === RASTER_LAYER_OBJECT_GROUP_NAME;
|
|
||||||
const filterCAChildren = (node: Konva.Node): boolean => node.name() === CA_LAYER_IMAGE_NAME;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the bbox of each regional guidance layer. Only calculates if the mask has changed.
|
|
||||||
* @param stage The konva stage
|
|
||||||
* @param entityStates An array of layers to calculate bboxes for
|
|
||||||
* @param onBboxChanged Callback for when the bounding box changes
|
|
||||||
*/
|
|
||||||
export const updateBboxes = (
|
|
||||||
stage: Konva.Stage,
|
|
||||||
layers: LayerEntity[],
|
|
||||||
controlAdapters: ControlAdapterEntity[],
|
|
||||||
regions: RegionEntity[],
|
|
||||||
onBboxChanged: (arg: BboxChangedArg, entityType: CanvasEntity['type']) => void
|
|
||||||
): void => {
|
|
||||||
for (const entityState of [...layers, ...controlAdapters, ...regions]) {
|
|
||||||
const konvaLayer = stage.findOne<Konva.Layer>(`#${entityState.id}`);
|
|
||||||
assert(konvaLayer, `Layer ${entityState.id} not found in stage`);
|
|
||||||
// We only need to recalculate the bbox if the layer has changed
|
|
||||||
if (entityState.bboxNeedsUpdate) {
|
|
||||||
const bboxRect = konvaLayer.findOne<Konva.Rect>(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(entityState, konvaLayer);
|
|
||||||
|
|
||||||
// Hide the bbox while we calculate the new bbox, else the bbox will be included in the calculation
|
|
||||||
const visible = bboxRect.visible();
|
|
||||||
bboxRect.visible(false);
|
|
||||||
|
|
||||||
if (entityState.type === 'layer') {
|
|
||||||
if (entityState.objects.length === 0) {
|
|
||||||
// No objects - no bbox to calculate
|
|
||||||
onBboxChanged({ id: entityState.id, bbox: null }, 'layer');
|
|
||||||
} else {
|
|
||||||
onBboxChanged({ id: entityState.id, bbox: getLayerBboxPixels(konvaLayer, filterLayerChildren) }, 'layer');
|
|
||||||
}
|
}
|
||||||
} else if (entityState.type === 'control_adapter') {
|
if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) {
|
||||||
if (!entityState.imageObject && !entityState.processedImageObject) {
|
anchor.height(30);
|
||||||
// No objects - no bbox to calculate
|
anchor.offsetY(15);
|
||||||
onBboxChanged({ id: entityState.id, bbox: null }, 'control_adapter');
|
anchor.width(8);
|
||||||
} else {
|
anchor.offsetX(4);
|
||||||
onBboxChanged(
|
|
||||||
{ id: entityState.id, bbox: getLayerBboxPixels(konvaLayer, filterCAChildren) },
|
|
||||||
'control_adapter'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (entityState.type === 'regional_guidance') {
|
|
||||||
if (entityState.objects.length === 0) {
|
|
||||||
// No objects - no bbox to calculate
|
|
||||||
onBboxChanged({ id: entityState.id, bbox: null }, 'regional_guidance');
|
|
||||||
} else {
|
|
||||||
onBboxChanged(
|
|
||||||
{ id: entityState.id, bbox: getLayerBboxPixels(konvaLayer, filterRGChildren) },
|
|
||||||
'regional_guidance'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
anchorDragBoundFunc: (_oldAbsPos, newAbsPos) => {
|
||||||
|
// 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.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 = getCtrlKey() || getMetaKey() ? 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.transformer.on('transform', () => {
|
||||||
|
// 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.transformer.getActiveAnchor();
|
||||||
|
if (!anchor) {
|
||||||
|
// Pretty sure we should always have an anchor here?
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the visibility of the bbox
|
const alt = getAltKey();
|
||||||
bboxRect.visible(visible);
|
const ctrl = getCtrlKey();
|
||||||
}
|
const meta = getMetaKey();
|
||||||
|
const shift = getShiftKey();
|
||||||
|
|
||||||
|
// 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 (getAltKey()) {
|
||||||
|
gridSize = gridSize * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The coords should be correct per the anchorDragBoundFunc.
|
||||||
|
let x = this.rect.x();
|
||||||
|
let y = this.rect.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.rect.width() * this.rect.scaleX(), gridSize);
|
||||||
|
let height = roundToMultipleMin(this.rect.height() * this.rect.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 && this.CORNER_ANCHORS.includes(anchor) && !alt) {
|
||||||
|
// Fit the bbox to the last aspect ratio
|
||||||
|
let fittedWidth = Math.sqrt(width * height * $aspectRatioBuffer.get());
|
||||||
|
let fittedHeight = fittedWidth / $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 bbox = {
|
||||||
|
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.
|
||||||
|
// TODO(psyche): In `renderBboxPreview()` we also call setAttrs, need to do it twice to ensure it renders correctly.
|
||||||
|
// Gotta be a way to avoid setting it twice...
|
||||||
|
this.rect.setAttrs({ ...bbox, scaleX: 1, scaleY: 1 });
|
||||||
|
|
||||||
|
// Update the bbox in internal state.
|
||||||
|
onBboxTransformed(bbox);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
$aspectRatioBuffer.set(bbox.width / bbox.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.transformer.on('transformend', () => {
|
||||||
|
// 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.
|
||||||
|
$aspectRatioBuffer.set(this.rect.width() / this.rect.height());
|
||||||
|
});
|
||||||
|
|
||||||
|
// The transformer will always be transforming the dummy rect
|
||||||
|
this.transformer.nodes([this.rect]);
|
||||||
|
this.group.add(this.rect);
|
||||||
|
this.group.add(this.transformer);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
render(bbox: CanvasV2State['bbox'], toolState: CanvasV2State['tool']) {
|
||||||
|
this.group.listening(toolState.selected === 'bbox');
|
||||||
|
this.rect.setAttrs({
|
||||||
|
x: bbox.x,
|
||||||
|
y: bbox.y,
|
||||||
|
width: bbox.width,
|
||||||
|
height: bbox.height,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
listening: toolState.selected === 'bbox',
|
||||||
|
});
|
||||||
|
this.transformer.setAttrs({
|
||||||
|
listening: toolState.selected === 'bbox',
|
||||||
|
enabledAnchors: toolState.selected === 'bbox' ? this.ALL_ANCHORS : this.NO_ANCHORS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -0,0 +1,67 @@
|
|||||||
|
import { getArbitraryBaseColor } from '@invoke-ai/ui-library';
|
||||||
|
import { DOCUMENT_FIT_PADDING_PX } from 'features/controlLayers/konva/constants';
|
||||||
|
import type { CanvasV2State, StageAttrs } from 'features/controlLayers/store/types';
|
||||||
|
import Konva from 'konva';
|
||||||
|
|
||||||
|
export class CanvasDocumentSizeOverlay {
|
||||||
|
group: Konva.Group;
|
||||||
|
outerRect: Konva.Rect;
|
||||||
|
innerRect: Konva.Rect;
|
||||||
|
padding: number;
|
||||||
|
|
||||||
|
constructor(padding?: number) {
|
||||||
|
this.padding = padding ?? DOCUMENT_FIT_PADDING_PX;
|
||||||
|
this.group = new Konva.Group({ id: 'document_overlay_group', listening: false });
|
||||||
|
this.outerRect = new Konva.Rect({
|
||||||
|
id: 'document_overlay_outer_rect',
|
||||||
|
listening: false,
|
||||||
|
fill: getArbitraryBaseColor(10),
|
||||||
|
opacity: 0.7,
|
||||||
|
});
|
||||||
|
this.innerRect = new Konva.Rect({
|
||||||
|
id: 'document_overlay_inner_rect',
|
||||||
|
listening: false,
|
||||||
|
fill: 'white',
|
||||||
|
globalCompositeOperation: 'destination-out',
|
||||||
|
});
|
||||||
|
this.group.add(this.outerRect);
|
||||||
|
this.group.add(this.innerRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(stage: Konva.Stage, document: CanvasV2State['document']) {
|
||||||
|
this.group.zIndex(0);
|
||||||
|
|
||||||
|
const x = stage.x();
|
||||||
|
const y = stage.y();
|
||||||
|
const width = stage.width();
|
||||||
|
const height = stage.height();
|
||||||
|
const scale = stage.scaleX();
|
||||||
|
|
||||||
|
this.outerRect.setAttrs({
|
||||||
|
offsetX: x / scale,
|
||||||
|
offsetY: y / scale,
|
||||||
|
width: width / scale,
|
||||||
|
height: height / scale,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.innerRect.setAttrs({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: document.width,
|
||||||
|
height: document.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fitToStage(stage: Konva.Stage, document: CanvasV2State['document'], setStageAttrs: (attrs: StageAttrs) => void) {
|
||||||
|
// Fit & center the document on the stage
|
||||||
|
const width = stage.width();
|
||||||
|
const height = stage.height();
|
||||||
|
const docWidthWithBuffer = document.width + this.padding * 2;
|
||||||
|
const docHeightWithBuffer = document.height + this.padding * 2;
|
||||||
|
const scale = Math.min(Math.min(width / docWidthWithBuffer, height / docHeightWithBuffer), 1);
|
||||||
|
const x = (width - docWidthWithBuffer * scale) / 2 + this.padding * scale;
|
||||||
|
const y = (height - docHeightWithBuffer * scale) / 2 + this.padding * scale;
|
||||||
|
stage.setAttrs({ x, y, width, height, scaleX: scale, scaleY: scale });
|
||||||
|
setStageAttrs({ x, y, width, height, scale });
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,244 @@
|
|||||||
|
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
|
||||||
|
import {
|
||||||
|
CA_LAYER_IMAGE_NAME,
|
||||||
|
LAYER_BBOX_NAME,
|
||||||
|
RASTER_LAYER_OBJECT_GROUP_NAME,
|
||||||
|
RG_LAYER_OBJECT_GROUP_NAME,
|
||||||
|
} from 'features/controlLayers/konva/naming';
|
||||||
|
import { createBboxRect } from 'features/controlLayers/konva/renderers/objects';
|
||||||
|
import { imageDataToDataURL } from 'features/controlLayers/konva/util';
|
||||||
|
import type {
|
||||||
|
BboxChangedArg,
|
||||||
|
CanvasEntity,
|
||||||
|
ControlAdapterEntity,
|
||||||
|
LayerEntity,
|
||||||
|
RegionEntity,
|
||||||
|
} from 'features/controlLayers/store/types';
|
||||||
|
import Konva from 'konva';
|
||||||
|
import type { IRect } from 'konva/lib/types';
|
||||||
|
import { assert } from 'tsafe';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logic to create and render bounding boxes for layers.
|
||||||
|
* Some utils are included for calculating bounding boxes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Extents = {
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GET_CLIENT_RECT_CONFIG = { skipTransform: true };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the bounding box of an image.
|
||||||
|
* @param imageData The ImageData object to get the bounding box of.
|
||||||
|
* @returns The minimum and maximum x and y values of the image's bounding box, or null if the image has no pixels.
|
||||||
|
*/
|
||||||
|
const getImageDataBbox = (imageData: ImageData): Extents | null => {
|
||||||
|
const { data, width, height } = imageData;
|
||||||
|
let minX = width;
|
||||||
|
let minY = height;
|
||||||
|
let maxX = -1;
|
||||||
|
let maxY = -1;
|
||||||
|
let alpha = 0;
|
||||||
|
let isEmpty = true;
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
alpha = data[(y * width + x) * 4 + 3] ?? 0;
|
||||||
|
if (alpha > 0) {
|
||||||
|
isEmpty = false;
|
||||||
|
if (x < minX) {
|
||||||
|
minX = x;
|
||||||
|
}
|
||||||
|
if (x > maxX) {
|
||||||
|
maxX = x;
|
||||||
|
}
|
||||||
|
if (y < minY) {
|
||||||
|
minY = y;
|
||||||
|
}
|
||||||
|
if (y > maxY) {
|
||||||
|
maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isEmpty ? null : { minX, minY, maxX, maxY };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clones a regional guidance konva layer onto an offscreen stage/canvas. This allows the pixel data for a given layer
|
||||||
|
* to be captured, manipulated or analyzed without interference from other layers.
|
||||||
|
* @param layer The konva layer to clone.
|
||||||
|
* @param filterChildren A callback to filter out unwanted children
|
||||||
|
* @returns The cloned stage and layer.
|
||||||
|
*/
|
||||||
|
const getIsolatedLayerClone = (
|
||||||
|
layer: Konva.Layer,
|
||||||
|
filterChildren: (node: Konva.Node) => boolean
|
||||||
|
): { stageClone: Konva.Stage; layerClone: Konva.Layer } => {
|
||||||
|
const stage = layer.getStage();
|
||||||
|
|
||||||
|
// Construct an offscreen canvas with the same dimensions as the layer's stage.
|
||||||
|
const offscreenStageContainer = document.createElement('div');
|
||||||
|
const stageClone = new Konva.Stage({
|
||||||
|
container: offscreenStageContainer,
|
||||||
|
x: stage.x(),
|
||||||
|
y: stage.y(),
|
||||||
|
width: stage.width(),
|
||||||
|
height: stage.height(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clone the layer and filter out unwanted children.
|
||||||
|
const layerClone = layer.clone();
|
||||||
|
stageClone.add(layerClone);
|
||||||
|
|
||||||
|
for (const child of layerClone.getChildren()) {
|
||||||
|
if (filterChildren(child) && child.hasChildren()) {
|
||||||
|
// We need to cache the group to ensure it composites out eraser strokes correctly
|
||||||
|
child.opacity(1);
|
||||||
|
child.cache();
|
||||||
|
} else {
|
||||||
|
// Filter out unwanted children.
|
||||||
|
child.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stageClone, layerClone };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the bounding box of a regional prompt konva layer. This function has special handling for regional prompt layers.
|
||||||
|
* @param layer The konva layer to get the bounding box of.
|
||||||
|
* @param preview Whether to open a new tab displaying the rendered layer, which is used to calculate the bbox.
|
||||||
|
*/
|
||||||
|
const getLayerBboxPixels = (
|
||||||
|
layer: Konva.Layer,
|
||||||
|
filterChildren: (node: Konva.Node) => boolean,
|
||||||
|
preview: boolean = false
|
||||||
|
): IRect | null => {
|
||||||
|
// To calculate the layer's bounding box, we must first export it to a pixel array, then do some math.
|
||||||
|
//
|
||||||
|
// Though it is relatively fast, we can't use Konva's `getClientRect`. It programmatically determines the rect
|
||||||
|
// by calculating the extents of individual shapes from their "vector" shape data.
|
||||||
|
//
|
||||||
|
// This doesn't work when some shapes are drawn with composite operations that "erase" pixels, like eraser lines.
|
||||||
|
// These shapes' extents are still calculated as if they were solid, leading to a bounding box that is too large.
|
||||||
|
const { stageClone, layerClone } = getIsolatedLayerClone(layer, filterChildren);
|
||||||
|
|
||||||
|
// Get a worst-case rect using the relatively fast `getClientRect`.
|
||||||
|
const layerRect = layerClone.getClientRect();
|
||||||
|
if (layerRect.width === 0 || layerRect.height === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Capture the image data with the above rect.
|
||||||
|
const layerImageData = stageClone
|
||||||
|
.toCanvas(layerRect)
|
||||||
|
.getContext('2d')
|
||||||
|
?.getImageData(0, 0, layerRect.width, layerRect.height);
|
||||||
|
assert(layerImageData, "Unable to get layer's image data");
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
openBase64ImageInTab([{ base64: imageDataToDataURL(layerImageData), caption: layer.id() }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the layer's bounding box.
|
||||||
|
const layerBbox = getImageDataBbox(layerImageData);
|
||||||
|
|
||||||
|
if (!layerBbox) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct the bounding box to be relative to the layer's position.
|
||||||
|
const correctedLayerBbox = {
|
||||||
|
x: layerBbox.minX - Math.floor(stageClone.x()) + layerRect.x - Math.floor(layer.x()),
|
||||||
|
y: layerBbox.minY - Math.floor(stageClone.y()) + layerRect.y - Math.floor(layer.y()),
|
||||||
|
width: layerBbox.maxX - layerBbox.minX,
|
||||||
|
height: layerBbox.maxY - layerBbox.minY,
|
||||||
|
};
|
||||||
|
|
||||||
|
return correctedLayerBbox;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the bounding box of a konva layer. This function is faster than `getLayerBboxPixels` but less accurate. It
|
||||||
|
* should only be used when there are no eraser strokes or shapes in the layer.
|
||||||
|
* @param layer The konva layer to get the bounding box of.
|
||||||
|
* @returns The bounding box of the layer.
|
||||||
|
*/
|
||||||
|
export const getLayerBboxFast = (layer: Konva.Layer): IRect => {
|
||||||
|
const bbox = layer.getClientRect(GET_CLIENT_RECT_CONFIG);
|
||||||
|
return {
|
||||||
|
x: Math.floor(bbox.x),
|
||||||
|
y: Math.floor(bbox.y),
|
||||||
|
width: Math.floor(bbox.width),
|
||||||
|
height: Math.floor(bbox.height),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterRGChildren = (node: Konva.Node): boolean => node.name() === RG_LAYER_OBJECT_GROUP_NAME;
|
||||||
|
const filterLayerChildren = (node: Konva.Node): boolean => node.name() === RASTER_LAYER_OBJECT_GROUP_NAME;
|
||||||
|
const filterCAChildren = (node: Konva.Node): boolean => node.name() === CA_LAYER_IMAGE_NAME;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the bbox of each regional guidance layer. Only calculates if the mask has changed.
|
||||||
|
* @param stage The konva stage
|
||||||
|
* @param entityStates An array of layers to calculate bboxes for
|
||||||
|
* @param onBboxChanged Callback for when the bounding box changes
|
||||||
|
*/
|
||||||
|
export const updateBboxes = (
|
||||||
|
stage: Konva.Stage,
|
||||||
|
layers: LayerEntity[],
|
||||||
|
controlAdapters: ControlAdapterEntity[],
|
||||||
|
regions: RegionEntity[],
|
||||||
|
onBboxChanged: (arg: BboxChangedArg, entityType: CanvasEntity['type']) => void
|
||||||
|
): void => {
|
||||||
|
for (const entityState of [...layers, ...controlAdapters, ...regions]) {
|
||||||
|
const konvaLayer = stage.findOne<Konva.Layer>(`#${entityState.id}`);
|
||||||
|
assert(konvaLayer, `Layer ${entityState.id} not found in stage`);
|
||||||
|
// We only need to recalculate the bbox if the layer has changed
|
||||||
|
if (entityState.bboxNeedsUpdate) {
|
||||||
|
const bboxRect = konvaLayer.findOne<Konva.Rect>(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(entityState, konvaLayer);
|
||||||
|
|
||||||
|
// Hide the bbox while we calculate the new bbox, else the bbox will be included in the calculation
|
||||||
|
const visible = bboxRect.visible();
|
||||||
|
bboxRect.visible(false);
|
||||||
|
|
||||||
|
if (entityState.type === 'layer') {
|
||||||
|
if (entityState.objects.length === 0) {
|
||||||
|
// No objects - no bbox to calculate
|
||||||
|
onBboxChanged({ id: entityState.id, bbox: null }, 'layer');
|
||||||
|
} else {
|
||||||
|
onBboxChanged({ id: entityState.id, bbox: getLayerBboxPixels(konvaLayer, filterLayerChildren) }, 'layer');
|
||||||
|
}
|
||||||
|
} else if (entityState.type === 'control_adapter') {
|
||||||
|
if (!entityState.imageObject && !entityState.processedImageObject) {
|
||||||
|
// No objects - no bbox to calculate
|
||||||
|
onBboxChanged({ id: entityState.id, bbox: null }, 'control_adapter');
|
||||||
|
} else {
|
||||||
|
onBboxChanged(
|
||||||
|
{ id: entityState.id, bbox: getLayerBboxPixels(konvaLayer, filterCAChildren) },
|
||||||
|
'control_adapter'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (entityState.type === 'regional_guidance') {
|
||||||
|
if (entityState.objects.length === 0) {
|
||||||
|
// No objects - no bbox to calculate
|
||||||
|
onBboxChanged({ id: entityState.id, bbox: null }, 'regional_guidance');
|
||||||
|
} else {
|
||||||
|
onBboxChanged(
|
||||||
|
{ id: entityState.id, bbox: getLayerBboxPixels(konvaLayer, filterRGChildren) },
|
||||||
|
'regional_guidance'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the visibility of the bbox
|
||||||
|
bboxRect.visible(visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -1,7 +1,7 @@
|
|||||||
import { rgbColorToString } from 'common/util/colorCodeTransformers';
|
import { rgbColorToString } from 'common/util/colorCodeTransformers';
|
||||||
import { getObjectGroupId } from 'features/controlLayers/konva/naming';
|
import { getObjectGroupId } from 'features/controlLayers/konva/naming';
|
||||||
import type { StateApi } from 'features/controlLayers/konva/nodeManager';
|
import type { StateApi } from 'features/controlLayers/konva/nodeManager';
|
||||||
import { getLayerBboxFast } from 'features/controlLayers/konva/renderers/bbox';
|
import { getLayerBboxFast } from 'features/controlLayers/konva/renderers/entityBbox';
|
||||||
import { KonvaBrushLine, KonvaEraserLine, KonvaRect } from 'features/controlLayers/konva/renderers/objects';
|
import { KonvaBrushLine, KonvaEraserLine, KonvaRect } from 'features/controlLayers/konva/renderers/objects';
|
||||||
import { mapId } from 'features/controlLayers/konva/util';
|
import { mapId } from 'features/controlLayers/konva/util';
|
||||||
import type { CanvasEntityIdentifier, InpaintMaskEntity, Tool } from 'features/controlLayers/store/types';
|
import type { CanvasEntityIdentifier, InpaintMaskEntity, Tool } from 'features/controlLayers/store/types';
|
||||||
|
@ -1,588 +1,9 @@
|
|||||||
import { getArbitraryBaseColor } from '@invoke-ai/ui-library';
|
|
||||||
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
|
|
||||||
import { roundToMultiple, roundToMultipleMin } from 'common/util/roundDownToMultiple';
|
|
||||||
import {
|
|
||||||
BRUSH_BORDER_INNER_COLOR,
|
|
||||||
BRUSH_BORDER_OUTER_COLOR,
|
|
||||||
BRUSH_ERASER_BORDER_WIDTH,
|
|
||||||
DOCUMENT_FIT_PADDING_PX,
|
|
||||||
} from 'features/controlLayers/konva/constants';
|
|
||||||
import {
|
|
||||||
PREVIEW_GENERATION_BBOX_DUMMY_RECT,
|
|
||||||
PREVIEW_GENERATION_BBOX_GROUP,
|
|
||||||
PREVIEW_GENERATION_BBOX_TRANSFORMER,
|
|
||||||
PREVIEW_RECT_ID,
|
|
||||||
} from 'features/controlLayers/konva/naming';
|
|
||||||
import { KonvaImage } from 'features/controlLayers/konva/renderers/objects';
|
|
||||||
import type { CanvasEntity, CanvasV2State, Position, RgbaColor, StageAttrs } from 'features/controlLayers/store/types';
|
|
||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import type { IRect } from 'konva/lib/types';
|
|
||||||
import { atom } from 'nanostores';
|
|
||||||
import { assert } from 'tsafe';
|
|
||||||
|
|
||||||
export class CanvasDocumentSizeOverlay {
|
import type { CanvasBbox } from './bbox';
|
||||||
group: Konva.Group;
|
import type { CanvasDocumentSizeOverlay } from './documentSizeOverlay';
|
||||||
outerRect: Konva.Rect;
|
import type { CanvasStagingArea } from './stagingArea';
|
||||||
innerRect: Konva.Rect;
|
import type { CanvasTool } from './tool';
|
||||||
padding: number;
|
|
||||||
|
|
||||||
constructor(padding?: number) {
|
|
||||||
this.padding = padding ?? DOCUMENT_FIT_PADDING_PX;
|
|
||||||
this.group = new Konva.Group({ id: 'document_overlay_group', listening: false });
|
|
||||||
this.outerRect = new Konva.Rect({
|
|
||||||
id: 'document_overlay_outer_rect',
|
|
||||||
listening: false,
|
|
||||||
fill: getArbitraryBaseColor(10),
|
|
||||||
opacity: 0.7,
|
|
||||||
});
|
|
||||||
this.innerRect = new Konva.Rect({
|
|
||||||
id: 'document_overlay_inner_rect',
|
|
||||||
listening: false,
|
|
||||||
fill: 'white',
|
|
||||||
globalCompositeOperation: 'destination-out',
|
|
||||||
});
|
|
||||||
this.group.add(this.outerRect);
|
|
||||||
this.group.add(this.innerRect);
|
|
||||||
}
|
|
||||||
|
|
||||||
render(stage: Konva.Stage, document: CanvasV2State['document']) {
|
|
||||||
this.group.zIndex(0);
|
|
||||||
|
|
||||||
const x = stage.x();
|
|
||||||
const y = stage.y();
|
|
||||||
const width = stage.width();
|
|
||||||
const height = stage.height();
|
|
||||||
const scale = stage.scaleX();
|
|
||||||
|
|
||||||
this.outerRect.setAttrs({
|
|
||||||
offsetX: x / scale,
|
|
||||||
offsetY: y / scale,
|
|
||||||
width: width / scale,
|
|
||||||
height: height / scale,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.innerRect.setAttrs({
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: document.width,
|
|
||||||
height: document.height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fitToStage(stage: Konva.Stage, document: CanvasV2State['document'], setStageAttrs: (attrs: StageAttrs) => void) {
|
|
||||||
// Fit & center the document on the stage
|
|
||||||
const width = stage.width();
|
|
||||||
const height = stage.height();
|
|
||||||
const docWidthWithBuffer = document.width + this.padding * 2;
|
|
||||||
const docHeightWithBuffer = document.height + this.padding * 2;
|
|
||||||
const scale = Math.min(Math.min(width / docWidthWithBuffer, height / docHeightWithBuffer), 1);
|
|
||||||
const x = (width - docWidthWithBuffer * scale) / 2 + this.padding * scale;
|
|
||||||
const y = (height - docHeightWithBuffer * scale) / 2 + this.padding * scale;
|
|
||||||
stage.setAttrs({ x, y, width, height, scaleX: scale, scaleY: scale });
|
|
||||||
setStageAttrs({ x, y, width, height, scale });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CanvasStagingArea {
|
|
||||||
group: Konva.Group;
|
|
||||||
image: KonvaImage | null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.group = new Konva.Group({ listening: false });
|
|
||||||
this.image = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async render(stagingArea: CanvasV2State['stagingArea']) {
|
|
||||||
if (!stagingArea || stagingArea.selectedImageIndex === null) {
|
|
||||||
if (this.image) {
|
|
||||||
this.image.destroy();
|
|
||||||
this.image = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stagingArea.selectedImageIndex !== null) {
|
|
||||||
const imageDTO = stagingArea.images[stagingArea.selectedImageIndex];
|
|
||||||
assert(imageDTO, 'Image must exist');
|
|
||||||
if (this.image) {
|
|
||||||
if (!this.image.isLoading && !this.image.isError && this.image.imageName !== imageDTO.image_name) {
|
|
||||||
await this.image.updateImageSource(imageDTO.image_name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const { image_name, width, height } = imageDTO;
|
|
||||||
this.image = new KonvaImage({
|
|
||||||
imageObject: {
|
|
||||||
id: 'staging-area-image',
|
|
||||||
type: 'image',
|
|
||||||
x: stagingArea.bbox.x,
|
|
||||||
y: stagingArea.bbox.y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
filters: [],
|
|
||||||
image: {
|
|
||||||
name: image_name,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.group.add(this.image.konvaImageGroup);
|
|
||||||
await this.image.updateImageSource(imageDTO.image_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CanvasTool {
|
|
||||||
group: Konva.Group;
|
|
||||||
brush: {
|
|
||||||
group: Konva.Group;
|
|
||||||
fillCircle: Konva.Circle;
|
|
||||||
innerBorderCircle: Konva.Circle;
|
|
||||||
outerBorderCircle: Konva.Circle;
|
|
||||||
};
|
|
||||||
eraser: {
|
|
||||||
group: Konva.Group;
|
|
||||||
fillCircle: Konva.Circle;
|
|
||||||
innerBorderCircle: Konva.Circle;
|
|
||||||
outerBorderCircle: Konva.Circle;
|
|
||||||
};
|
|
||||||
rect: {
|
|
||||||
group: Konva.Group;
|
|
||||||
fillRect: Konva.Rect;
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.group = new Konva.Group();
|
|
||||||
|
|
||||||
// Create the brush preview group & circles
|
|
||||||
this.brush = {
|
|
||||||
group: new Konva.Group(),
|
|
||||||
fillCircle: new Konva.Circle({
|
|
||||||
listening: false,
|
|
||||||
strokeEnabled: false,
|
|
||||||
}),
|
|
||||||
innerBorderCircle: new Konva.Circle({
|
|
||||||
listening: false,
|
|
||||||
stroke: BRUSH_BORDER_INNER_COLOR,
|
|
||||||
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
|
|
||||||
strokeEnabled: true,
|
|
||||||
}),
|
|
||||||
outerBorderCircle: new Konva.Circle({
|
|
||||||
listening: false,
|
|
||||||
stroke: BRUSH_BORDER_OUTER_COLOR,
|
|
||||||
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
|
|
||||||
strokeEnabled: true,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
this.brush.group.add(this.brush.fillCircle);
|
|
||||||
this.brush.group.add(this.brush.innerBorderCircle);
|
|
||||||
this.brush.group.add(this.brush.outerBorderCircle);
|
|
||||||
this.group.add(this.brush.group);
|
|
||||||
|
|
||||||
this.eraser = {
|
|
||||||
group: new Konva.Group(),
|
|
||||||
fillCircle: new Konva.Circle({
|
|
||||||
listening: false,
|
|
||||||
strokeEnabled: false,
|
|
||||||
fill: 'white',
|
|
||||||
globalCompositeOperation: 'destination-out',
|
|
||||||
}),
|
|
||||||
innerBorderCircle: new Konva.Circle({
|
|
||||||
listening: false,
|
|
||||||
stroke: BRUSH_BORDER_INNER_COLOR,
|
|
||||||
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
|
|
||||||
strokeEnabled: true,
|
|
||||||
}),
|
|
||||||
outerBorderCircle: new Konva.Circle({
|
|
||||||
listening: false,
|
|
||||||
stroke: BRUSH_BORDER_OUTER_COLOR,
|
|
||||||
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
|
|
||||||
strokeEnabled: true,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
this.eraser.group.add(this.eraser.fillCircle);
|
|
||||||
this.eraser.group.add(this.eraser.innerBorderCircle);
|
|
||||||
this.eraser.group.add(this.eraser.outerBorderCircle);
|
|
||||||
this.group.add(this.eraser.group);
|
|
||||||
|
|
||||||
// Create the rect preview - this is a rectangle drawn from the last mouse down position to the current cursor position
|
|
||||||
this.rect = {
|
|
||||||
group: new Konva.Group(),
|
|
||||||
fillRect: new Konva.Rect({
|
|
||||||
id: PREVIEW_RECT_ID,
|
|
||||||
listening: false,
|
|
||||||
strokeEnabled: false,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
this.rect.group.add(this.rect.fillRect);
|
|
||||||
this.group.add(this.rect.group);
|
|
||||||
}
|
|
||||||
|
|
||||||
scaleTool(stage: Konva.Stage, toolState: CanvasV2State['tool']) {
|
|
||||||
const scale = stage.scaleX();
|
|
||||||
|
|
||||||
const brushRadius = toolState.brush.width / 2;
|
|
||||||
this.brush.innerBorderCircle.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale);
|
|
||||||
this.brush.outerBorderCircle.setAttrs({
|
|
||||||
strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale,
|
|
||||||
radius: brushRadius + BRUSH_ERASER_BORDER_WIDTH / scale,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eraserRadius = toolState.eraser.width / 2;
|
|
||||||
this.eraser.innerBorderCircle.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale);
|
|
||||||
this.eraser.outerBorderCircle.setAttrs({
|
|
||||||
strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale,
|
|
||||||
radius: eraserRadius + BRUSH_ERASER_BORDER_WIDTH / scale,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render(
|
|
||||||
stage: Konva.Stage,
|
|
||||||
renderedEntityCount: number,
|
|
||||||
toolState: CanvasV2State['tool'],
|
|
||||||
currentFill: RgbaColor,
|
|
||||||
selectedEntity: CanvasEntity | null,
|
|
||||||
cursorPos: Position | null,
|
|
||||||
lastMouseDownPos: Position | null,
|
|
||||||
isDrawing: boolean,
|
|
||||||
isMouseDown: boolean
|
|
||||||
) {
|
|
||||||
const tool = toolState.selected;
|
|
||||||
const isDrawableEntity =
|
|
||||||
selectedEntity?.type === 'regional_guidance' ||
|
|
||||||
selectedEntity?.type === 'layer' ||
|
|
||||||
selectedEntity?.type === 'inpaint_mask';
|
|
||||||
|
|
||||||
// Update the stage's pointer style
|
|
||||||
if (tool === 'view') {
|
|
||||||
// View gets a hand
|
|
||||||
stage.container().style.cursor = isMouseDown ? 'grabbing' : 'grab';
|
|
||||||
} else if (renderedEntityCount === 0) {
|
|
||||||
// We have no layers, so we should not render any tool
|
|
||||||
stage.container().style.cursor = 'default';
|
|
||||||
} else if (!isDrawableEntity) {
|
|
||||||
// Non-drawable layers don't have tools
|
|
||||||
stage.container().style.cursor = 'not-allowed';
|
|
||||||
} else if (tool === 'move') {
|
|
||||||
// Move tool gets a pointer
|
|
||||||
stage.container().style.cursor = 'default';
|
|
||||||
} else if (tool === 'rect') {
|
|
||||||
// Rect gets a crosshair
|
|
||||||
stage.container().style.cursor = 'crosshair';
|
|
||||||
} else if (tool === 'brush' || tool === 'eraser') {
|
|
||||||
// Hide the native cursor and use the konva-rendered brush preview
|
|
||||||
stage.container().style.cursor = 'none';
|
|
||||||
} else if (tool === 'bbox') {
|
|
||||||
stage.container().style.cursor = 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
stage.draggable(tool === 'view');
|
|
||||||
|
|
||||||
if (!cursorPos || renderedEntityCount === 0 || !isDrawableEntity) {
|
|
||||||
// We can bail early if the mouse isn't over the stage or there are no layers
|
|
||||||
this.group.visible(false);
|
|
||||||
} else {
|
|
||||||
this.group.visible(true);
|
|
||||||
|
|
||||||
// No need to render the brush preview if the cursor position or color is missing
|
|
||||||
if (cursorPos && tool === 'brush') {
|
|
||||||
const scale = stage.scaleX();
|
|
||||||
// Update the fill circle
|
|
||||||
const radius = toolState.brush.width / 2;
|
|
||||||
this.brush.fillCircle.setAttrs({
|
|
||||||
x: cursorPos.x,
|
|
||||||
y: cursorPos.y,
|
|
||||||
radius,
|
|
||||||
fill: isDrawing ? '' : rgbaColorToString(currentFill),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the inner border of the brush preview
|
|
||||||
this.brush.innerBorderCircle.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius });
|
|
||||||
|
|
||||||
// Update the outer border of the brush preview
|
|
||||||
this.brush.outerBorderCircle.setAttrs({
|
|
||||||
x: cursorPos.x,
|
|
||||||
y: cursorPos.y,
|
|
||||||
radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scaleTool(stage, toolState);
|
|
||||||
|
|
||||||
this.brush.group.visible(true);
|
|
||||||
this.eraser.group.visible(false);
|
|
||||||
this.rect.group.visible(false);
|
|
||||||
} else if (cursorPos && tool === 'eraser') {
|
|
||||||
const scale = stage.scaleX();
|
|
||||||
// Update the fill circle
|
|
||||||
const radius = toolState.eraser.width / 2;
|
|
||||||
this.eraser.fillCircle.setAttrs({
|
|
||||||
x: cursorPos.x,
|
|
||||||
y: cursorPos.y,
|
|
||||||
radius,
|
|
||||||
fill: 'white',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the inner border of the eraser preview
|
|
||||||
this.eraser.innerBorderCircle.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius });
|
|
||||||
|
|
||||||
// Update the outer border of the eraser preview
|
|
||||||
this.eraser.outerBorderCircle.setAttrs({
|
|
||||||
x: cursorPos.x,
|
|
||||||
y: cursorPos.y,
|
|
||||||
radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scaleTool(stage, toolState);
|
|
||||||
|
|
||||||
this.brush.group.visible(false);
|
|
||||||
this.eraser.group.visible(true);
|
|
||||||
this.rect.group.visible(false);
|
|
||||||
} else if (cursorPos && lastMouseDownPos && tool === 'rect') {
|
|
||||||
this.rect.fillRect.setAttrs({
|
|
||||||
x: Math.min(cursorPos.x, lastMouseDownPos.x),
|
|
||||||
y: Math.min(cursorPos.y, lastMouseDownPos.y),
|
|
||||||
width: Math.abs(cursorPos.x - lastMouseDownPos.x),
|
|
||||||
height: Math.abs(cursorPos.y - lastMouseDownPos.y),
|
|
||||||
fill: rgbaColorToString(currentFill),
|
|
||||||
visible: true,
|
|
||||||
});
|
|
||||||
this.brush.group.visible(false);
|
|
||||||
this.eraser.group.visible(false);
|
|
||||||
this.rect.group.visible(true);
|
|
||||||
} else {
|
|
||||||
this.brush.group.visible(false);
|
|
||||||
this.eraser.group.visible(false);
|
|
||||||
this.rect.group.visible(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CanvasBbox {
|
|
||||||
group: Konva.Group;
|
|
||||||
rect: Konva.Rect;
|
|
||||||
transformer: Konva.Transformer;
|
|
||||||
|
|
||||||
ALL_ANCHORS: string[] = [
|
|
||||||
'top-left',
|
|
||||||
'top-center',
|
|
||||||
'top-right',
|
|
||||||
'middle-right',
|
|
||||||
'middle-left',
|
|
||||||
'bottom-left',
|
|
||||||
'bottom-center',
|
|
||||||
'bottom-right',
|
|
||||||
];
|
|
||||||
CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
||||||
NO_ANCHORS: string[] = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
getBbox: () => IRect,
|
|
||||||
onBboxTransformed: (bbox: IRect) => void,
|
|
||||||
getShiftKey: () => boolean,
|
|
||||||
getCtrlKey: () => boolean,
|
|
||||||
getMetaKey: () => boolean,
|
|
||||||
getAltKey: () => boolean
|
|
||||||
) {
|
|
||||||
// Create a stash to hold onto the last aspect ratio of the bbox - this allows for locking the aspect ratio when
|
|
||||||
// transforming the bbox.
|
|
||||||
const bbox = getBbox();
|
|
||||||
const $aspectRatioBuffer = atom(bbox.width / bbox.height);
|
|
||||||
|
|
||||||
// Use a transformer for the generation bbox. Transformers need some shape to transform, we will use a fully
|
|
||||||
// transparent rect for this purpose.
|
|
||||||
this.group = new Konva.Group({ id: PREVIEW_GENERATION_BBOX_GROUP, listening: false });
|
|
||||||
this.rect = new Konva.Rect({
|
|
||||||
id: PREVIEW_GENERATION_BBOX_DUMMY_RECT,
|
|
||||||
listening: false,
|
|
||||||
strokeEnabled: false,
|
|
||||||
draggable: true,
|
|
||||||
...getBbox(),
|
|
||||||
});
|
|
||||||
this.rect.on('dragmove', () => {
|
|
||||||
const gridSize = getCtrlKey() || getMetaKey() ? 8 : 64;
|
|
||||||
const oldBbox = getBbox();
|
|
||||||
const newBbox: IRect = {
|
|
||||||
...oldBbox,
|
|
||||||
x: roundToMultiple(this.rect.x(), gridSize),
|
|
||||||
y: roundToMultiple(this.rect.y(), gridSize),
|
|
||||||
};
|
|
||||||
this.rect.setAttrs(newBbox);
|
|
||||||
if (oldBbox.x !== newBbox.x || oldBbox.y !== newBbox.y) {
|
|
||||||
onBboxTransformed(newBbox);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.transformer = new Konva.Transformer({
|
|
||||||
id: PREVIEW_GENERATION_BBOX_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: (anchor) => {
|
|
||||||
// Make the x/y resize anchors little bars
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
anchorDragBoundFunc: (_oldAbsPos, newAbsPos) => {
|
|
||||||
// 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.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 = getCtrlKey() || getMetaKey() ? 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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.transformer.on('transform', () => {
|
|
||||||
// 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.transformer.getActiveAnchor();
|
|
||||||
if (!anchor) {
|
|
||||||
// Pretty sure we should always have an anchor here?
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alt = getAltKey();
|
|
||||||
const ctrl = getCtrlKey();
|
|
||||||
const meta = getMetaKey();
|
|
||||||
const shift = getShiftKey();
|
|
||||||
|
|
||||||
// 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 (getAltKey()) {
|
|
||||||
gridSize = gridSize * 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The coords should be correct per the anchorDragBoundFunc.
|
|
||||||
let x = this.rect.x();
|
|
||||||
let y = this.rect.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.rect.width() * this.rect.scaleX(), gridSize);
|
|
||||||
let height = roundToMultipleMin(this.rect.height() * this.rect.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 && this.CORNER_ANCHORS.includes(anchor) && !alt) {
|
|
||||||
// Fit the bbox to the last aspect ratio
|
|
||||||
let fittedWidth = Math.sqrt(width * height * $aspectRatioBuffer.get());
|
|
||||||
let fittedHeight = fittedWidth / $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 bbox = {
|
|
||||||
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.
|
|
||||||
// TODO(psyche): In `renderBboxPreview()` we also call setAttrs, need to do it twice to ensure it renders correctly.
|
|
||||||
// Gotta be a way to avoid setting it twice...
|
|
||||||
this.rect.setAttrs({ ...bbox, scaleX: 1, scaleY: 1 });
|
|
||||||
|
|
||||||
// Update the bbox in internal state.
|
|
||||||
onBboxTransformed(bbox);
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
$aspectRatioBuffer.set(bbox.width / bbox.height);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.transformer.on('transformend', () => {
|
|
||||||
// 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.
|
|
||||||
$aspectRatioBuffer.set(this.rect.width() / this.rect.height());
|
|
||||||
});
|
|
||||||
|
|
||||||
// The transformer will always be transforming the dummy rect
|
|
||||||
this.transformer.nodes([this.rect]);
|
|
||||||
this.group.add(this.rect);
|
|
||||||
this.group.add(this.transformer);
|
|
||||||
}
|
|
||||||
|
|
||||||
render(bbox: CanvasV2State['bbox'], toolState: CanvasV2State['tool']) {
|
|
||||||
this.group.listening(toolState.selected === 'bbox');
|
|
||||||
this.rect.setAttrs({
|
|
||||||
x: bbox.x,
|
|
||||||
y: bbox.y,
|
|
||||||
width: bbox.width,
|
|
||||||
height: bbox.height,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
listening: toolState.selected === 'bbox',
|
|
||||||
});
|
|
||||||
this.transformer.setAttrs({
|
|
||||||
listening: toolState.selected === 'bbox',
|
|
||||||
enabledAnchors: toolState.selected === 'bbox' ? this.ALL_ANCHORS : this.NO_ANCHORS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CanvasPreview {
|
export class CanvasPreview {
|
||||||
konvaLayer: Konva.Layer;
|
konvaLayer: Konva.Layer;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { rgbColorToString } from 'common/util/colorCodeTransformers';
|
import { rgbColorToString } from 'common/util/colorCodeTransformers';
|
||||||
import { getObjectGroupId } from 'features/controlLayers/konva/naming';
|
import { getObjectGroupId } from 'features/controlLayers/konva/naming';
|
||||||
import type { StateApi } from 'features/controlLayers/konva/nodeManager';
|
import type { StateApi } from 'features/controlLayers/konva/nodeManager';
|
||||||
import { getLayerBboxFast } from 'features/controlLayers/konva/renderers/bbox';
|
import { getLayerBboxFast } from 'features/controlLayers/konva/renderers/entityBbox';
|
||||||
import { KonvaBrushLine, KonvaEraserLine, KonvaRect } from 'features/controlLayers/konva/renderers/objects';
|
import { KonvaBrushLine, KonvaEraserLine, KonvaRect } from 'features/controlLayers/konva/renderers/objects';
|
||||||
import { mapId } from 'features/controlLayers/konva/util';
|
import { mapId } from 'features/controlLayers/konva/util';
|
||||||
import type { CanvasEntityIdentifier, RegionEntity, Tool } from 'features/controlLayers/store/types';
|
import type { CanvasEntityIdentifier, RegionEntity, Tool } from 'features/controlLayers/store/types';
|
||||||
|
@ -5,7 +5,7 @@ import { $isDebugging } from 'app/store/nanostores/isDebugging';
|
|||||||
import type { RootState } from 'app/store/store';
|
import type { RootState } from 'app/store/store';
|
||||||
import { setStageEventHandlers } from 'features/controlLayers/konva/events';
|
import { setStageEventHandlers } from 'features/controlLayers/konva/events';
|
||||||
import { KonvaNodeManager, setNodeManager } from 'features/controlLayers/konva/nodeManager';
|
import { KonvaNodeManager, setNodeManager } from 'features/controlLayers/konva/nodeManager';
|
||||||
import { updateBboxes } from 'features/controlLayers/konva/renderers/bbox';
|
import { updateBboxes } from 'features/controlLayers/konva/renderers/entityBbox';
|
||||||
import {
|
import {
|
||||||
$stageAttrs,
|
$stageAttrs,
|
||||||
bboxChanged,
|
bboxChanged,
|
||||||
|
@ -1,41 +1,55 @@
|
|||||||
import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
|
import { KonvaImage } from 'features/controlLayers/konva/renderers/objects';
|
||||||
import { createImageObjectGroup, updateImageSource } from 'features/controlLayers/konva/renderers/objects';
|
import type { CanvasV2State } from 'features/controlLayers/store/types';
|
||||||
import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/types';
|
|
||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import { assert } from 'tsafe';
|
import { assert } from 'tsafe';
|
||||||
|
|
||||||
export const createStagingArea = (): KonvaNodeManager['preview']['stagingArea'] => {
|
|
||||||
const group = new Konva.Group({ id: 'staging_area_group', listening: false });
|
|
||||||
return { group, image: null };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRenderStagingArea = async (manager: KonvaNodeManager) => {
|
export class CanvasStagingArea {
|
||||||
const { getStagingAreaState } = manager.stateApi;
|
group: Konva.Group;
|
||||||
const stagingArea = getStagingAreaState();
|
image: KonvaImage | null;
|
||||||
|
|
||||||
if (!stagingArea || stagingArea.selectedImageIndex === null) {
|
constructor() {
|
||||||
if (manager.preview.stagingArea.image) {
|
this.group = new Konva.Group({ listening: false });
|
||||||
manager.preview.stagingArea.image.konvaImageGroup.visible(false);
|
this.image = null;
|
||||||
manager.preview.stagingArea.image = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stagingArea.selectedImageIndex) {
|
async render(stagingArea: CanvasV2State['stagingArea']) {
|
||||||
const imageDTO = stagingArea.images[stagingArea.selectedImageIndex];
|
if (!stagingArea || stagingArea.selectedImageIndex === null) {
|
||||||
assert(imageDTO, 'Image must exist');
|
if (this.image) {
|
||||||
if (manager.preview.stagingArea.image) {
|
this.image.destroy();
|
||||||
if (manager.preview.stagingArea.image.imageName !== imageDTO.image_name) {
|
this.image = null;
|
||||||
await updateImageSource({
|
}
|
||||||
objectRecord: manager.preview.stagingArea.image,
|
return;
|
||||||
image: imageDTOToImageWithDims(imageDTO),
|
}
|
||||||
});
|
|
||||||
|
if (stagingArea.selectedImageIndex !== null) {
|
||||||
|
const imageDTO = stagingArea.images[stagingArea.selectedImageIndex];
|
||||||
|
assert(imageDTO, 'Image must exist');
|
||||||
|
if (this.image) {
|
||||||
|
if (!this.image.isLoading && !this.image.isError && this.image.imageName !== imageDTO.image_name) {
|
||||||
|
await this.image.updateImageSource(imageDTO.image_name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { image_name, width, height } = imageDTO;
|
||||||
|
this.image = new KonvaImage({
|
||||||
|
imageObject: {
|
||||||
|
id: 'staging-area-image',
|
||||||
|
type: 'image',
|
||||||
|
x: stagingArea.bbox.x,
|
||||||
|
y: stagingArea.bbox.y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
filters: [],
|
||||||
|
image: {
|
||||||
|
name: image_name,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.group.add(this.image.konvaImageGroup);
|
||||||
|
await this.image.updateImageSource(imageDTO.image_name);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
manager.preview.stagingArea.image = await createImageObjectGroup({
|
|
||||||
obj: imageDTOToImageObject(imageDTO),
|
|
||||||
name: imageDTO.image_name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
@ -0,0 +1,235 @@
|
|||||||
|
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
|
||||||
|
import {
|
||||||
|
BRUSH_BORDER_INNER_COLOR,
|
||||||
|
BRUSH_BORDER_OUTER_COLOR,
|
||||||
|
BRUSH_ERASER_BORDER_WIDTH
|
||||||
|
} from 'features/controlLayers/konva/constants';
|
||||||
|
import { PREVIEW_RECT_ID } from 'features/controlLayers/konva/naming';
|
||||||
|
import type { CanvasEntity, CanvasV2State, Position, RgbaColor } from 'features/controlLayers/store/types';
|
||||||
|
import Konva from 'konva';
|
||||||
|
|
||||||
|
|
||||||
|
export class CanvasTool {
|
||||||
|
group: Konva.Group;
|
||||||
|
brush: {
|
||||||
|
group: Konva.Group;
|
||||||
|
fillCircle: Konva.Circle;
|
||||||
|
innerBorderCircle: Konva.Circle;
|
||||||
|
outerBorderCircle: Konva.Circle;
|
||||||
|
};
|
||||||
|
eraser: {
|
||||||
|
group: Konva.Group;
|
||||||
|
fillCircle: Konva.Circle;
|
||||||
|
innerBorderCircle: Konva.Circle;
|
||||||
|
outerBorderCircle: Konva.Circle;
|
||||||
|
};
|
||||||
|
rect: {
|
||||||
|
group: Konva.Group;
|
||||||
|
fillRect: Konva.Rect;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.group = new Konva.Group();
|
||||||
|
|
||||||
|
// Create the brush preview group & circles
|
||||||
|
this.brush = {
|
||||||
|
group: new Konva.Group(),
|
||||||
|
fillCircle: new Konva.Circle({
|
||||||
|
listening: false,
|
||||||
|
strokeEnabled: false,
|
||||||
|
}),
|
||||||
|
innerBorderCircle: new Konva.Circle({
|
||||||
|
listening: false,
|
||||||
|
stroke: BRUSH_BORDER_INNER_COLOR,
|
||||||
|
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
|
||||||
|
strokeEnabled: true,
|
||||||
|
}),
|
||||||
|
outerBorderCircle: new Konva.Circle({
|
||||||
|
listening: false,
|
||||||
|
stroke: BRUSH_BORDER_OUTER_COLOR,
|
||||||
|
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
|
||||||
|
strokeEnabled: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
this.brush.group.add(this.brush.fillCircle);
|
||||||
|
this.brush.group.add(this.brush.innerBorderCircle);
|
||||||
|
this.brush.group.add(this.brush.outerBorderCircle);
|
||||||
|
this.group.add(this.brush.group);
|
||||||
|
|
||||||
|
this.eraser = {
|
||||||
|
group: new Konva.Group(),
|
||||||
|
fillCircle: new Konva.Circle({
|
||||||
|
listening: false,
|
||||||
|
strokeEnabled: false,
|
||||||
|
fill: 'white',
|
||||||
|
globalCompositeOperation: 'destination-out',
|
||||||
|
}),
|
||||||
|
innerBorderCircle: new Konva.Circle({
|
||||||
|
listening: false,
|
||||||
|
stroke: BRUSH_BORDER_INNER_COLOR,
|
||||||
|
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
|
||||||
|
strokeEnabled: true,
|
||||||
|
}),
|
||||||
|
outerBorderCircle: new Konva.Circle({
|
||||||
|
listening: false,
|
||||||
|
stroke: BRUSH_BORDER_OUTER_COLOR,
|
||||||
|
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
|
||||||
|
strokeEnabled: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
this.eraser.group.add(this.eraser.fillCircle);
|
||||||
|
this.eraser.group.add(this.eraser.innerBorderCircle);
|
||||||
|
this.eraser.group.add(this.eraser.outerBorderCircle);
|
||||||
|
this.group.add(this.eraser.group);
|
||||||
|
|
||||||
|
// Create the rect preview - this is a rectangle drawn from the last mouse down position to the current cursor position
|
||||||
|
this.rect = {
|
||||||
|
group: new Konva.Group(),
|
||||||
|
fillRect: new Konva.Rect({
|
||||||
|
id: PREVIEW_RECT_ID,
|
||||||
|
listening: false,
|
||||||
|
strokeEnabled: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
this.rect.group.add(this.rect.fillRect);
|
||||||
|
this.group.add(this.rect.group);
|
||||||
|
}
|
||||||
|
|
||||||
|
scaleTool(stage: Konva.Stage, toolState: CanvasV2State['tool']) {
|
||||||
|
const scale = stage.scaleX();
|
||||||
|
|
||||||
|
const brushRadius = toolState.brush.width / 2;
|
||||||
|
this.brush.innerBorderCircle.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale);
|
||||||
|
this.brush.outerBorderCircle.setAttrs({
|
||||||
|
strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale,
|
||||||
|
radius: brushRadius + BRUSH_ERASER_BORDER_WIDTH / scale,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eraserRadius = toolState.eraser.width / 2;
|
||||||
|
this.eraser.innerBorderCircle.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale);
|
||||||
|
this.eraser.outerBorderCircle.setAttrs({
|
||||||
|
strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale,
|
||||||
|
radius: eraserRadius + BRUSH_ERASER_BORDER_WIDTH / scale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
stage: Konva.Stage,
|
||||||
|
renderedEntityCount: number,
|
||||||
|
toolState: CanvasV2State['tool'],
|
||||||
|
currentFill: RgbaColor,
|
||||||
|
selectedEntity: CanvasEntity | null,
|
||||||
|
cursorPos: Position | null,
|
||||||
|
lastMouseDownPos: Position | null,
|
||||||
|
isDrawing: boolean,
|
||||||
|
isMouseDown: boolean
|
||||||
|
) {
|
||||||
|
const tool = toolState.selected;
|
||||||
|
const isDrawableEntity = selectedEntity?.type === 'regional_guidance' ||
|
||||||
|
selectedEntity?.type === 'layer' ||
|
||||||
|
selectedEntity?.type === 'inpaint_mask';
|
||||||
|
|
||||||
|
// Update the stage's pointer style
|
||||||
|
if (tool === 'view') {
|
||||||
|
// View gets a hand
|
||||||
|
stage.container().style.cursor = isMouseDown ? 'grabbing' : 'grab';
|
||||||
|
} else if (renderedEntityCount === 0) {
|
||||||
|
// We have no layers, so we should not render any tool
|
||||||
|
stage.container().style.cursor = 'default';
|
||||||
|
} else if (!isDrawableEntity) {
|
||||||
|
// Non-drawable layers don't have tools
|
||||||
|
stage.container().style.cursor = 'not-allowed';
|
||||||
|
} else if (tool === 'move') {
|
||||||
|
// Move tool gets a pointer
|
||||||
|
stage.container().style.cursor = 'default';
|
||||||
|
} else if (tool === 'rect') {
|
||||||
|
// Rect gets a crosshair
|
||||||
|
stage.container().style.cursor = 'crosshair';
|
||||||
|
} else if (tool === 'brush' || tool === 'eraser') {
|
||||||
|
// Hide the native cursor and use the konva-rendered brush preview
|
||||||
|
stage.container().style.cursor = 'none';
|
||||||
|
} else if (tool === 'bbox') {
|
||||||
|
stage.container().style.cursor = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.draggable(tool === 'view');
|
||||||
|
|
||||||
|
if (!cursorPos || renderedEntityCount === 0 || !isDrawableEntity) {
|
||||||
|
// We can bail early if the mouse isn't over the stage or there are no layers
|
||||||
|
this.group.visible(false);
|
||||||
|
} else {
|
||||||
|
this.group.visible(true);
|
||||||
|
|
||||||
|
// No need to render the brush preview if the cursor position or color is missing
|
||||||
|
if (cursorPos && tool === 'brush') {
|
||||||
|
const scale = stage.scaleX();
|
||||||
|
// Update the fill circle
|
||||||
|
const radius = toolState.brush.width / 2;
|
||||||
|
this.brush.fillCircle.setAttrs({
|
||||||
|
x: cursorPos.x,
|
||||||
|
y: cursorPos.y,
|
||||||
|
radius,
|
||||||
|
fill: isDrawing ? '' : rgbaColorToString(currentFill),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the inner border of the brush preview
|
||||||
|
this.brush.innerBorderCircle.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius });
|
||||||
|
|
||||||
|
// Update the outer border of the brush preview
|
||||||
|
this.brush.outerBorderCircle.setAttrs({
|
||||||
|
x: cursorPos.x,
|
||||||
|
y: cursorPos.y,
|
||||||
|
radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scaleTool(stage, toolState);
|
||||||
|
|
||||||
|
this.brush.group.visible(true);
|
||||||
|
this.eraser.group.visible(false);
|
||||||
|
this.rect.group.visible(false);
|
||||||
|
} else if (cursorPos && tool === 'eraser') {
|
||||||
|
const scale = stage.scaleX();
|
||||||
|
// Update the fill circle
|
||||||
|
const radius = toolState.eraser.width / 2;
|
||||||
|
this.eraser.fillCircle.setAttrs({
|
||||||
|
x: cursorPos.x,
|
||||||
|
y: cursorPos.y,
|
||||||
|
radius,
|
||||||
|
fill: 'white',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the inner border of the eraser preview
|
||||||
|
this.eraser.innerBorderCircle.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius });
|
||||||
|
|
||||||
|
// Update the outer border of the eraser preview
|
||||||
|
this.eraser.outerBorderCircle.setAttrs({
|
||||||
|
x: cursorPos.x,
|
||||||
|
y: cursorPos.y,
|
||||||
|
radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scaleTool(stage, toolState);
|
||||||
|
|
||||||
|
this.brush.group.visible(false);
|
||||||
|
this.eraser.group.visible(true);
|
||||||
|
this.rect.group.visible(false);
|
||||||
|
} else if (cursorPos && lastMouseDownPos && tool === 'rect') {
|
||||||
|
this.rect.fillRect.setAttrs({
|
||||||
|
x: Math.min(cursorPos.x, lastMouseDownPos.x),
|
||||||
|
y: Math.min(cursorPos.y, lastMouseDownPos.y),
|
||||||
|
width: Math.abs(cursorPos.x - lastMouseDownPos.x),
|
||||||
|
height: Math.abs(cursorPos.y - lastMouseDownPos.y),
|
||||||
|
fill: rgbaColorToString(currentFill),
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
this.brush.group.visible(false);
|
||||||
|
this.eraser.group.visible(false);
|
||||||
|
this.rect.group.visible(true);
|
||||||
|
} else {
|
||||||
|
this.brush.group.visible(false);
|
||||||
|
this.eraser.group.visible(false);
|
||||||
|
this.rect.group.visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user