feat(ui): transformable layers

This commit is contained in:
psychedelicious 2024-07-03 15:56:29 +10:00
parent 6a10d31b19
commit 49371ddec9
10 changed files with 299 additions and 85 deletions

View File

@ -13,6 +13,7 @@ import type {
Rect, Rect,
RectShapeAddedArg, RectShapeAddedArg,
RgbaColor, RgbaColor,
ScaleChangedArg,
StageAttrs, StageAttrs,
Tool, Tool,
} from 'features/controlLayers/store/types'; } from 'features/controlLayers/store/types';
@ -63,6 +64,8 @@ export type StateApi = {
onBrushWidthChanged: (size: number) => void; onBrushWidthChanged: (size: number) => void;
onEraserWidthChanged: (size: number) => void; onEraserWidthChanged: (size: number) => void;
getMaskOpacity: () => number; getMaskOpacity: () => number;
getIsSelected: (id: string) => boolean;
onScaleChanged: (arg: ScaleChangedArg, entityType: CanvasEntity['type']) => void;
onPosChanged: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void; onPosChanged: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void;
onBboxTransformed: (bbox: Rect) => void; onBboxTransformed: (bbox: Rect) => void;
getShiftKey: () => boolean; getShiftKey: () => boolean;
@ -155,9 +158,8 @@ export class KonvaNodeManager {
this.controlAdapters = new Map(); this.controlAdapters = new Map();
} }
renderLayers() { async renderLayers() {
const { entities } = this.stateApi.getLayersState(); const { entities } = this.stateApi.getLayersState();
const toolState = this.stateApi.getToolState();
for (const canvasLayer of this.layers.values()) { for (const canvasLayer of this.layers.values()) {
if (!entities.find((l) => l.id === canvasLayer.id)) { if (!entities.find((l) => l.id === canvasLayer.id)) {
@ -169,11 +171,11 @@ export class KonvaNodeManager {
for (const entity of entities) { for (const entity of entities) {
let adapter = this.layers.get(entity.id); let adapter = this.layers.get(entity.id);
if (!adapter) { if (!adapter) {
adapter = new CanvasLayer(entity, this.stateApi.onPosChanged); adapter = new CanvasLayer(entity, this);
this.layers.set(adapter.id, adapter); this.layers.set(adapter.id, adapter);
this.stage.add(adapter.layer); this.stage.add(adapter.layer);
} }
adapter.render(entity, toolState.selected); await adapter.render(entity);
} }
} }

View File

@ -43,8 +43,7 @@ export class CanvasControlAdapter {
const filters = entity.filter === 'LightnessToAlphaFilter' ? [LightnessToAlphaFilter] : []; const filters = entity.filter === 'LightnessToAlphaFilter' ? [LightnessToAlphaFilter] : [];
if (!this.image) { if (!this.image) {
this.image = await new KonvaImage({ this.image = await new KonvaImage(imageObject, {
imageObject,
onLoad: (konvaImage) => { onLoad: (konvaImage) => {
konvaImage.filters(filters); konvaImage.filters(filters);
konvaImage.cache(); konvaImage.cache();

View File

@ -79,7 +79,7 @@ export class CanvasInpaintMask {
assert(brushLine instanceof KonvaBrushLine || brushLine === undefined); assert(brushLine instanceof KonvaBrushLine || brushLine === undefined);
if (!brushLine) { if (!brushLine) {
brushLine = new KonvaBrushLine({ brushLine: obj }); brushLine = new KonvaBrushLine(obj);
this.objects.set(brushLine.id, brushLine); this.objects.set(brushLine.id, brushLine);
this.group.add(brushLine.konvaLineGroup); this.group.add(brushLine.konvaLineGroup);
groupNeedsCache = true; groupNeedsCache = true;
@ -94,7 +94,7 @@ export class CanvasInpaintMask {
assert(eraserLine instanceof KonvaEraserLine || eraserLine === undefined); assert(eraserLine instanceof KonvaEraserLine || eraserLine === undefined);
if (!eraserLine) { if (!eraserLine) {
eraserLine = new KonvaEraserLine({ eraserLine: obj }); eraserLine = new KonvaEraserLine(obj);
this.objects.set(eraserLine.id, eraserLine); this.objects.set(eraserLine.id, eraserLine);
this.group.add(eraserLine.konvaLineGroup); this.group.add(eraserLine.konvaLineGroup);
groupNeedsCache = true; groupNeedsCache = true;
@ -109,7 +109,7 @@ export class CanvasInpaintMask {
assert(rect instanceof KonvaRect || rect === undefined); assert(rect instanceof KonvaRect || rect === undefined);
if (!rect) { if (!rect) {
rect = new KonvaRect({ rectShape: obj }); rect = new KonvaRect(obj);
this.objects.set(rect.id, rect); this.objects.set(rect.id, rect);
this.group.add(rect.konvaRect); this.group.add(rect.konvaRect);
groupNeedsCache = true; groupNeedsCache = true;
@ -129,7 +129,6 @@ export class CanvasInpaintMask {
return; return;
} }
// We must clear the cache first so Konva will re-draw the group with the new compositing rect // We must clear the cache first so Konva will re-draw the group with the new compositing rect
if (this.group.isCached()) { if (this.group.isCached()) {
this.group.clearCache(); this.group.clearCache();

View File

@ -1,38 +1,53 @@
import { getObjectGroupId } from 'features/controlLayers/konva/naming'; import { getObjectGroupId } from 'features/controlLayers/konva/naming';
import type { StateApi } from 'features/controlLayers/konva/nodeManager'; import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
import { KonvaBrushLine, KonvaEraserLine, KonvaImage, KonvaRect } from 'features/controlLayers/konva/renderers/objects'; import { KonvaBrushLine, KonvaEraserLine, KonvaImage, KonvaRect } from 'features/controlLayers/konva/renderers/objects';
import { mapId } from 'features/controlLayers/konva/util'; import { mapId } from 'features/controlLayers/konva/util';
import type { LayerEntity, Tool } from 'features/controlLayers/store/types'; import { isDrawingTool, type LayerEntity } from 'features/controlLayers/store/types';
import Konva from 'konva'; import Konva from 'konva';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export class CanvasLayer { export class CanvasLayer {
id: string; id: string;
manager: KonvaNodeManager;
layer: Konva.Layer; layer: Konva.Layer;
group: Konva.Group; group: Konva.Group;
transformer: Konva.Transformer;
objects: Map<string, KonvaBrushLine | KonvaEraserLine | KonvaRect | KonvaImage>; objects: Map<string, KonvaBrushLine | KonvaEraserLine | KonvaRect | KonvaImage>;
constructor(entity: LayerEntity, onPosChanged: StateApi['onPosChanged']) { constructor(entity: LayerEntity, manager: KonvaNodeManager) {
this.id = entity.id; this.id = entity.id;
this.manager = manager;
this.layer = new Konva.Layer({ this.layer = new Konva.Layer({
id: entity.id, id: entity.id,
draggable: true, listening: false,
dragDistance: 0,
}); });
// When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing this.group = new Konva.Group({
// the position - we do not need to call this on the `dragmove` event.
this.layer.on('dragend', function (e) {
onPosChanged({ id: entity.id, x: Math.floor(e.target.x()), y: Math.floor(e.target.y()) }, 'layer');
});
const group = new Konva.Group({
id: getObjectGroupId(this.layer.id(), uuidv4()), id: getObjectGroupId(this.layer.id(), uuidv4()),
listening: false, listening: false,
}); });
this.group = group;
this.layer.add(this.group); this.layer.add(this.group);
this.transformer = new Konva.Transformer({
shouldOverdrawWholeArea: true,
draggable: true,
dragDistance: 0,
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
rotateEnabled: false,
flipEnabled: false,
});
this.transformer.on('transformend', () => {
this.manager.stateApi.onScaleChanged(
{ id: this.id, scale: this.group.scaleX(), x: this.group.x(), y: this.group.y() },
'layer'
);
});
this.transformer.on('dragend', () => {
this.manager.stateApi.onPosChanged({ id: this.id, x: this.group.x(), y: this.group.y() }, 'layer');
});
this.layer.add(this.transformer);
this.objects = new Map(); this.objects = new Map();
} }
@ -40,20 +55,24 @@ export class CanvasLayer {
this.layer.destroy(); this.layer.destroy();
} }
async render(layerState: LayerEntity, selectedTool: Tool) { async render(layerState: LayerEntity) {
// Update the layer's position and listening state // Update the layer's position and listening state
this.layer.setAttrs({ this.group.setAttrs({
listening: selectedTool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events x: layerState.x,
x: Math.floor(layerState.x), y: layerState.y,
y: Math.floor(layerState.y), scaleX: 1,
scaleY: 1,
}); });
let didDraw = false;
const objectIds = layerState.objects.map(mapId); const objectIds = layerState.objects.map(mapId);
// Destroy any objects that are no longer in state // Destroy any objects that are no longer in state
for (const object of this.objects.values()) { for (const object of this.objects.values()) {
if (!objectIds.includes(object.id)) { if (!objectIds.includes(object.id)) {
this.objects.delete(object.id); this.objects.delete(object.id);
object.destroy(); object.destroy();
didDraw = true;
} }
} }
@ -63,45 +82,60 @@ export class CanvasLayer {
assert(brushLine instanceof KonvaBrushLine || brushLine === undefined); assert(brushLine instanceof KonvaBrushLine || brushLine === undefined);
if (!brushLine) { if (!brushLine) {
brushLine = new KonvaBrushLine({ brushLine: obj }); brushLine = new KonvaBrushLine(obj);
this.objects.set(brushLine.id, brushLine); this.objects.set(brushLine.id, brushLine);
this.group.add(brushLine.konvaLineGroup); this.group.add(brushLine.konvaLineGroup);
didDraw = true;
} else {
if (brushLine.update(obj)) {
didDraw = true;
} }
if (obj.points.length !== brushLine.konvaLine.points().length) {
brushLine.konvaLine.points(obj.points);
} }
} else if (obj.type === 'eraser_line') { } else if (obj.type === 'eraser_line') {
let eraserLine = this.objects.get(obj.id); let eraserLine = this.objects.get(obj.id);
assert(eraserLine instanceof KonvaEraserLine || eraserLine === undefined); assert(eraserLine instanceof KonvaEraserLine || eraserLine === undefined);
if (!eraserLine) { if (!eraserLine) {
eraserLine = new KonvaEraserLine({ eraserLine: obj }); eraserLine = new KonvaEraserLine(obj);
this.objects.set(eraserLine.id, eraserLine); this.objects.set(eraserLine.id, eraserLine);
this.group.add(eraserLine.konvaLineGroup); this.group.add(eraserLine.konvaLineGroup);
didDraw = true;
} else {
if (eraserLine.update(obj)) {
didDraw = true;
} }
if (obj.points.length !== eraserLine.konvaLine.points().length) {
eraserLine.konvaLine.points(obj.points);
} }
} else if (obj.type === 'rect_shape') { } else if (obj.type === 'rect_shape') {
let rect = this.objects.get(obj.id); let rect = this.objects.get(obj.id);
assert(rect instanceof KonvaRect || rect === undefined); assert(rect instanceof KonvaRect || rect === undefined);
if (!rect) { if (!rect) {
rect = new KonvaRect({ rectShape: obj }); rect = new KonvaRect(obj);
this.objects.set(rect.id, rect); this.objects.set(rect.id, rect);
this.group.add(rect.konvaRect); this.group.add(rect.konvaRect);
didDraw = true;
} else {
if (rect.update(obj)) {
didDraw = true;
}
} }
} else if (obj.type === 'image') { } else if (obj.type === 'image') {
let image = this.objects.get(obj.id); let image = this.objects.get(obj.id);
assert(image instanceof KonvaImage || image === undefined); assert(image instanceof KonvaImage || image === undefined);
if (!image) { if (!image) {
image = await new KonvaImage({ imageObject: obj }); image = await new KonvaImage(obj, {
onLoad: () => {
this.updateGroup(true);
},
});
this.objects.set(image.id, image); this.objects.set(image.id, image);
this.group.add(image.konvaImageGroup); this.group.add(image.konvaImageGroup);
await image.updateImageSource(obj.image.name);
} else {
if (await image.update(obj)) {
didDraw = true;
} }
if (image.imageName !== obj.image.name) {
image.updateImageSource(obj.image.name);
} }
} }
} }
@ -111,22 +145,71 @@ export class CanvasLayer {
this.layer.visible(layerState.isEnabled); this.layer.visible(layerState.isEnabled);
} }
// const bboxRect = konvaLayer.findOne<Konva.Rect>(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(layerState, konvaLayer);
// if (layerState.bbox) {
// const active = !layerState.bboxNeedsUpdate && layerState.isSelected && tool === 'move';
// bboxRect.setAttrs({
// visible: active,
// listening: active,
// x: layerState.bbox.x,
// y: layerState.bbox.y,
// width: layerState.bbox.width,
// height: layerState.bbox.height,
// stroke: layerState.isSelected ? BBOX_SELECTED_STROKE : '',
// strokeWidth: 1 / stage.scaleX(),
// });
// } else {
// bboxRect.visible(false);
// }
this.group.opacity(layerState.opacity); this.group.opacity(layerState.opacity);
// The layer only listens when using the move tool - otherwise the stage is handling mouse events
this.updateGroup(didDraw);
}
updateGroup(didDraw: boolean) {
const isSelected = this.manager.stateApi.getIsSelected(this.id);
const selectedTool = this.manager.stateApi.getToolState().selected;
if (this.objects.size === 0) {
// If the layer is totally empty, reset the cache and bail out.
this.layer.listening(false);
this.transformer.nodes([]);
if (this.group.isCached()) {
this.group.clearCache();
}
return;
}
if (isSelected && selectedTool === 'move') {
// When the layer is selected and being moved, we should always cache it.
// We should update the cache if we drew to the layer.
if (!this.group.isCached() || didDraw) {
this.group.cache();
}
// Activate the transformer
this.layer.listening(true);
this.transformer.nodes([this.group]);
this.transformer.forceUpdate();
return;
}
if (isSelected && selectedTool !== 'move') {
// If the layer is selected but not using the move tool, we don't want the layer to be listening.
this.layer.listening(false);
// The transformer also does not need to be active.
this.transformer.nodes([]);
if (isDrawingTool(selectedTool)) {
// We are using a drawing tool (brush, eraser, rect). These tools change the layer's rendered appearance, so we
// should never be cached.
if (this.group.isCached()) {
this.group.clearCache();
}
} else {
// We are using a non-drawing tool (move, view, bbox), so we should cache the layer.
// We should update the cache if we drew to the layer.
if (!this.group.isCached() || didDraw) {
this.group.cache();
}
}
return;
}
if (!isSelected) {
// Unselected layers should not be listening
this.layer.listening(false);
// The transformer also does not need to be active.
this.transformer.nodes([]);
// Update the layer's cache if it's not already cached or we drew to it.
if (!this.group.isCached() || didDraw) {
this.group.cache();
}
return;
}
} }
} }

View File

@ -27,10 +27,10 @@ export class KonvaBrushLine {
id: string; id: string;
konvaLineGroup: Konva.Group; konvaLineGroup: Konva.Group;
konvaLine: Konva.Line; konvaLine: Konva.Line;
lastBrushLine: BrushLine;
constructor(arg: { brushLine: BrushLine }) { constructor(brushLine: BrushLine) {
const { brushLine } = arg; const { id, strokeWidth, clip, color, points } = brushLine;
const { id, strokeWidth, clip, color } = brushLine;
this.id = id; this.id = id;
this.konvaLineGroup = new Konva.Group({ this.konvaLineGroup = new Konva.Group({
clip, clip,
@ -46,8 +46,26 @@ export class KonvaBrushLine {
lineJoin: 'round', lineJoin: 'round',
globalCompositeOperation: 'source-over', globalCompositeOperation: 'source-over',
stroke: rgbaColorToString(color), stroke: rgbaColorToString(color),
points,
}); });
this.konvaLineGroup.add(this.konvaLine); this.konvaLineGroup.add(this.konvaLine);
this.lastBrushLine = brushLine;
}
update(brushLine: BrushLine, force?: boolean): boolean {
if (this.lastBrushLine !== brushLine || force) {
const { points, color, clip, strokeWidth } = brushLine;
this.konvaLine.setAttrs({
points,
stroke: rgbaColorToString(color),
clip,
strokeWidth,
});
this.lastBrushLine = brushLine;
return true;
} else {
return false;
}
} }
destroy() { destroy() {
@ -59,10 +77,10 @@ export class KonvaEraserLine {
id: string; id: string;
konvaLineGroup: Konva.Group; konvaLineGroup: Konva.Group;
konvaLine: Konva.Line; konvaLine: Konva.Line;
lastEraserLine: EraserLine;
constructor(arg: { eraserLine: EraserLine }) { constructor(eraserLine: EraserLine) {
const { eraserLine } = arg; const { id, strokeWidth, clip, points } = eraserLine;
const { id, strokeWidth, clip } = eraserLine;
this.id = id; this.id = id;
this.konvaLineGroup = new Konva.Group({ this.konvaLineGroup = new Konva.Group({
clip, clip,
@ -78,8 +96,25 @@ export class KonvaEraserLine {
lineJoin: 'round', lineJoin: 'round',
globalCompositeOperation: 'destination-out', globalCompositeOperation: 'destination-out',
stroke: rgbaColorToString(RGBA_RED), stroke: rgbaColorToString(RGBA_RED),
points,
}); });
this.konvaLineGroup.add(this.konvaLine); this.konvaLineGroup.add(this.konvaLine);
this.lastEraserLine = eraserLine;
}
update(eraserLine: EraserLine, force?: boolean): boolean {
if (this.lastEraserLine !== eraserLine || force) {
const { points, clip, strokeWidth } = eraserLine;
this.konvaLine.setAttrs({
points,
clip,
strokeWidth,
});
this.lastEraserLine = eraserLine;
return true;
} else {
return false;
}
} }
destroy() { destroy() {
@ -90,9 +125,9 @@ export class KonvaEraserLine {
export class KonvaRect { export class KonvaRect {
id: string; id: string;
konvaRect: Konva.Rect; konvaRect: Konva.Rect;
lastRectShape: RectShape;
constructor(arg: { rectShape: RectShape }) { constructor(rectShape: RectShape) {
const { rectShape } = arg;
const { id, x, y, width, height } = rectShape; const { id, x, y, width, height } = rectShape;
this.id = id; this.id = id;
const konvaRect = new Konva.Rect({ const konvaRect = new Konva.Rect({
@ -105,6 +140,24 @@ export class KonvaRect {
fill: rgbaColorToString(rectShape.color), fill: rgbaColorToString(rectShape.color),
}); });
this.konvaRect = konvaRect; this.konvaRect = konvaRect;
this.lastRectShape = rectShape;
}
update(rectShape: RectShape, force?: boolean): boolean {
if (this.lastRectShape !== rectShape || force) {
const { x, y, width, height, color } = rectShape;
this.konvaRect.setAttrs({
x,
y,
width,
height,
fill: rgbaColorToString(color),
});
this.lastRectShape = rectShape;
return true;
} else {
return false;
}
} }
destroy() { destroy() {
@ -126,15 +179,18 @@ export class KonvaImage {
onLoading: () => void; onLoading: () => void;
onLoad: (imageName: string, imageEl: HTMLImageElement) => void; onLoad: (imageName: string, imageEl: HTMLImageElement) => void;
onError: () => void; onError: () => void;
lastImageObject: ImageObject;
constructor(arg: { constructor(
imageObject: ImageObject; imageObject: ImageObject,
options: {
getImageDTO?: (imageName: string) => Promise<ImageDTO | null>; getImageDTO?: (imageName: string) => Promise<ImageDTO | null>;
onLoading?: () => void; onLoading?: () => void;
onLoad?: (konvaImage: Konva.Image) => void; onLoad?: (konvaImage: Konva.Image) => void;
onError?: () => void; onError?: () => void;
}) { }
const { imageObject, getImageDTO, onLoading, onLoad, onError } = arg; ) {
const { getImageDTO, onLoading, onLoad, onError } = options;
const { id, width, height, x, y } = imageObject; const { id, width, height, x, y } = imageObject;
this.konvaImageGroup = new Konva.Group({ id, listening: false, x, y }); this.konvaImageGroup = new Konva.Group({ id, listening: false, x, y });
this.konvaPlaceholderGroup = new Konva.Group({ listening: false }); this.konvaPlaceholderGroup = new Konva.Group({ listening: false });
@ -188,6 +244,8 @@ export class KonvaImage {
id: this.id, id: this.id,
listening: false, listening: false,
image: imageEl, image: imageEl,
width,
height,
}); });
this.konvaImageGroup.add(this.konvaImage); this.konvaImageGroup.add(this.konvaImage);
} }
@ -213,6 +271,7 @@ export class KonvaImage {
onError(); onError();
} }
}; };
this.lastImageObject = imageObject;
} }
async updateImageSource(imageName: string) { async updateImageSource(imageName: string) {
@ -238,6 +297,22 @@ export class KonvaImage {
} }
} }
async update(imageObject: ImageObject, force?: boolean): Promise<boolean> {
if (this.lastImageObject !== imageObject || force) {
const { width, height, x, y, image } = imageObject;
if (this.lastImageObject.image.name !== image.name || force) {
await this.updateImageSource(image.name);
}
this.konvaImage?.setAttrs({ x, y, width, height });
this.konvaPlaceholderRect.setAttrs({ width, height });
this.konvaPlaceholderText.setAttrs({ width, height, fontSize: width / 16 });
this.lastImageObject = imageObject;
return true;
} else {
return false;
}
}
destroy() { destroy() {
this.konvaImageGroup.destroy(); this.konvaImageGroup.destroy();
} }

View File

@ -79,7 +79,7 @@ export class CanvasRegion {
assert(brushLine instanceof KonvaBrushLine || brushLine === undefined); assert(brushLine instanceof KonvaBrushLine || brushLine === undefined);
if (!brushLine) { if (!brushLine) {
brushLine = new KonvaBrushLine({ brushLine: obj }); brushLine = new KonvaBrushLine(obj);
this.objects.set(brushLine.id, brushLine); this.objects.set(brushLine.id, brushLine);
this.group.add(brushLine.konvaLineGroup); this.group.add(brushLine.konvaLineGroup);
groupNeedsCache = true; groupNeedsCache = true;
@ -94,7 +94,7 @@ export class CanvasRegion {
assert(eraserLine instanceof KonvaEraserLine || eraserLine === undefined); assert(eraserLine instanceof KonvaEraserLine || eraserLine === undefined);
if (!eraserLine) { if (!eraserLine) {
eraserLine = new KonvaEraserLine({ eraserLine: obj }); eraserLine = new KonvaEraserLine(obj);
this.objects.set(eraserLine.id, eraserLine); this.objects.set(eraserLine.id, eraserLine);
this.group.add(eraserLine.konvaLineGroup); this.group.add(eraserLine.konvaLineGroup);
groupNeedsCache = true; groupNeedsCache = true;
@ -109,7 +109,7 @@ export class CanvasRegion {
assert(rect instanceof KonvaRect || rect === undefined); assert(rect instanceof KonvaRect || rect === undefined);
if (!rect) { if (!rect) {
rect = new KonvaRect({ rectShape: obj }); rect = new KonvaRect(obj);
this.objects.set(rect.id, rect); this.objects.set(rect.id, rect);
this.group.add(rect.konvaRect); this.group.add(rect.konvaRect);
groupNeedsCache = true; groupNeedsCache = true;

View File

@ -28,6 +28,7 @@ import {
layerImageCacheChanged, layerImageCacheChanged,
layerLinePointAdded, layerLinePointAdded,
layerRectAdded, layerRectAdded,
layerScaled,
layerTranslated, layerTranslated,
rgBboxChanged, rgBboxChanged,
rgBrushLineAdded, rgBrushLineAdded,
@ -48,6 +49,7 @@ import type {
PointAddedToLineArg, PointAddedToLineArg,
PosChangedArg, PosChangedArg,
RectShapeAddedArg, RectShapeAddedArg,
ScaleChangedArg,
Tool, Tool,
} from 'features/controlLayers/store/types'; } from 'features/controlLayers/store/types';
import type Konva from 'konva'; import type Konva from 'konva';
@ -90,7 +92,7 @@ export const initializeRenderer = (
// Set up callbacks for various events // Set up callbacks for various events
const onPosChanged = (arg: PosChangedArg, entityType: CanvasEntity['type']) => { const onPosChanged = (arg: PosChangedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Position changed'); logIfDebugging('onPosChanged');
if (entityType === 'layer') { if (entityType === 'layer') {
dispatch(layerTranslated(arg)); dispatch(layerTranslated(arg));
} else if (entityType === 'control_adapter') { } else if (entityType === 'control_adapter') {
@ -101,6 +103,12 @@ export const initializeRenderer = (
dispatch(imTranslated(arg)); dispatch(imTranslated(arg));
} }
}; };
const onScaleChanged = (arg: ScaleChangedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('onScaleChanged');
if (entityType === 'layer') {
dispatch(layerScaled(arg));
}
};
const onBboxChanged = (arg: BboxChangedArg, entityType: CanvasEntity['type']) => { const onBboxChanged = (arg: BboxChangedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Entity bbox changed'); logIfDebugging('Entity bbox changed');
if (entityType === 'layer') { if (entityType === 'layer') {
@ -249,7 +257,12 @@ export const initializeRenderer = (
const getInpaintMaskState = () => canvasV2.inpaintMask; const getInpaintMaskState = () => canvasV2.inpaintMask;
const getMaskOpacity = () => canvasV2.settings.maskOpacity; const getMaskOpacity = () => canvasV2.settings.maskOpacity;
const getStagingAreaState = () => canvasV2.stagingArea; const getStagingAreaState = () => canvasV2.stagingArea;
const getIsSelected = (id: string) => getSelectedEntity()?.id === id;
// Read-only state, derived from nanostores
const resetLastProgressEvent = () => {
$lastProgressEvent.set(null);
};
// Read-write state, ephemeral interaction state // Read-write state, ephemeral interaction state
let isDrawing = false; let isDrawing = false;
const getIsDrawing = () => isDrawing; const getIsDrawing = () => isDrawing;
@ -307,9 +320,8 @@ export const initializeRenderer = (
getStagingAreaState, getStagingAreaState,
getShouldShowStagedImage: $shouldShowStagedImage.get, getShouldShowStagedImage: $shouldShowStagedImage.get,
getLastProgressEvent: $lastProgressEvent.get, getLastProgressEvent: $lastProgressEvent.get,
resetLastProgressEvent: () => { resetLastProgressEvent,
$lastProgressEvent.set(null); getIsSelected,
},
// Read-write state // Read-write state
setTool, setTool,
@ -340,6 +352,7 @@ export const initializeRenderer = (
onRegionMaskImageCached, onRegionMaskImageCached,
onInpaintMaskImageCached, onInpaintMaskImageCached,
onLayerImageCached, onLayerImageCached,
onScaleChanged,
}; };
const manager = new KonvaNodeManager(stage, container, stateApi); const manager = new KonvaNodeManager(stage, container, stateApi);
@ -367,7 +380,8 @@ export const initializeRenderer = (
if ( if (
isFirstRender || isFirstRender ||
canvasV2.layers.entities !== prevCanvasV2.layers.entities || canvasV2.layers.entities !== prevCanvasV2.layers.entities ||
canvasV2.tool.selected !== prevCanvasV2.tool.selected canvasV2.tool.selected !== prevCanvasV2.tool.selected ||
prevSelectedEntity?.id !== selectedEntity?.id
) { ) {
logIfDebugging('Rendering layers'); logIfDebugging('Rendering layers');
manager.renderLayers(); manager.renderLayers();
@ -377,7 +391,8 @@ export const initializeRenderer = (
isFirstRender || isFirstRender ||
canvasV2.regions.entities !== prevCanvasV2.regions.entities || canvasV2.regions.entities !== prevCanvasV2.regions.entities ||
canvasV2.settings.maskOpacity !== prevCanvasV2.settings.maskOpacity || canvasV2.settings.maskOpacity !== prevCanvasV2.settings.maskOpacity ||
canvasV2.tool.selected !== prevCanvasV2.tool.selected canvasV2.tool.selected !== prevCanvasV2.tool.selected ||
prevSelectedEntity?.id !== selectedEntity?.id
) { ) {
logIfDebugging('Rendering regions'); logIfDebugging('Rendering regions');
manager.renderRegions(); manager.renderRegions();
@ -387,13 +402,18 @@ export const initializeRenderer = (
isFirstRender || isFirstRender ||
canvasV2.inpaintMask !== prevCanvasV2.inpaintMask || canvasV2.inpaintMask !== prevCanvasV2.inpaintMask ||
canvasV2.settings.maskOpacity !== prevCanvasV2.settings.maskOpacity || canvasV2.settings.maskOpacity !== prevCanvasV2.settings.maskOpacity ||
canvasV2.tool.selected !== prevCanvasV2.tool.selected canvasV2.tool.selected !== prevCanvasV2.tool.selected ||
prevSelectedEntity?.id !== selectedEntity?.id
) { ) {
logIfDebugging('Rendering inpaint mask'); logIfDebugging('Rendering inpaint mask');
manager.renderInpaintMask(); manager.renderInpaintMask();
} }
if (isFirstRender || canvasV2.controlAdapters.entities !== prevCanvasV2.controlAdapters.entities) { if (
isFirstRender ||
canvasV2.controlAdapters.entities !== prevCanvasV2.controlAdapters.entities ||
prevSelectedEntity?.id !== selectedEntity?.id
) {
logIfDebugging('Rendering control adapters'); logIfDebugging('Rendering control adapters');
manager.renderControlAdapters(); manager.renderControlAdapters();
} }
@ -427,7 +447,8 @@ export const initializeRenderer = (
isFirstRender || isFirstRender ||
canvasV2.layers.entities !== prevCanvasV2.layers.entities || canvasV2.layers.entities !== prevCanvasV2.layers.entities ||
canvasV2.controlAdapters.entities !== prevCanvasV2.controlAdapters.entities || canvasV2.controlAdapters.entities !== prevCanvasV2.controlAdapters.entities ||
canvasV2.regions.entities !== prevCanvasV2.regions.entities canvasV2.regions.entities !== prevCanvasV2.regions.entities ||
prevSelectedEntity?.id !== selectedEntity?.id
) { ) {
logIfDebugging('Arranging entities'); logIfDebugging('Arranging entities');
manager.arrangeEntities(); manager.arrangeEntities();

View File

@ -215,6 +215,7 @@ export const {
layerImageAdded, layerImageAdded,
layerAllDeleted, layerAllDeleted,
layerImageCacheChanged, layerImageCacheChanged,
layerScaled,
// IP Adapters // IP Adapters
ipaAdded, ipaAdded,
ipaRecalled, ipaRecalled,

View File

@ -172,6 +172,36 @@ export const layersReducers = {
payload: { ...payload, lineId: uuidv4() }, payload: { ...payload, lineId: uuidv4() },
}), }),
}, },
layerScaled: (state, action: PayloadAction<{ id: string; scale: number; x: number; y: number }>) => {
const { id, scale, x, y } = action.payload;
const layer = selectLayer(state, id);
if (!layer) {
return;
}
for (const obj of layer.objects) {
if (obj.type === 'brush_line') {
obj.points = obj.points.map((point) => point * scale);
obj.strokeWidth *= scale;
} else if (obj.type === 'eraser_line') {
obj.points = obj.points.map((point) => point * scale);
obj.strokeWidth *= scale;
} else if (obj.type === 'rect_shape') {
obj.x *= scale;
obj.y *= scale;
obj.height *= scale;
obj.width *= scale;
} else if (obj.type === 'image') {
obj.x *= scale;
obj.y *= scale;
obj.height *= scale;
obj.width *= scale;
}
}
layer.x = x;
layer.y = y;
layer.bboxNeedsUpdate = true;
state.layers.imageCache = null;
},
layerEraserLineAdded: { layerEraserLineAdded: {
reducer: (state, action: PayloadAction<EraserLineAddedArg & { lineId: string }>) => { reducer: (state, action: PayloadAction<EraserLineAddedArg & { lineId: string }>) => {
const { id, points, lineId, width, clip } = action.payload; const { id, points, lineId, width, clip } = action.payload;

View File

@ -462,6 +462,9 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
const zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox']); const zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox']);
export type Tool = z.infer<typeof zTool>; export type Tool = z.infer<typeof zTool>;
export function isDrawingTool(tool: Tool): tool is 'brush' | 'eraser' | 'rect' {
return tool === 'brush' || tool === 'eraser' || tool === 'rect';
}
const zDrawingTool = zTool.extract(['brush', 'eraser']); const zDrawingTool = zTool.extract(['brush', 'eraser']);
@ -891,6 +894,7 @@ export type CanvasV2State = {
export type StageAttrs = { x: number; y: number; width: number; height: number; scale: number }; export type StageAttrs = { x: number; y: number; width: number; height: number; scale: number };
export type PosChangedArg = { id: string; x: number; y: number }; export type PosChangedArg = { id: string; x: number; y: number };
export type ScaleChangedArg = { id: string; scale: number; x: number; y: number };
export type BboxChangedArg = { id: string; bbox: Rect | null }; export type BboxChangedArg = { id: string; bbox: Rect | null };
export type EraserLineAddedArg = { export type EraserLineAddedArg = {
id: string; id: string;