docs(ui): docstrings for classes (wip)

This commit is contained in:
psychedelicious
2024-09-01 12:49:13 +10:00
parent 1a51842277
commit 3942e2a501
7 changed files with 511 additions and 267 deletions

View File

@@ -15,6 +15,11 @@ const DEFAULT_CONFIG: CanvasBackgroundModuleConfig = {
GRID_LINE_COLOR_FINE: getArbitraryBaseColor(18),
};
/**
* Renders a background grid on the canvas, where the grid spacing changes based on the stage scale.
*
* The grid is only visible when the dynamic grid setting is enabled.
*/
export class CanvasBackgroundModule extends CanvasModuleBase {
readonly type = 'background';
@@ -22,13 +27,19 @@ export class CanvasBackgroundModule extends CanvasModuleBase {
path: string[];
parent: CanvasManager;
manager: CanvasManager;
subscriptions = new Set<() => void>();
log: Logger;
subscriptions = new Set<() => void>();
config: CanvasBackgroundModuleConfig = DEFAULT_CONFIG;
/**
* The Konva objects that make up the background grid:
* - A layer to hold the grid lines
* - An array of grid lines
*/
konva: {
layer: Konva.Layer;
lines: Konva.Line[];
};
constructor(manager: CanvasManager) {
@@ -41,11 +52,20 @@ export class CanvasBackgroundModule extends CanvasModuleBase {
this.log.debug('Creating module');
this.konva = { layer: new Konva.Layer({ name: `${this.type}:layer`, listening: false }) };
this.konva = { layer: new Konva.Layer({ name: `${this.type}:layer`, listening: false }), lines: [] };
/**
* The background grid should be rendered when the stage attributes change:
* - scale
* - position
* - size
*/
this.subscriptions.add(this.manager.stateApi.$stageAttrs.listen(this.render));
}
/**
* Renders the background grid.
*/
render = () => {
const settings = this.manager.stateApi.getSettings();
@@ -56,11 +76,10 @@ export class CanvasBackgroundModule extends CanvasModuleBase {
this.konva.layer.visible(true);
this.konva.layer.zIndex(0);
const scale = this.manager.stage.getScale();
const { x, y } = this.manager.stage.getPosition();
const { width, height } = this.manager.stage.getSize();
const gridSpacing = this.getGridSpacing(scale);
const gridSpacing = CanvasBackgroundModule.getGridSpacing(scale);
const stageRect = {
x1: 0,
y1: 0,
@@ -99,41 +118,44 @@ export class CanvasBackgroundModule extends CanvasModuleBase {
let _y = 0;
this.konva.layer.destroyChildren();
this.konva.lines = [];
for (let i = 0; i < xSteps; i++) {
_x = gridFullRect.x1 + i * gridSpacing;
this.konva.layer.add(
new Konva.Line({
x: _x,
y: gridFullRect.y1,
points: [0, 0, 0, ySize],
stroke: _x % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE,
strokeWidth,
listening: false,
})
);
const line = new Konva.Line({
x: _x,
y: gridFullRect.y1,
points: [0, 0, 0, ySize],
stroke: _x % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE,
strokeWidth,
listening: false,
});
this.konva.lines.push(line);
this.konva.layer.add(line);
}
for (let i = 0; i < ySteps; i++) {
_y = gridFullRect.y1 + i * gridSpacing;
this.konva.layer.add(
new Konva.Line({
x: gridFullRect.x1,
y: _y,
points: [0, 0, xSize, 0],
stroke: _y % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE,
strokeWidth,
listening: false,
})
);
const line = new Konva.Line({
x: gridFullRect.x1,
y: _y,
points: [0, 0, xSize, 0],
stroke: _y % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE,
strokeWidth,
listening: false,
});
this.konva.lines.push(line);
this.konva.layer.add(line);
}
};
/**
* Gets the grid spacing. The value depends on the stage scale - at higher scales, the grid spacing is smaller.
* Gets the grid line spacing for the dynamic grid.
*
* The value depends on the stage scale - at higher scales, the grid spacing is smaller.
*
* @param scale The stage scale
* @returns The grid spacing based on the stage scale
*/
getGridSpacing = (scale: number): number => {
static getGridSpacing = (scale: number): number => {
if (scale >= 2) {
return 8;
}

View File

@@ -2,7 +2,7 @@ import { roundToMultiple, roundToMultipleMin } from 'common/util/roundDownToMult
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { Rect } from 'features/controlLayers/store/types';
import type { Coordinate, Rect } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
@@ -21,6 +21,9 @@ const ALL_ANCHORS: string[] = [
const CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
const NO_ANCHORS: string[] = [];
/**
* Renders the bounding box. The bounding box can be transformed by the user.
*/
export class CanvasBboxModule extends CanvasModuleBase {
readonly type = 'bbox';
@@ -32,12 +35,24 @@ export class CanvasBboxModule extends CanvasModuleBase {
subscriptions: Set<() => void> = new Set();
/**
* The Konva objects that make up the bbox:
* - A group to hold all the objects
* - A transformer to allow the bbox to be transformed
* - A transparent rect so the transformer has something to transform
*/
konva: {
group: Konva.Group;
rect: Konva.Rect;
transformer: Konva.Transformer;
proxyRect: Konva.Rect;
};
/**
* Buffer to store the last aspect ratio of the bbox. When the users holds shift while transforming the bbox, this is
* used to lock the aspect ratio.
*/
$aspectRatioBuffer = atom(0);
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
@@ -48,16 +63,15 @@ export class CanvasBboxModule extends CanvasModuleBase {
this.log.debug('Creating bbox module');
// Create a stash to hold onto the last aspect ratio of the bbox - this allows for locking the aspect ratio when
// transforming the bbox.
// Set the initial aspect ratio buffer per app state.
const bbox = this.manager.stateApi.getBbox();
const $aspectRatioBuffer = atom(bbox.rect.width / bbox.rect.height);
this.$aspectRatioBuffer.set(bbox.rect.width / bbox.rect.height);
this.konva = {
group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
// Use a transformer for the generation bbox. Transformers need some shape to transform, we will use a fully
// transparent rect for this purpose.
rect: new Konva.Rect({
group: new Konva.Group({ name: `${this.type}:group`, listening: true }),
// We will use a Konva.Transformer for the generation bbox. Transformers need some shape to transform, so we will
// create a transparent rect for this purpose.
proxyRect: new Konva.Rect({
name: `${this.type}:rect`,
listening: false,
strokeEnabled: false,
@@ -83,173 +97,43 @@ export class CanvasBboxModule extends CanvasModuleBase {
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.konva.transformer.getStage();
assert(stage, 'Stage must exist');
// We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid.
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
// Because we are working in absolute coordinates, we need to scale the grid size by the stage scale.
const scaledGridSize = gridSize * stage.scaleX();
// To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position.
const stageAbsPos = stage.getAbsolutePosition();
// The offset is the remainder of the stage's absolute position divided by the scaled grid size.
const offsetX = stageAbsPos.x % scaledGridSize;
const offsetY = stageAbsPos.y % scaledGridSize;
// Finally, calculate the position by rounding to the grid and adding the offset.
return {
x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX,
y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY,
};
},
anchorStyleFunc: this.anchorStyleFunc,
anchorDragBoundFunc: this.anchorDragBoundFunc,
}),
};
this.konva.rect.on('dragmove', () => {
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
const bbox = this.manager.stateApi.getBbox();
const bboxRect: Rect = {
...bbox.rect,
x: roundToMultiple(this.konva.rect.x(), gridSize),
y: roundToMultiple(this.konva.rect.y(), gridSize),
};
this.konva.rect.setAttrs(bboxRect);
if (bbox.rect.x !== bboxRect.x || bbox.rect.y !== bboxRect.y) {
this.manager.stateApi.setGenerationBbox(bboxRect);
}
});
this.konva.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.konva.transformer.getActiveAnchor();
if (!anchor) {
// Pretty sure we should always have an anchor here?
return;
}
this.konva.proxyRect.on('dragmove', this.onDragMove);
this.konva.transformer.on('transform', this.onTransform);
this.konva.transformer.on('transformend', this.onTransformEnd);
const alt = this.manager.stateApi.$altKey.get();
const ctrl = this.manager.stateApi.$ctrlKey.get();
const meta = this.manager.stateApi.$metaKey.get();
const shift = this.manager.stateApi.$shiftKey.get();
// Grid size depends on the modifier keys
let gridSize = ctrl || meta ? 8 : 64;
// Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the
// new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if
// we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes.
// Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid.
if (this.manager.stateApi.$altKey.get()) {
gridSize = gridSize * 2;
}
// The coords should be correct per the anchorDragBoundFunc.
let x = this.konva.rect.x();
let y = this.konva.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.konva.rect.width() * this.konva.rect.scaleX(), gridSize);
let height = roundToMultipleMin(this.konva.rect.height() * this.konva.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 && 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 bboxRect = {
x: Math.round(x),
y: Math.round(y),
width,
height,
};
// Update the bboxRect's attrs directly with the new transform, and reset its scale to 1.
// 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.konva.rect.setAttrs({ ...bboxRect, scaleX: 1, scaleY: 1 });
// Update the bbox in internal state.
this.manager.stateApi.setGenerationBbox(bboxRect);
// Update the aspect ratio buffer whenever the shift key is not held - this allows for a nice UX where you can start
// a transform, get the right aspect ratio, then hold shift to lock it in.
if (!shift) {
$aspectRatioBuffer.set(bboxRect.width / bboxRect.height);
}
});
this.konva.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.konva.rect.width() / this.konva.rect.height());
});
// The transformer will always be transforming the dummy rect
this.konva.transformer.nodes([this.konva.rect]);
this.konva.group.add(this.konva.rect);
// The transformer will always be transforming the proxy rect
this.konva.transformer.nodes([this.konva.proxyRect]);
this.konva.group.add(this.konva.proxyRect);
this.konva.group.add(this.konva.transformer);
// We will listen to the tool state to determine if the bbox should be visible or not.
this.subscriptions.add(this.manager.stateApi.$tool.listen(this.render));
}
/**
* Renders the bbox. The bbox is only visible when the tool is set to 'bbox'.
*/
render = () => {
this.log.trace('Rendering bbox module');
this.log.trace('Rendering');
const bbox = this.manager.stateApi.getBbox();
const { x, y, width, height } = this.manager.stateApi.getBbox().rect;
const tool = this.manager.stateApi.$tool.get();
this.konva.group.visible(true);
// We need to reach up to the preview layer to enable/disable listening so that the bbox can be interacted with.
this.manager.konva.previewLayer.listening(tool === 'bbox');
this.konva.group.listening(tool === 'bbox');
this.konva.rect.setAttrs({
x: bbox.rect.x,
y: bbox.rect.y,
width: bbox.rect.width,
height: bbox.rect.height,
this.konva.proxyRect.setAttrs({
x,
y,
width,
height,
scaleX: 1,
scaleY: 1,
listening: tool === 'bbox',
@@ -261,8 +145,173 @@ export class CanvasBboxModule extends CanvasModuleBase {
};
destroy = () => {
this.log.trace('Destroying bbox module');
this.log.trace('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.group.destroy();
};
/**
* Handles the dragmove event on the bbox rect:
* - Snaps the bbox position to the grid (determined by ctrl/meta key)
* - Pushes the new bbox rect into app state
*/
onDragMove = () => {
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
const bbox = this.manager.stateApi.getBbox();
const bboxRect: Rect = {
...bbox.rect,
x: roundToMultiple(this.konva.proxyRect.x(), gridSize),
y: roundToMultiple(this.konva.proxyRect.y(), gridSize),
};
this.konva.proxyRect.setAttrs(bboxRect);
if (bbox.rect.x !== bboxRect.x || bbox.rect.y !== bboxRect.y) {
this.manager.stateApi.setGenerationBbox(bboxRect);
}
};
/**
* Handles the transform event on the bbox transformer:
* - Snaps the bbox dimensions to the grid (determined by ctrl/meta key)
* - Centered scaling when alt is held
* - Aspect ratio locking when shift is held
* - Pushes the new bbox rect into app state
* - Syncs the aspect ratio buffer
*/
onTransform = () => {
// In the transform callback, we calculate the bbox's new dims and pos and update the konva object.
// Some special handling is needed depending on the anchor being dragged.
const anchor = this.konva.transformer.getActiveAnchor();
if (!anchor) {
// Pretty sure we should always have an anchor here?
return;
}
const alt = this.manager.stateApi.$altKey.get();
const ctrl = this.manager.stateApi.$ctrlKey.get();
const meta = this.manager.stateApi.$metaKey.get();
const shift = this.manager.stateApi.$shiftKey.get();
// Grid size depends on the modifier keys
let gridSize = ctrl || meta ? 8 : 64;
// Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the
// new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if
// we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes.
// Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid.
if (this.manager.stateApi.$altKey.get()) {
gridSize = gridSize * 2;
}
// The coords should be correct per the anchorDragBoundFunc.
let x = this.konva.proxyRect.x();
let y = this.konva.proxyRect.y();
// Konva transforms by scaling the dims, not directly changing width and height. At this point, the width and height
// *have not changed*, only the scale has changed. To get the final height, we need to scale the dims and then snap
// them to the grid.
let width = roundToMultipleMin(this.konva.proxyRect.width() * this.konva.proxyRect.scaleX(), gridSize);
let height = roundToMultipleMin(this.konva.proxyRect.height() * this.konva.proxyRect.scaleY(), gridSize);
// If shift is held and we are resizing from a corner, retain aspect ratio - needs special handling. We skip this
// if alt/opt is held - this requires math too big for my brain.
if (shift && CORNER_ANCHORS.includes(anchor) && !alt) {
// Fit the bbox to the last aspect ratio
let fittedWidth = Math.sqrt(width * height * this.$aspectRatioBuffer.get());
let fittedHeight = fittedWidth / this.$aspectRatioBuffer.get();
fittedWidth = roundToMultipleMin(fittedWidth, gridSize);
fittedHeight = roundToMultipleMin(fittedHeight, gridSize);
// We need to adjust the x and y coords to have the resize occur from the right origin.
if (anchor === 'top-left') {
// The transform origin is the bottom-right anchor. Both x and y need to be updated.
x = x - (fittedWidth - width);
y = y - (fittedHeight - height);
}
if (anchor === 'top-right') {
// The transform origin is the bottom-left anchor. Only y needs to be updated.
y = y - (fittedHeight - height);
}
if (anchor === 'bottom-left') {
// The transform origin is the top-right anchor. Only x needs to be updated.
x = x - (fittedWidth - width);
}
// Update the width and height to the fitted dims.
width = fittedWidth;
height = fittedHeight;
}
const bboxRect = {
x: Math.round(x),
y: Math.round(y),
width,
height,
};
// Update the bboxRect's attrs directly with the new transform, and reset its scale to 1.
this.konva.proxyRect.setAttrs({ ...bboxRect, scaleX: 1, scaleY: 1 });
// Update the bbox in internal state.
this.manager.stateApi.setGenerationBbox(bboxRect);
// Update the aspect ratio buffer whenever the shift key is not held - this allows for a nice UX where you can start
// a transform, get the right aspect ratio, then hold shift to lock it in.
if (!shift) {
this.$aspectRatioBuffer.set(bboxRect.width / bboxRect.height);
}
};
/**
* Handles the transformend event on the bbox transformer:
* - Updates the aspect ratio buffer with the new aspect ratio
*/
onTransformEnd = () => {
// Always update the aspect ratio buffer when the transform ends, so if the next transform starts with shift held,
// we have the correct aspect ratio to start from.
this.$aspectRatioBuffer.set(this.konva.proxyRect.width() / this.konva.proxyRect.height());
};
/**
* This function is called for each anchor on the transformer. It sets the style of the anchor based on its name.
* We make the x/y resize anchors little bars.
*/
anchorStyleFunc = (anchor: Konva.Rect): void => {
if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) {
anchor.height(8);
anchor.offsetY(4);
anchor.width(30);
anchor.offsetX(15);
}
if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) {
anchor.height(30);
anchor.offsetY(15);
anchor.width(8);
anchor.offsetX(4);
}
};
/**
* This function is called for each anchor on the transformer. It sets the drag bounds for the anchor based on the
* stage's position and the grid size. Care is taken to ensure the anchor snaps to the grid correctly.
*/
anchorDragBoundFunc = (oldAbsPos: Coordinate, newAbsPos: Coordinate): Coordinate => {
// This function works with absolute position - that is, a position in "physical" pixels on the screen, as opposed
// to konva's internal coordinate system.
const stage = this.konva.transformer.getStage();
assert(stage, 'Stage must exist');
// We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid.
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
// Because we are working in absolute coordinates, we need to scale the grid size by the stage scale.
const scaledGridSize = gridSize * stage.scaleX();
// To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position.
const stageAbsPos = stage.getAbsolutePosition();
// The offset is the remainder of the stage's absolute position divided by the scaled grid size.
const offsetX = stageAbsPos.x % scaledGridSize;
const offsetY = stageAbsPos.y % scaledGridSize;
// Finally, calculate the position by rounding to the grid and adding the offset.
return {
x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX,
y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY,
};
};
}

View File

@@ -22,6 +22,9 @@ const DEFAULT_CONFIG: BrushToolPreviewConfig = {
BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)',
};
/**
* Renders a preview of the brush tool on the canvas.
*/
export class CanvasBrushToolPreview extends CanvasModuleBase {
readonly type = 'brush_tool_preview';
@@ -33,6 +36,13 @@ export class CanvasBrushToolPreview extends CanvasModuleBase {
config: BrushToolPreviewConfig = DEFAULT_CONFIG;
/**
* The Konva objects that make up the brush tool preview:
* - A group to hold the fill circle and borders
* - A circle to fill the brush area
* - An inner border ring
* - An outer border ring
*/
konva: {
group: Konva.Group;
fillCircle: Konva.Circle;
@@ -80,6 +90,7 @@ export class CanvasBrushToolPreview extends CanvasModuleBase {
render = () => {
const cursorPos = this.manager.stateApi.$lastCursorPos.get();
// If the cursor position is not available, do not update the brush preview. The tool module will handle visiblity.
if (!cursorPos) {
return;
}
@@ -129,7 +140,7 @@ export class CanvasBrushToolPreview extends CanvasModuleBase {
};
destroy = () => {
this.log.debug('Destroying brush tool preview module');
this.log.debug('Destroying module');
this.konva.group.destroy();
};
}

View File

@@ -26,6 +26,10 @@ const DEFAULT_CONFIG: CanvasCacheModuleConfig = {
generationModeCacheSize: 100,
};
/**
* A cache module for storing the results of expensive calculations. For example, when we rasterize a layer and upload
* it to the server, we store the resultant image name in this cache for future use.
*/
export class CanvasCacheModule extends CanvasModuleBase {
readonly type = 'cache';
@@ -37,8 +41,28 @@ export class CanvasCacheModule extends CanvasModuleBase {
config: CanvasCacheModuleConfig = DEFAULT_CONFIG;
/**
* A cache for storing image names. Used as a cache for results of layer/canvas/entity exports. For example, when we
* rasterize a layer and upload it to the server, we store the image name in this cache.
*
* The cache key is a hash of the exported entity's state and the export rect.
*/
imageNameCache = new LRUCache<string, string>({ max: this.config.imageNameCacheSize });
/**
* A cache for storing canvas elements. Similar to the image name cache, but for canvas elements. The primary use is
* for caching composite layers. For example, the canvas compositor module uses this to store the canvas elements for
* individual raster layers when creating a composite of the layers.
*
* The cache key is a hash of the exported entity's state and the export rect.
*/
canvasElementCache = new LRUCache<string, HTMLCanvasElement>({ max: this.config.canvasElementCacheSize });
/**
* A cache for the generation mode calculation, which is fairly expensive.
*
* The cache key is a hash of all the objects that contribute to the generation mode calculation (e.g. the composite
* raster layer, the composite inpaint mask, and bounding box), and the value is the generation mode.
*/
generationModeCache = new LRUCache<string, GenerationMode>({ max: this.config.generationModeCacheSize });
constructor(manager: CanvasManager) {
@@ -52,6 +76,9 @@ export class CanvasCacheModule extends CanvasModuleBase {
this.log.debug('Creating cache module');
}
/**
* Clears all caches.
*/
clearAll = () => {
this.canvasElementCache.clear();
this.imageNameCache.clear();

View File

@@ -63,6 +63,9 @@ const DEFAULT_CONFIG: ColorPickerToolConfig = {
CROSSHAIR_BORDER_COLOR: 'rgba(255,255,255,0.8)',
};
/**
* Renders a preview of the color picker tool on the canvas.
*/
export class CanvasColorPickerToolPreview extends CanvasModuleBase {
readonly type = 'color_picker_tool_preview';
@@ -74,10 +77,16 @@ export class CanvasColorPickerToolPreview extends CanvasModuleBase {
config: ColorPickerToolConfig = DEFAULT_CONFIG;
/**
* The Konva objects that make up the color picker tool preview:
* - A group to hold all the objects
* - A ring that shows the candidate and current color
* - A crosshair to help with color selection
*/
konva: {
group: Konva.Group;
ringNewColor: Konva.Ring;
ringOldColor: Konva.Arc;
ringCandidateColor: Konva.Ring;
ringCurrentColor: Konva.Arc;
ringInnerBorder: Konva.Ring;
ringOuterBorder: Konva.Ring;
crosshairNorthInner: Konva.Line;
@@ -98,18 +107,18 @@ export class CanvasColorPickerToolPreview extends CanvasModuleBase {
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating color picker tool preview module');
this.log.debug('Creating module');
this.konva = {
group: new Konva.Group({ name: `${this.type}:color_picker_group`, listening: false }),
ringNewColor: new Konva.Ring({
name: `${this.type}:color_picker_new_color_ring`,
ringCandidateColor: new Konva.Ring({
name: `${this.type}:color_picker_candidate_color_ring`,
innerRadius: 0,
outerRadius: 0,
strokeEnabled: false,
}),
ringOldColor: new Konva.Arc({
name: `${this.type}:color_picker_old_color_arc`,
ringCurrentColor: new Konva.Arc({
name: `${this.type}:color_picker_current_color_arc`,
innerRadius: 0,
outerRadius: 0,
angle: 180,
@@ -164,8 +173,8 @@ export class CanvasColorPickerToolPreview extends CanvasModuleBase {
};
this.konva.group.add(
this.konva.ringNewColor,
this.konva.ringOldColor,
this.konva.ringCandidateColor,
this.konva.ringCurrentColor,
this.konva.ringInnerBorder,
this.konva.ringOuterBorder,
this.konva.crosshairNorthOuter,
@@ -179,9 +188,13 @@ export class CanvasColorPickerToolPreview extends CanvasModuleBase {
);
}
/**
* Renders the color picker tool preview on the canvas.
*/
render = () => {
const cursorPos = this.manager.stateApi.$lastCursorPos.get();
// If the cursor position is not available, do not render the preview. The tool module will handle visibility.
if (!cursorPos) {
return;
}
@@ -193,14 +206,14 @@ export class CanvasColorPickerToolPreview extends CanvasModuleBase {
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
this.konva.ringNewColor.setAttrs({
this.konva.ringCandidateColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(colorUnderCursor),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.ringOldColor.setAttrs({
this.konva.ringCurrentColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(toolState.fill),

View File

@@ -15,6 +15,12 @@ import type { ImageDTO } from 'services/api/types';
import stableHash from 'stable-hash';
import { assert } from 'tsafe';
/**
* Handles compositing operations:
* - Rasterizing and uploading the composite raster layer
* - Rasterizing and uploading the composite inpaint mask
* - Caclulating the generation mode (which requires the composite raster layer and inpaint mask)
*/
export class CanvasCompositorModule extends CanvasModuleBase {
readonly type = 'compositor';
@@ -34,6 +40,11 @@ export class CanvasCompositorModule extends CanvasModuleBase {
this.log.debug('Creating compositor module');
}
/**
* Gets the entity IDs of all raster layers that should be included in the composite raster layer.
* A raster layer is included if it is enabled and has objects.
* @returns An array of raster layer entity IDs
*/
getCompositeRasterLayerEntityIds = (): string[] => {
const ids = [];
for (const adapter of this.manager.adapters.rasterLayers.values()) {
@@ -44,16 +55,35 @@ export class CanvasCompositorModule extends CanvasModuleBase {
return ids;
};
getCompositeInpaintMaskEntityIds = (): string[] => {
const ids = [];
for (const adapter of this.manager.adapters.inpaintMasks.values()) {
if (adapter.state.isEnabled && adapter.renderer.hasObjects()) {
ids.push(adapter.id);
/**
* Gets a hash of the composite raster layer, which includes the state of all raster layers that are included in the
* composite plus arbitrary extra data that should contribute to the hash (e.g. a rect).
* @param extra Any extra data to include in the hash
* @returns A hash for the composite raster layer
*/
getCompositeRasterLayerHash = (extra: SerializableObject): string => {
const data: Record<string, SerializableObject> = {
extra,
};
for (const id of this.getCompositeRasterLayerEntityIds()) {
const adapter = this.manager.adapters.rasterLayers.get(id);
if (!adapter) {
this.log.warn({ id }, 'Raster layer adapter not found');
continue;
}
data[id] = adapter.getHashableState();
}
return ids;
return stableHash(data);
};
/**
* Gets a canvas element for the composite raster layer. Only the region defined by the rect is included in the canvas.
*
* If the hash of the composite raster layer is found in the cache, the cached canvas is returned.
*
* @param rect The region to include in the canvas
* @returns A canvas element with the composite raster layer drawn on it
*/
getCompositeRasterLayerCanvas = (rect: Rect): HTMLCanvasElement => {
const hash = this.getCompositeRasterLayerHash({ rect });
const cachedCanvas = this.manager.cache.canvasElementCache.get(hash);
@@ -86,6 +116,99 @@ export class CanvasCompositorModule extends CanvasModuleBase {
return canvas;
};
/**
* Rasterizes the composite raster layer and uploads it to the server.
*
* If the hash of the composite raster layer is found in the cache, the cached image DTO is returned.
*
* @param rect The region to include in the rasterized image
* @param saveToGallery Whether to save the image to the gallery or just return the uploaded image DTO
* @returns A promise that resolves to the uploaded image DTO
*/
rasterizeAndUploadCompositeRasterLayer = async (rect: Rect, saveToGallery: boolean): Promise<ImageDTO> => {
this.log.trace({ rect }, 'Rasterizing composite raster layer');
const canvas = this.getCompositeRasterLayerCanvas(rect);
const blob = await canvasToBlob(canvas);
if (this.manager._isDebugging) {
previewBlob(blob, 'Composite raster layer canvas');
}
return uploadImage(blob, 'composite-raster-layer.png', 'general', !saveToGallery);
};
/**
* Gets the image DTO for the composite raster layer.
*
* If the image is found in the cache, the cached image DTO is returned.
*
* @param rect The region to include in the image
* @returns A promise that resolves to the image DTO
*/
getCompositeRasterLayerImageDTO = async (rect: Rect): Promise<ImageDTO> => {
let imageDTO: ImageDTO | null = null;
const hash = this.getCompositeRasterLayerHash({ rect });
const cachedImageName = this.manager.cache.imageNameCache.get(hash);
if (cachedImageName) {
imageDTO = await getImageDTO(cachedImageName);
if (imageDTO) {
this.log.trace({ rect, imageName: cachedImageName, imageDTO }, 'Using cached composite raster layer image');
return imageDTO;
}
}
imageDTO = await this.rasterizeAndUploadCompositeRasterLayer(rect, false);
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
return imageDTO;
};
/**
* Gets the entity IDs of all inpaint masks that should be included in the composite inpaint mask.
* An inpaint mask is included if it is enabled and has objects.
* @returns An array of inpaint mask entity IDs
*/
getCompositeInpaintMaskEntityIds = (): string[] => {
const ids = [];
for (const adapter of this.manager.adapters.inpaintMasks.values()) {
if (adapter.state.isEnabled && adapter.renderer.hasObjects()) {
ids.push(adapter.id);
}
}
return ids;
};
/**
* Gets a hash of the composite inpaint mask, which includes the state of all inpaint masks that are included in the
* composite plus arbitrary extra data that should contribute to the hash (e.g. a rect).
* @param extra Any extra data to include in the hash
* @returns A hash for the composite inpaint mask
*/
getCompositeInpaintMaskHash = (extra: SerializableObject): string => {
const data: Record<string, SerializableObject> = {
extra,
};
for (const id of this.getCompositeInpaintMaskEntityIds()) {
const adapter = this.manager.adapters.inpaintMasks.get(id);
if (!adapter) {
this.log.warn({ id }, 'Inpaint mask adapter not found');
continue;
}
data[id] = adapter.getHashableState();
}
return stableHash(data);
};
/**
* Gets a canvas element for the composite inpaint mask. Only the region defined by the rect is included in the canvas.
*
* If the hash of the composite inpaint mask is found in the cache, the cached canvas is returned.
*
* @param rect The region to include in the canvas
* @returns A canvas element with the composite inpaint mask drawn on it
*/
getCompositeInpaintMaskCanvas = (rect: Rect): HTMLCanvasElement => {
const hash = this.getCompositeInpaintMaskHash({ rect });
const cachedCanvas = this.manager.cache.canvasElementCache.get(hash);
@@ -118,68 +241,15 @@ export class CanvasCompositorModule extends CanvasModuleBase {
return canvas;
};
getCompositeRasterLayerHash = (extra: SerializableObject): string => {
const data: Record<string, SerializableObject> = {
extra,
};
for (const id of this.getCompositeRasterLayerEntityIds()) {
const adapter = this.manager.adapters.rasterLayers.get(id);
if (!adapter) {
this.log.warn({ id }, 'Raster layer adapter not found');
continue;
}
data[id] = adapter.getHashableState();
}
return stableHash(data);
};
getCompositeInpaintMaskHash = (extra: SerializableObject): string => {
const data: Record<string, SerializableObject> = {
extra,
};
for (const id of this.getCompositeInpaintMaskEntityIds()) {
const adapter = this.manager.adapters.inpaintMasks.get(id);
if (!adapter) {
this.log.warn({ id }, 'Inpaint mask adapter not found');
continue;
}
data[id] = adapter.getHashableState();
}
return stableHash(data);
};
rasterizeAndUploadCompositeRasterLayer = async (rect: Rect, saveToGallery: boolean) => {
this.log.trace({ rect }, 'Rasterizing composite raster layer');
const canvas = this.getCompositeRasterLayerCanvas(rect);
const blob = await canvasToBlob(canvas);
if (this.manager._isDebugging) {
previewBlob(blob, 'Composite raster layer canvas');
}
return uploadImage(blob, 'composite-raster-layer.png', 'general', !saveToGallery);
};
getCompositeRasterLayerImageDTO = async (rect: Rect): Promise<ImageDTO> => {
let imageDTO: ImageDTO | null = null;
const hash = this.getCompositeRasterLayerHash({ rect });
const cachedImageName = this.manager.cache.imageNameCache.get(hash);
if (cachedImageName) {
imageDTO = await getImageDTO(cachedImageName);
if (imageDTO) {
this.log.trace({ rect, imageName: cachedImageName, imageDTO }, 'Using cached composite raster layer image');
return imageDTO;
}
}
imageDTO = await this.rasterizeAndUploadCompositeRasterLayer(rect, false);
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
return imageDTO;
};
/**
* Rasterizes the composite inpaint mask and uploads it to the server.
*
* If the hash of the composite inpaint mask is found in the cache, the cached image DTO is returned.
*
* @param rect The region to include in the rasterized image
* @param saveToGallery Whether to save the image to the gallery or just return the uploaded image DTO
* @returns A promise that resolves to the uploaded image DTO
*/
rasterizeAndUploadCompositeInpaintMask = async (rect: Rect, saveToGallery: boolean) => {
this.log.trace({ rect }, 'Rasterizing composite inpaint mask');
@@ -192,6 +262,14 @@ export class CanvasCompositorModule extends CanvasModuleBase {
return uploadImage(blob, 'composite-inpaint-mask.png', 'general', !saveToGallery);
};
/**
* Gets the image DTO for the composite inpaint mask.
*
* If the image is found in the cache, the cached image DTO is returned.
*
* @param rect The region to include in the image
* @returns A promise that resolves to the image DTO
*/
getCompositeInpaintMaskImageDTO = async (rect: Rect): Promise<ImageDTO> => {
let imageDTO: ImageDTO | null = null;
@@ -211,6 +289,24 @@ export class CanvasCompositorModule extends CanvasModuleBase {
return imageDTO;
};
/**
* Calculates the generation mode for the current canvas state. This is determined by the transparency of the
* composite raster layer and composite inpaint mask:
* - Composite raster layer is fully transparent -> txt2img
* - Composite raster layer is partially transparent -> outpainting
* - Composite raster layer is opaque & composite inpaint mask is fully transparent -> img2img
* - Composite raster layer is opaque & composite inpaint mask is partially transparent -> inpainting
*
* Definitions:
* - Fully transparent: all pixels have an alpha value of 0.
* - Partially transparent: at least one pixel with an alpha value of 0 & at least one pixel with an alpha value
* greater than 0.
* - Opaque: all pixels have an alpha value greater than 0.
*
* The generation mode is cached to avoid recalculating it when the canvas state has not changed.
*
* @returns The generation mode
*/
getGenerationMode(): GenerationMode {
const { rect } = this.manager.stateApi.getBbox();

View File

@@ -22,6 +22,15 @@ import type { Logger } from 'roarr';
import stableHash from 'stable-hash';
import { assert } from 'tsafe';
/**
* Handles the rendering for a single raster or control layer entity.
*
* This module has two main components:
* - A transformer, which handles the positioning and interaction state of the layer
* - A renderer, which handles the rendering of the layer's objects
*
* The canvas rendering module interacts with this module to coordinate the rendering of all raster and control layers.
*/
export class CanvasEntityLayerAdapter extends CanvasModuleBase {
readonly type = 'entity_layer_adapter';
@@ -31,12 +40,29 @@ export class CanvasEntityLayerAdapter extends CanvasModuleBase {
parent: CanvasManager;
log: Logger;
/**
* The last known state of the entity.
*/
state: CanvasRasterLayerState | CanvasControlLayerState;
/**
* The Konva nodes that make up the entity layer:
* - A layer to hold the everything
*
* Note that the transformer and object renderer have their own Konva nodes, but they are not stored here.
*/
konva: {
layer: Konva.Layer;
};
/**
* The transformer for this entity layer.
*/
transformer: CanvasEntityTransformer;
/**
* The renderer for this entity layer.
*/
renderer: CanvasEntityRenderer;
isFirstRender: boolean = true;