tidy(ui): abstract stage logic into module

This commit is contained in:
psychedelicious 2024-08-22 10:06:58 +10:00
parent 516ecdb0ee
commit ee7dafaf57
7 changed files with 276 additions and 219 deletions

View File

@ -16,14 +16,14 @@ export const CanvasResetViewButton = memo(() => {
if (!canvasManager) {
return;
}
canvasManager.setStageScale(1);
canvasManager.stage.setScale(1);
}, [canvasManager]);
const resetView = useCallback(() => {
if (!canvasManager) {
return;
}
canvasManager.resetView();
canvasManager.stage.resetView();
}, [canvasManager]);
const onReset = useCallback(() => {

View File

@ -91,7 +91,7 @@ export const CanvasScale = memo(() => {
snappedScale = snapToNearest(scale, snapCandidates, 2);
}
const mappedScale = mapSliderValueToScale(snappedScale);
canvasManager.setStageScale(mappedScale / 100);
canvasManager.stage.setScale(mappedScale / 100);
},
[canvasManager]
);
@ -101,11 +101,11 @@ export const CanvasScale = memo(() => {
return;
}
if (isNaN(Number(localScale))) {
canvasManager.setStageScale(1);
canvasManager.stage.setScale(1);
setLocalScale(100);
return;
}
canvasManager.setStageScale(clamp(localScale / 100, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE));
canvasManager.stage.setScale(clamp(localScale / 100, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE));
}, [canvasManager, localScale]);
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {

View File

@ -1,22 +1,20 @@
import type { AppSocket } from 'app/hooks/useSocketIO';
import { logger } from 'app/logging/logger';
import type { AppStore } from 'app/store/store';
import type { JSONObject, SerializableObject } from 'common/types';
import type { SerializableObject } from 'common/types';
import { CanvasFilter } from 'features/controlLayers/konva/CanvasFilter';
import { MAX_CANVAS_SCALE, MIN_CANVAS_SCALE } from 'features/controlLayers/konva/constants';
import { CanvasStageModule } from 'features/controlLayers/konva/CanvasStageModule';
import {
canvasToBlob,
canvasToImageData,
getImageDataTransparency,
getPrefixedId,
getRectUnion,
nanoid,
previewBlob,
} from 'features/controlLayers/konva/util';
import type { Extents, ExtentsResult, GetBboxTask, WorkerLogMessage } from 'features/controlLayers/konva/worker';
import type { CanvasV2State, Coordinate, Dimensions, GenerationMode, Rect } from 'features/controlLayers/store/types';
import type { CanvasV2State, GenerationMode, Rect } from 'features/controlLayers/store/types';
import type Konva from 'konva';
import { clamp } from 'lodash-es';
import { LRUCache } from 'lru-cache';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
@ -40,7 +38,6 @@ export class CanvasManager {
id: string;
path: string[];
stage: Konva.Stage;
container: HTMLDivElement;
rasterLayerAdapters: Map<string, CanvasLayerAdapter> = new Map();
controlLayerAdapters: Map<string, CanvasLayerAdapter> = new Map();
@ -50,6 +47,7 @@ export class CanvasManager {
preview: CanvasPreview;
background: CanvasBackground;
filter: CanvasFilter;
stage: CanvasStageModule;
log: Logger;
socket: AppSocket;
@ -69,7 +67,6 @@ export class CanvasManager {
constructor(stage: Konva.Stage, container: HTMLDivElement, store: AppStore, socket: AppSocket) {
this.id = getPrefixedId(this.type);
this.path = [this.id];
this.stage = stage;
this.container = container;
this._store = store;
this.socket = socket;
@ -87,11 +84,13 @@ export class CanvasManager {
};
});
this.stage = new CanvasStageModule(stage, container, this);
this.preview = new CanvasPreview(this);
this.stage.add(this.preview.getLayer());
this.stage.addLayer(this.preview.getLayer());
this.background = new CanvasBackground(this);
this.stage.add(this.background.konva.layer);
this.stage.addLayer(this.background.konva.layer);
this.filter = new CanvasFilter(this);
@ -169,83 +168,6 @@ export class CanvasManager {
this.preview.getLayer().zIndex(++zIndex);
}
fitStageToContainer() {
this.stage.width(this.container.offsetWidth);
this.stage.height(this.container.offsetHeight);
this.stateApi.$stageAttrs.set({
x: this.stage.x(),
y: this.stage.y(),
width: this.stage.width(),
height: this.stage.height(),
scale: this.stage.scaleX(),
});
}
getVisibleRect = (): Rect => {
const rects = [];
for (const adapter of this.inpaintMaskAdapters.values()) {
if (adapter.state.isEnabled) {
rects.push(adapter.transformer.getRelativeRect());
}
}
for (const adapter of this.rasterLayerAdapters.values()) {
if (adapter.state.isEnabled) {
rects.push(adapter.transformer.getRelativeRect());
}
}
for (const adapter of this.controlLayerAdapters.values()) {
if (adapter.state.isEnabled) {
rects.push(adapter.transformer.getRelativeRect());
}
}
for (const adapter of this.regionalGuidanceAdapters.values()) {
if (adapter.state.isEnabled) {
rects.push(adapter.transformer.getRelativeRect());
}
}
const rectUnion = getRectUnion(...rects);
if (rectUnion.width === 0 || rectUnion.height === 0) {
// fall back to the bbox if there is no content
return this.stateApi.getBbox().rect;
} else {
return rectUnion;
}
};
resetView() {
const { width, height } = this.getStageSize();
const rect = this.getVisibleRect();
const padding = 20; // Padding in absolute pixels
const availableWidth = width - padding * 2;
const availableHeight = height - padding * 2;
const scale = Math.min(Math.min(availableWidth / rect.width, availableHeight / rect.height), 1);
const x = -rect.x * scale + padding + (availableWidth - rect.width * scale) / 2;
const y = -rect.y * scale + padding + (availableHeight - rect.height * scale) / 2;
this.stage.setAttrs({
x,
y,
scaleX: scale,
scaleY: scale,
});
this.stateApi.$stageAttrs.set({
...this.stateApi.$stageAttrs.get(),
x,
y,
scale,
});
}
getTransformingLayer = (): CanvasLayerAdapter | CanvasMaskAdapter | null => {
const transformingEntity = this.stateApi.$transformingEntity.get();
if (!transformingEntity) {
@ -307,6 +229,10 @@ export class CanvasManager {
const isFirstRender = this.isFirstRender;
this.isFirstRender = false;
if (isFirstRender) {
this.log.trace('First render');
}
const prevState = this.prevState;
this.prevState = state;
@ -340,7 +266,7 @@ export class CanvasManager {
if (!adapter) {
adapter = new CanvasLayerAdapter(entityState, this);
this.rasterLayerAdapters.set(adapter.id, adapter);
this.stage.add(adapter.konva.layer);
this.stage.addLayer(adapter.konva.layer);
}
await adapter.update({
state: entityState,
@ -371,7 +297,7 @@ export class CanvasManager {
if (!adapter) {
adapter = new CanvasLayerAdapter(entityState, this);
this.controlLayerAdapters.set(adapter.id, adapter);
this.stage.add(adapter.konva.layer);
this.stage.addLayer(adapter.konva.layer);
}
await adapter.update({
state: entityState,
@ -408,7 +334,7 @@ export class CanvasManager {
if (!adapter) {
adapter = new CanvasMaskAdapter(entityState, this);
this.regionalGuidanceAdapters.set(adapter.id, adapter);
this.stage.add(adapter.konva.layer);
this.stage.addLayer(adapter.konva.layer);
}
await adapter.update({
state: entityState,
@ -445,7 +371,7 @@ export class CanvasManager {
if (!adapter) {
adapter = new CanvasMaskAdapter(entityState, this);
this.inpaintMaskAdapters.set(adapter.id, adapter);
this.stage.add(adapter.konva.layer);
this.stage.addLayer(adapter.konva.layer);
}
await adapter.update({
state: entityState,
@ -488,23 +414,15 @@ export class CanvasManager {
};
initialize = () => {
this.log.debug('Initializing renderer');
this.stage.container(this.container);
this.log.debug('Initializing canvas manager');
const unsubscribeListeners = setStageEventHandlers(this);
// We can use a resize observer to ensure the stage always fits the container. We also need to re-render the bg and
// document bounds overlay when the stage is resized.
const resizeObserver = new ResizeObserver(this.fitStageToContainer.bind(this));
resizeObserver.observe(this.container);
this.fitStageToContainer();
const cleanupStage = this.stage.initialize();
const unsubscribeRenderer = this._store.subscribe(this.render);
this.log.debug('First render of konva stage');
return () => {
this.log.debug('Cleaning up konva renderer');
this.log.debug('Cleaning up canvas manager');
const allAdapters = [
...this.rasterLayerAdapters.values(),
...this.controlLayerAdapters.values(),
@ -518,96 +436,10 @@ export class CanvasManager {
this.preview.destroy();
unsubscribeRenderer();
unsubscribeListeners();
resizeObserver.disconnect();
cleanupStage();
};
};
/**
* Gets the center of the stage in either absolute or relative coordinates
* @param absolute Whether to return the center in absolute coordinates
*/
getStageCenter(absolute = false): Coordinate {
const scale = this.getStageScale();
const { x, y } = this.getStagePosition();
const { width, height } = this.getStageSize();
const center = {
x: (width / 2 - x) / scale,
y: (height / 2 - y) / scale,
};
if (!absolute) {
return center;
}
return this.stage.getAbsoluteTransform().point(center);
}
/**
* Sets the scale of the stage. If center is provided, the stage will zoom in/out on that point.
* @param scale The new scale to set
* @param center The center of the stage to zoom in/out on
*/
setStageScale(scale: number, center: Coordinate = this.getStageCenter(true)) {
const newScale = clamp(Math.round(scale * 100) / 100, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE);
const { x, y } = this.getStagePosition();
const oldScale = this.getStageScale();
const deltaX = (center.x - x) / oldScale;
const deltaY = (center.y - y) / oldScale;
const newX = center.x - deltaX * newScale;
const newY = center.y - deltaY * newScale;
this.stage.setAttrs({
x: newX,
y: newY,
scaleX: newScale,
scaleY: newScale,
});
this.stateApi.$stageAttrs.set({
x: Math.floor(this.stage.x()),
y: Math.floor(this.stage.y()),
width: this.stage.width(),
height: this.stage.height(),
scale: this.stage.scaleX(),
});
}
/**
* Gets the scale of the stage. The stage is always scaled uniformly in x and y.
*/
getStageScale(): number {
// The stage is never scaled differently in x and y
return this.stage.scaleX();
}
/**
* Gets the position of the stage.
*/
getStagePosition(): Coordinate {
return this.stage.position();
}
/**
* Gets the size of the stage.
*/
getStageSize(): Dimensions {
return this.stage.size();
}
/**
* Scales a number of pixels by the current stage scale. For example, if the stage is scaled by 5, then 10 pixels
* would be scaled to 10px / 5 = 2 pixels.
* @param pixels The number of pixels to scale
* @returns The number of pixels scaled by the current stage scale
*/
getScaledPixels(pixels: number): number {
return pixels / this.getStageScale();
}
clearCaches = () => {
this.canvasCache.clear();
this.imageNameCache.clear();
@ -820,13 +652,13 @@ export class CanvasManager {
return generationMode;
}
getLoggingContext = (): JSONObject => {
getLoggingContext = (): SerializableObject => {
return {
path: this.path.join('.'),
};
};
buildLogger = (getContext: () => JSONObject): Logger => {
buildLogger = (getContext: () => SerializableObject): Logger => {
return this.log.child((message) => {
return {
...message,

View File

@ -0,0 +1,225 @@
import type { SerializableObject } from 'common/types';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { getPrefixedId, getRectUnion } from 'features/controlLayers/konva/util';
import type { Coordinate, Dimensions, Rect } from 'features/controlLayers/store/types';
import type Konva from 'konva';
import { clamp } from 'lodash-es';
import type { Logger } from 'roarr';
export class CanvasStageModule {
static MIN_CANVAS_SCALE = 0.1;
static MAX_CANVAS_SCALE = 20;
id: string;
path: string[];
konva: { stage: Konva.Stage };
manager: CanvasManager;
container: HTMLDivElement;
log: Logger;
constructor(stage: Konva.Stage, container: HTMLDivElement, manager: CanvasManager) {
this.id = getPrefixedId('stage');
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating stage module');
this.container = container;
this.konva = { stage };
}
initialize = () => {
this.log.debug('Initializing stage');
this.konva.stage.container(this.container);
const resizeObserver = new ResizeObserver(this.fitStageToContainer);
resizeObserver.observe(this.container);
this.fitStageToContainer();
return () => {
this.log.debug('Destroying stage');
resizeObserver.disconnect();
this.konva.stage.destroy();
};
};
fitStageToContainer = () => {
this.log.trace('Fitting stage to container');
this.konva.stage.width(this.konva.stage.container().offsetWidth);
this.konva.stage.height(this.konva.stage.container().offsetHeight);
this.manager.stateApi.$stageAttrs.set({
x: this.konva.stage.x(),
y: this.konva.stage.y(),
width: this.konva.stage.width(),
height: this.konva.stage.height(),
scale: this.konva.stage.scaleX(),
});
};
getVisibleRect = (): Rect => {
const rects = [];
for (const adapter of this.manager.inpaintMaskAdapters.values()) {
if (adapter.state.isEnabled) {
rects.push(adapter.transformer.getRelativeRect());
}
}
for (const adapter of this.manager.rasterLayerAdapters.values()) {
if (adapter.state.isEnabled) {
rects.push(adapter.transformer.getRelativeRect());
}
}
for (const adapter of this.manager.controlLayerAdapters.values()) {
if (adapter.state.isEnabled) {
rects.push(adapter.transformer.getRelativeRect());
}
}
for (const adapter of this.manager.regionalGuidanceAdapters.values()) {
if (adapter.state.isEnabled) {
rects.push(adapter.transformer.getRelativeRect());
}
}
const rectUnion = getRectUnion(...rects);
if (rectUnion.width === 0 || rectUnion.height === 0) {
// fall back to the bbox if there is no content
return this.manager.stateApi.getBbox().rect;
} else {
return rectUnion;
}
};
resetView() {
this.log.trace('Resetting view');
const { width, height } = this.getSize();
const rect = this.getVisibleRect();
const padding = 20; // Padding in absolute pixels
const availableWidth = width - padding * 2;
const availableHeight = height - padding * 2;
const scale = Math.min(Math.min(availableWidth / rect.width, availableHeight / rect.height), 1);
const x = -rect.x * scale + padding + (availableWidth - rect.width * scale) / 2;
const y = -rect.y * scale + padding + (availableHeight - rect.height * scale) / 2;
this.konva.stage.setAttrs({
x,
y,
scaleX: scale,
scaleY: scale,
});
this.manager.stateApi.$stageAttrs.set({
...this.manager.stateApi.$stageAttrs.get(),
x,
y,
scale,
});
}
/**
* Gets the center of the stage in either absolute or relative coordinates
* @param absolute Whether to return the center in absolute coordinates
*/
getCenter = (absolute = false): Coordinate => {
const scale = this.getScale();
const { x, y } = this.getPosition();
const { width, height } = this.getSize();
const center = {
x: (width / 2 - x) / scale,
y: (height / 2 - y) / scale,
};
if (!absolute) {
return center;
}
return this.konva.stage.getAbsoluteTransform().point(center);
};
/**
* Sets the scale of the stage. If center is provided, the stage will zoom in/out on that point.
* @param scale The new scale to set
* @param center The center of the stage to zoom in/out on
*/
setScale = (scale: number, center: Coordinate = this.getCenter(true)) => {
this.log.trace('Setting scale');
const newScale = clamp(
Math.round(scale * 100) / 100,
CanvasStageModule.MIN_CANVAS_SCALE,
CanvasStageModule.MAX_CANVAS_SCALE
);
const { x, y } = this.getPosition();
const oldScale = this.getScale();
const deltaX = (center.x - x) / oldScale;
const deltaY = (center.y - y) / oldScale;
const newX = center.x - deltaX * newScale;
const newY = center.y - deltaY * newScale;
this.konva.stage.setAttrs({
x: newX,
y: newY,
scaleX: newScale,
scaleY: newScale,
});
this.manager.stateApi.$stageAttrs.set({
x: Math.floor(this.konva.stage.x()),
y: Math.floor(this.konva.stage.y()),
width: this.konva.stage.width(),
height: this.konva.stage.height(),
scale: this.konva.stage.scaleX(),
});
};
/**
* Gets the scale of the stage. The stage is always scaled uniformly in x and y.
*/
getScale = (): number => {
// The stage is never scaled differently in x and y
return this.konva.stage.scaleX();
};
/**
* Gets the position of the stage.
*/
getPosition = (): Coordinate => {
return this.konva.stage.position();
};
/**
* Gets the size of the stage.
*/
getSize(): Dimensions {
return this.konva.stage.size();
}
/**
* Scales a number of pixels by the current stage scale. For example, if the stage is scaled by 5, then 10 pixels
* would be scaled to 10px / 5 = 2 pixels.
* @param pixels The number of pixels to scale
* @returns The number of pixels scaled by the current stage scale
*/
getScaledPixels = (pixels: number): number => {
return pixels / this.getScale();
};
setIsDraggable = (isDraggable: boolean) => {
this.konva.stage.draggable(isDraggable);
};
addLayer = (layer: Konva.Layer) => {
this.konva.stage.add(layer);
};
getLoggingContext = (): SerializableObject => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@ -159,7 +159,7 @@ export class CanvasTool {
scaleTool = () => {
const toolState = this.manager.stateApi.getToolState();
const scale = this.manager.stage.scaleX();
const scale = this.manager.stage.getScale();
const brushRadius = toolState.brush.width / 2;
this.konva.brush.innerBorderCircle.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale);
@ -199,34 +199,34 @@ export class CanvasTool {
// Update the stage's pointer style
if (Boolean(this.manager.stateApi.$transformingEntity.get()) || renderedEntityCount === 0) {
// We are transforming and/or have no layers, so we should not render any tool
stage.container().style.cursor = 'default';
stage.container.style.cursor = 'default';
} else if (tool === 'view') {
// view tool gets a hand
stage.container().style.cursor = isMouseDown ? 'grabbing' : 'grab';
stage.container.style.cursor = isMouseDown ? 'grabbing' : 'grab';
// Bbox tool gets default
} else if (tool === 'bbox') {
stage.container().style.cursor = 'default';
stage.container.style.cursor = 'default';
} else if (tool === 'eyeDropper') {
// Eyedropper gets none
stage.container().style.cursor = 'none';
stage.container.style.cursor = 'none';
} else if (isDrawable) {
if (tool === 'move') {
// Move gets default arrow
stage.container().style.cursor = 'default';
stage.container.style.cursor = 'default';
} else if (tool === 'rect') {
// Rect gets a crosshair
stage.container().style.cursor = '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';
stage.container.style.cursor = 'none';
}
} else {
// isDrawable === 'false'
// Non-drawable layers don't have tools
stage.container().style.cursor = 'not-allowed';
stage.container.style.cursor = 'not-allowed';
}
stage.draggable(tool === 'view');
stage.setIsDraggable(tool === 'view');
if (!cursorPos || renderedEntityCount === 0 || !isDrawable) {
// We can bail early if the mouse isn't over the stage or there are no layers
@ -238,7 +238,7 @@ export class CanvasTool {
if (cursorPos && tool === 'brush') {
const brushPreviewFill = this.manager.stateApi.getBrushPreviewFill();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.brush.width);
const scale = stage.scaleX();
const scale = stage.getScale();
// Update the fill circle
const radius = toolState.brush.width / 2;
@ -261,7 +261,7 @@ export class CanvasTool {
} else if (cursorPos && tool === 'eraser') {
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width);
const scale = stage.scaleX();
const scale = stage.getScale();
// Update the fill circle
const radius = toolState.eraser.width / 2;
this.konva.eraser.fillCircle.setAttrs({

View File

@ -174,8 +174,8 @@ export class CanvasTransformer {
// We need to snap the anchor to the nearest pixel, but the positions provided to this callback are absolute,
// scaled coordinates. They need to be converted to stage coordinates, snapped, then converted back to absolute
// before returning them.
const stageScale = this.manager.getStageScale();
const stagePos = this.manager.getStagePosition();
const stageScale = this.manager.stage.getScale();
const stagePos = this.manager.stage.getPosition();
// Unscale and round the target position to the nearest pixel.
const targetX = Math.round(newPos.x / stageScale);
@ -335,8 +335,8 @@ export class CanvasTransformer {
// The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding
// and border
this.konva.outlineRect.setAttrs({
x: this.konva.proxyRect.x() - this.manager.getScaledPixels(CanvasTransformer.OUTLINE_PADDING),
y: this.konva.proxyRect.y() - this.manager.getScaledPixels(CanvasTransformer.OUTLINE_PADDING),
x: this.konva.proxyRect.x() - this.manager.stage.getScaledPixels(CanvasTransformer.OUTLINE_PADDING),
y: this.konva.proxyRect.y() - this.manager.stage.getScaledPixels(CanvasTransformer.OUTLINE_PADDING),
});
// The object group is translated by the difference between the interaction rect's new and old positions (which is
@ -407,8 +407,8 @@ export class CanvasTransformer {
* @param bbox The bounding box of the parent entity
*/
update = (position: Coordinate, bbox: Rect) => {
const onePixel = this.manager.getScaledPixels(1);
const bboxPadding = this.manager.getScaledPixels(CanvasTransformer.OUTLINE_PADDING);
const onePixel = this.manager.stage.getScaledPixels(1);
const bboxPadding = this.manager.stage.getScaledPixels(CanvasTransformer.OUTLINE_PADDING);
this.konva.outlineRect.setAttrs({
x: position.x + bbox.x - bboxPadding,
@ -474,8 +474,8 @@ export class CanvasTransformer {
* Updates the transformer's scale. This is called when the stage is scaled.
*/
syncScale = () => {
const onePixel = this.manager.getScaledPixels(1);
const bboxPadding = this.manager.getScaledPixels(CanvasTransformer.OUTLINE_PADDING);
const onePixel = this.manager.stage.getScaledPixels(1);
const bboxPadding = this.manager.stage.getScaledPixels(CanvasTransformer.OUTLINE_PADDING);
this.konva.outlineRect.setAttrs({
x: this.konva.proxyRect.x() - bboxPadding,

View File

@ -135,7 +135,7 @@ const getColorUnderCursor = (stage: Konva.Stage): RgbaColor | null => {
};
export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
const { stage, stateApi } = manager;
const stage = manager.stage.konva.stage;
const {
getToolState,
setTool,
@ -152,7 +152,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
setEraserWidth,
getCurrentFill,
getSelectedEntity,
} = stateApi;
} = manager.stateApi;
function getIsPrimaryMouseDown(e: KonvaEventObject<MouseEvent>) {
return e.evt.buttons === 1;
@ -496,8 +496,8 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (cursorPos) {
// When wheeling on trackpad, e.evt.ctrlKey is true - in that case, let's reverse the direction
const delta = e.evt.ctrlKey ? -e.evt.deltaY : e.evt.deltaY;
const scale = manager.getStageScale() * CANVAS_SCALE_BY ** delta;
manager.setStageScale(scale, cursorPos);
const scale = manager.stage.getScale() * CANVAS_SCALE_BY ** delta;
manager.stage.setScale(scale, cursorPos);
}
}
manager.preview.tool.render();