mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
587 lines
20 KiB
TypeScript
587 lines
20 KiB
TypeScript
import { $authToken } from 'app/store/nanostores/authToken';
|
|
import { rgbColorToString } from 'common/util/colorCodeTransformers';
|
|
import { SyncableMap } from 'common/util/SyncableMap/SyncableMap';
|
|
import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types';
|
|
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
|
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
|
import { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine';
|
|
import { CanvasObjectBrushLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure';
|
|
import { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLine';
|
|
import { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure';
|
|
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
|
|
import { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect';
|
|
import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types';
|
|
import { LightnessToAlphaFilter } from 'features/controlLayers/konva/filters';
|
|
import { getPatternSVG } from 'features/controlLayers/konva/patterns/getPatternSVG';
|
|
import {
|
|
getKonvaNodeDebugAttrs,
|
|
getPrefixedId,
|
|
konvaNodeToBlob,
|
|
konvaNodeToCanvas,
|
|
konvaNodeToImageData,
|
|
previewBlob,
|
|
} from 'features/controlLayers/konva/util';
|
|
import type { Rect } from 'features/controlLayers/store/types';
|
|
import { imageDTOToImageObject } from 'features/controlLayers/store/util';
|
|
import Konva from 'konva';
|
|
import type { GroupConfig } from 'konva/lib/Group';
|
|
import { throttle } from 'lodash-es';
|
|
import type { Logger } from 'roarr';
|
|
import { serializeError } from 'serialize-error';
|
|
import { getImageDTOSafe, uploadImage } from 'services/api/endpoints/images';
|
|
import type { ImageDTO } from 'services/api/types';
|
|
import { assert } from 'tsafe';
|
|
|
|
function setFillPatternImage(shape: Konva.Shape, ...args: Parameters<typeof getPatternSVG>): HTMLImageElement {
|
|
const imageElement = new Image();
|
|
imageElement.onload = () => {
|
|
shape.fillPatternImage(imageElement);
|
|
};
|
|
imageElement.crossOrigin = $authToken.get() ? 'use-credentials' : 'anonymous';
|
|
imageElement.src = getPatternSVG(...args);
|
|
return imageElement;
|
|
}
|
|
|
|
/**
|
|
* Handles rendering of objects for a canvas entity.
|
|
*/
|
|
export class CanvasEntityObjectRenderer extends CanvasModuleBase {
|
|
readonly type = 'object_renderer';
|
|
readonly id: string;
|
|
readonly path: string[];
|
|
readonly parent: CanvasEntityAdapter;
|
|
readonly manager: CanvasManager;
|
|
readonly log: Logger;
|
|
|
|
/**
|
|
* A set of subscriptions that should be cleaned up when the transformer is destroyed.
|
|
*/
|
|
subscriptions: Set<() => void> = new Set();
|
|
|
|
/**
|
|
* A map of object renderers, keyed by their ID.
|
|
*
|
|
* This map can be used with React.useSyncExternalStore to sync the object renderers with a React component.
|
|
*/
|
|
renderers = new SyncableMap<string, AnyObjectRenderer>();
|
|
|
|
/**
|
|
* A object containing singleton Konva nodes.
|
|
*/
|
|
konva: {
|
|
/**
|
|
* A Konva Group that holds all the object renderers.
|
|
*/
|
|
objectGroup: Konva.Group;
|
|
/**
|
|
* The compositing rect is used to draw the inpaint mask as a single shape with a given opacity.
|
|
*
|
|
* When drawing multiple transparent shapes on a canvas, overlapping regions will be more opaque. This doesn't
|
|
* match the expectation for a mask, where all shapes should have the same opacity, even if they overlap.
|
|
*
|
|
* To prevent this, we use a trick. Instead of drawing all shapes at the desired opacity, we draw them at opacity of 1.
|
|
* Then we draw a single rect that covers the entire canvas at the desired opacity, with a globalCompositeOperation
|
|
* of 'source-in'. The shapes effectively become a mask for the "compositing rect".
|
|
*
|
|
* This node is only added when the parent of the renderer is an inpaint mask or region, which require this behavior.
|
|
*
|
|
* The compositing rect is not added to the object group.
|
|
*/
|
|
compositing: {
|
|
group: Konva.Group;
|
|
rect: Konva.Rect;
|
|
patternImage: HTMLImageElement;
|
|
} | null;
|
|
};
|
|
|
|
constructor(parent: CanvasEntityAdapter) {
|
|
super();
|
|
this.id = getPrefixedId(this.type);
|
|
this.parent = parent;
|
|
this.manager = parent.manager;
|
|
this.path = this.manager.buildPath(this);
|
|
this.log = this.manager.buildLogger(this);
|
|
this.log.debug('Creating module');
|
|
|
|
this.konva = {
|
|
objectGroup: new Konva.Group({ name: `${this.type}:object_group`, listening: false }),
|
|
compositing: null,
|
|
};
|
|
|
|
this.parent.konva.layer.add(this.konva.objectGroup);
|
|
|
|
if (
|
|
this.parent.entityIdentifier.type === 'inpaint_mask' ||
|
|
this.parent.entityIdentifier.type === 'regional_guidance'
|
|
) {
|
|
const rect = new Konva.Rect({
|
|
name: `${this.type}:compositing_rect`,
|
|
globalCompositeOperation: 'source-in',
|
|
listening: false,
|
|
strokeEnabled: false,
|
|
perfectDrawEnabled: false,
|
|
});
|
|
this.konva.compositing = {
|
|
group: new Konva.Group({ name: `${this.type}:compositing_group`, listening: false }),
|
|
rect,
|
|
patternImage: new Image(), // we will set the src on this on the first render
|
|
};
|
|
this.konva.compositing.group.add(this.konva.compositing.rect);
|
|
this.parent.konva.layer.add(this.konva.compositing.group);
|
|
}
|
|
|
|
// The compositing rect must cover the whole stage at all times. When the stage is scaled, moved or resized, we
|
|
// need to update the compositing rect to match the stage.
|
|
this.subscriptions.add(
|
|
this.manager.stage.$stageAttrs.listen((stageAttrs, oldStageAttrs) => {
|
|
if (!this.konva.compositing) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
stageAttrs.width !== oldStageAttrs.width ||
|
|
stageAttrs.height !== oldStageAttrs.height ||
|
|
stageAttrs.scale !== oldStageAttrs.scale
|
|
) {
|
|
this.updateCompositingRectSize();
|
|
}
|
|
|
|
if (stageAttrs.x !== oldStageAttrs.x || stageAttrs.y !== oldStageAttrs.y) {
|
|
this.updateCompositingRectPosition();
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
initialize = async () => {
|
|
this.log.debug('Initializing module');
|
|
await this.render();
|
|
};
|
|
|
|
/**
|
|
* Renders the entity's objects.
|
|
* @returns A promise that resolves to a boolean, indicating if any of the objects were rendered.
|
|
*/
|
|
render = async (): Promise<boolean> => {
|
|
let didRender = false;
|
|
|
|
const objects = this.parent.state.objects;
|
|
const objectIds = objects.map((obj) => obj.id);
|
|
|
|
for (const renderer of this.renderers.values()) {
|
|
if (!objectIds.includes(renderer.id)) {
|
|
this.renderers.delete(renderer.id);
|
|
renderer.destroy();
|
|
didRender = true;
|
|
}
|
|
}
|
|
|
|
for (const obj of objects) {
|
|
didRender = (await this.renderObject(obj)) || didRender;
|
|
}
|
|
|
|
this.syncKonvaCache(didRender);
|
|
|
|
return didRender;
|
|
};
|
|
|
|
hideObjects = () => {
|
|
this.konva.objectGroup.hide();
|
|
};
|
|
|
|
showObjects = () => {
|
|
this.konva.objectGroup.show();
|
|
};
|
|
|
|
adoptObjectRenderer = (renderer: AnyObjectRenderer) => {
|
|
this.renderers.set(renderer.id, renderer);
|
|
renderer.konva.group.moveTo(this.konva.objectGroup);
|
|
};
|
|
|
|
syncKonvaCache = (force: boolean = false) => {
|
|
if (this.renderers.size === 0) {
|
|
this.log.trace('Clearing object group cache');
|
|
this.konva.objectGroup.clearCache();
|
|
return;
|
|
}
|
|
|
|
// We should never cache the entity if it is not visible - it will cache as a transparent image.
|
|
const isVisible = this.parent.konva.layer.visible();
|
|
const isCached = this.konva.objectGroup.isCached();
|
|
|
|
if (isVisible && (force || !isCached)) {
|
|
this.log.trace('Caching object group');
|
|
this.konva.objectGroup.clearCache();
|
|
this.konva.objectGroup.cache({ pixelRatio: 1, imageSmoothingEnabled: false });
|
|
}
|
|
};
|
|
|
|
updateTransparencyEffect = () => {
|
|
if (this.parent.state.type === 'control_layer') {
|
|
const filters = this.parent.state.withTransparencyEffect ? [LightnessToAlphaFilter] : [];
|
|
this.konva.objectGroup.filters(filters);
|
|
}
|
|
};
|
|
|
|
updateCompositingRectFill = throttle((force?: boolean) => {
|
|
if (!force && !this.hasObjects()) {
|
|
return;
|
|
}
|
|
|
|
if (!this.parent.konva.layer.visible()) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!this.konva.compositing ||
|
|
(this.parent.state.type !== 'inpaint_mask' && this.parent.state.type !== 'regional_guidance')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.log.trace('Updating compositing rect fill');
|
|
|
|
const fill = this.parent.state.fill;
|
|
|
|
if (fill.style === 'solid') {
|
|
this.konva.compositing.rect.setAttrs({
|
|
fill: rgbColorToString(fill.color),
|
|
fillPriority: 'color',
|
|
});
|
|
} else {
|
|
this.konva.compositing.rect.setAttrs({
|
|
fillPriority: 'pattern',
|
|
});
|
|
setFillPatternImage(this.konva.compositing.rect, fill.style, fill.color);
|
|
}
|
|
}, 100);
|
|
|
|
updateCompositingRectSize = (force?: boolean) => {
|
|
if (!force && !this.hasObjects()) {
|
|
return;
|
|
}
|
|
|
|
if (!this.parent.konva.layer.visible()) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!this.konva.compositing ||
|
|
(this.parent.state.type !== 'inpaint_mask' && this.parent.state.type !== 'regional_guidance')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.log.trace('Updating compositing rect size');
|
|
|
|
const scale = this.manager.stage.unscale(1);
|
|
|
|
this.konva.compositing.rect.setAttrs({
|
|
...this.manager.stage.getScaledStageRect(),
|
|
fillPatternScaleX: scale,
|
|
fillPatternScaleY: scale,
|
|
});
|
|
};
|
|
|
|
updateCompositingRectPosition = (force?: boolean) => {
|
|
if (!force && !this.hasObjects()) {
|
|
return;
|
|
}
|
|
|
|
if (!this.parent.konva.layer.visible()) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!this.konva.compositing ||
|
|
(this.parent.state.type !== 'inpaint_mask' && this.parent.state.type !== 'regional_guidance')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.log.trace('Updating compositing rect position');
|
|
|
|
this.konva.compositing.rect.setAttrs({
|
|
...this.manager.stage.getScaledStageRect(),
|
|
});
|
|
};
|
|
|
|
updateOpacity = throttle(() => {
|
|
if (!this.parent.konva.layer.visible()) {
|
|
return;
|
|
}
|
|
|
|
this.log.trace('Updating opacity');
|
|
|
|
const opacity = this.parent.state.opacity;
|
|
|
|
if (this.konva.compositing) {
|
|
this.konva.compositing.group.opacity(opacity);
|
|
} else {
|
|
this.konva.objectGroup.opacity(opacity);
|
|
}
|
|
this.parent.bufferRenderer.konva.group.opacity(opacity);
|
|
}, 100);
|
|
|
|
/**
|
|
* Renders the given object. If the object renderer does not exist, it will be created and its Konva group added to the
|
|
* parent entity's object group.
|
|
* @param objectState The object's state.
|
|
* @param force Whether to force the object to render, even if it has not changed. If omitted, the object renderer
|
|
* will only render if the object state has changed. The exception is the first render, where the object will always
|
|
* be rendered.
|
|
* @returns A promise that resolves to a boolean, indicating if the object was rendered.
|
|
*/
|
|
renderObject = async (objectState: AnyObjectState, force = false): Promise<boolean> => {
|
|
let didRender = false;
|
|
|
|
let renderer = this.renderers.get(objectState.id);
|
|
|
|
const isFirstRender = !renderer;
|
|
|
|
if (objectState.type === 'brush_line') {
|
|
assert(renderer instanceof CanvasObjectBrushLine || !renderer);
|
|
|
|
if (!renderer) {
|
|
renderer = new CanvasObjectBrushLine(objectState, this);
|
|
this.renderers.set(renderer.id, renderer);
|
|
this.konva.objectGroup.add(renderer.konva.group);
|
|
}
|
|
|
|
didRender = renderer.update(objectState, force || isFirstRender);
|
|
} else if (objectState.type === 'brush_line_with_pressure') {
|
|
assert(renderer instanceof CanvasObjectBrushLineWithPressure || !renderer);
|
|
|
|
if (!renderer) {
|
|
renderer = new CanvasObjectBrushLineWithPressure(objectState, this);
|
|
this.renderers.set(renderer.id, renderer);
|
|
this.konva.objectGroup.add(renderer.konva.group);
|
|
}
|
|
|
|
didRender = renderer.update(objectState, force || isFirstRender);
|
|
} else if (objectState.type === 'eraser_line') {
|
|
assert(renderer instanceof CanvasObjectEraserLine || !renderer);
|
|
|
|
if (!renderer) {
|
|
renderer = new CanvasObjectEraserLine(objectState, this);
|
|
this.renderers.set(renderer.id, renderer);
|
|
this.konva.objectGroup.add(renderer.konva.group);
|
|
}
|
|
|
|
didRender = renderer.update(objectState, force || isFirstRender);
|
|
} else if (objectState.type === 'eraser_line_with_pressure') {
|
|
assert(renderer instanceof CanvasObjectEraserLineWithPressure || !renderer);
|
|
|
|
if (!renderer) {
|
|
renderer = new CanvasObjectEraserLineWithPressure(objectState, this);
|
|
this.renderers.set(renderer.id, renderer);
|
|
this.konva.objectGroup.add(renderer.konva.group);
|
|
}
|
|
|
|
didRender = renderer.update(objectState, force || isFirstRender);
|
|
} else if (objectState.type === 'rect') {
|
|
assert(renderer instanceof CanvasObjectRect || !renderer);
|
|
|
|
if (!renderer) {
|
|
renderer = new CanvasObjectRect(objectState, this);
|
|
this.renderers.set(renderer.id, renderer);
|
|
this.konva.objectGroup.add(renderer.konva.group);
|
|
}
|
|
|
|
didRender = renderer.update(objectState, force || isFirstRender);
|
|
} else if (objectState.type === 'image') {
|
|
assert(renderer instanceof CanvasObjectImage || !renderer);
|
|
|
|
if (!renderer) {
|
|
renderer = new CanvasObjectImage(objectState, this);
|
|
this.renderers.set(renderer.id, renderer);
|
|
this.konva.objectGroup.add(renderer.konva.group);
|
|
}
|
|
didRender = await renderer.update(objectState, force || isFirstRender);
|
|
}
|
|
|
|
if (didRender && this.konva.objectGroup.isCached()) {
|
|
this.konva.objectGroup.clearCache();
|
|
}
|
|
|
|
return didRender;
|
|
};
|
|
|
|
/**
|
|
* Determines if the objects in the renderer require a pixel bbox calculation.
|
|
*
|
|
* In some cases, we can use Konva's getClientRect as the bbox, but it is not always accurate. It includes
|
|
* these visually transparent shapes in its calculation:
|
|
*
|
|
* - Eraser lines, which are normal lines with a globalCompositeOperation of 'destination-out'.
|
|
* - Clipped portions of any shape.
|
|
* - Images, which may have transparent areas.
|
|
*/
|
|
needsPixelBbox = (): boolean => {
|
|
let needsPixelBbox = false;
|
|
for (const renderer of this.renderers.values()) {
|
|
const isEraserLine = renderer instanceof CanvasObjectEraserLine;
|
|
const isImage = renderer instanceof CanvasObjectImage;
|
|
const hasClip = renderer instanceof CanvasObjectBrushLine && renderer.state.clip;
|
|
if (isEraserLine || hasClip || isImage) {
|
|
needsPixelBbox = true;
|
|
break;
|
|
}
|
|
}
|
|
return needsPixelBbox;
|
|
};
|
|
|
|
/**
|
|
* Checks if the renderer has any objects to render, including its buffer.
|
|
* @returns Whether the renderer has any objects to render.
|
|
*/
|
|
hasObjects = (): boolean => {
|
|
return this.renderers.size > 0 || this.parent.bufferRenderer.hasBuffer();
|
|
};
|
|
|
|
/**
|
|
* Rasterizes the parent entity, returning a promise that resolves to the image DTO.
|
|
*
|
|
* If the entity has a rasterization cache for the given rect, the cached image is returned. Otherwise, the entity is
|
|
* rasterized and the image is uploaded to the server.
|
|
*
|
|
* The rasterization cache is reset when the entity's state changes. The buffer object is not considered part of the
|
|
* entity state for this purpose as it is a temporary object.
|
|
*
|
|
* If rasterization fails for any reason, the promise will reject.
|
|
*
|
|
* @param options The rasterization options.
|
|
* @param options.rect The region of the entity to rasterize.
|
|
* @param options.replaceObjects Whether to replace the entity's objects with the rasterized image. If you just want
|
|
* the entity's image, omit or set this to false.
|
|
* @param options.attrs The Konva node attributes to apply to the rasterized image group. For example, you might want
|
|
* to disable filters or set the opacity to the rasterized image.
|
|
* @param options.bg Draws the entity on a canvas with the given background color. If omitted, the entity is drawn on
|
|
* a transparent canvas.
|
|
* @returns A promise that resolves to the rasterized image DTO or rejects if rasterization fails.
|
|
*/
|
|
rasterize = async (options: {
|
|
rect: Rect;
|
|
replaceObjects?: boolean;
|
|
attrs?: GroupConfig;
|
|
bg?: string;
|
|
ignoreCache?: boolean;
|
|
}): Promise<ImageDTO> => {
|
|
const rasterizingAdapter = this.manager.stateApi.$rasterizingAdapter.get();
|
|
if (rasterizingAdapter) {
|
|
assert(false, `Already rasterizing an entity: ${rasterizingAdapter.id}`);
|
|
}
|
|
|
|
const { rect, replaceObjects, attrs, bg, ignoreCache } = {
|
|
replaceObjects: false,
|
|
ignoreCache: false,
|
|
attrs: {},
|
|
...options,
|
|
};
|
|
let imageDTO: ImageDTO | null = null;
|
|
const rasterizeArgs = { rect, attrs, bg };
|
|
const hash = this.parent.hash(rasterizeArgs);
|
|
const cachedImageName = this.manager.cache.imageNameCache.get(hash);
|
|
|
|
if (cachedImageName && !ignoreCache) {
|
|
imageDTO = await getImageDTOSafe(cachedImageName);
|
|
if (imageDTO) {
|
|
this.log.trace({ rect, cachedImageName, imageDTO }, 'Using cached rasterized image');
|
|
return imageDTO;
|
|
}
|
|
}
|
|
|
|
this.log.trace({ rasterizeArgs }, 'Rasterizing entity');
|
|
this.manager.stateApi.$rasterizingAdapter.set(this.parent);
|
|
|
|
try {
|
|
const blob = await this.getBlob(rasterizeArgs);
|
|
if (this.manager._isDebugging) {
|
|
previewBlob(blob, 'Rasterized entity');
|
|
}
|
|
imageDTO = await uploadImage({
|
|
file: new File([blob], `${this.id}_rasterized.png`, { type: 'image/png' }),
|
|
image_category: 'other',
|
|
is_intermediate: true,
|
|
silent: true,
|
|
});
|
|
const imageObject = imageDTOToImageObject(imageDTO);
|
|
if (replaceObjects) {
|
|
await this.parent.bufferRenderer.setBuffer(imageObject);
|
|
this.parent.bufferRenderer.commitBuffer({ pushToState: false });
|
|
}
|
|
this.manager.stateApi.rasterizeEntity({
|
|
entityIdentifier: this.parent.entityIdentifier,
|
|
imageObject,
|
|
position: { x: Math.round(rect.x), y: Math.round(rect.y) },
|
|
replaceObjects,
|
|
});
|
|
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
|
|
return imageDTO;
|
|
} catch (error) {
|
|
this.log.error({ rasterizeArgs, error: serializeError(error as Error) }, 'Failed to rasterize entity');
|
|
throw error;
|
|
} finally {
|
|
this.manager.stateApi.$rasterizingAdapter.set(null);
|
|
}
|
|
};
|
|
|
|
cloneObjectGroup = (arg: { attrs?: GroupConfig } = {}): Konva.Group => {
|
|
const { attrs } = arg;
|
|
const clone = this.konva.objectGroup.clone();
|
|
if (attrs) {
|
|
clone.setAttrs(attrs);
|
|
}
|
|
if (clone.hasChildren()) {
|
|
clone.cache({ pixelRatio: 1, imageSmoothingEnabled: false });
|
|
}
|
|
return clone;
|
|
};
|
|
|
|
getCanvas = (arg: { rect?: Rect; attrs?: GroupConfig; bg?: string } = {}): HTMLCanvasElement => {
|
|
const { rect, attrs, bg } = arg;
|
|
const clone = this.cloneObjectGroup({ attrs });
|
|
const canvas = konvaNodeToCanvas({ node: clone, rect, bg });
|
|
clone.destroy();
|
|
return canvas;
|
|
};
|
|
|
|
getBlob = async (arg: { rect?: Rect; attrs?: GroupConfig; bg?: string } = {}): Promise<Blob> => {
|
|
const { rect, attrs, bg } = arg;
|
|
const clone = this.cloneObjectGroup({ attrs });
|
|
const blob = await konvaNodeToBlob({ node: clone, rect, bg });
|
|
return blob;
|
|
};
|
|
|
|
getImageData = (arg: { rect?: Rect; attrs?: GroupConfig; bg?: string } = {}): ImageData => {
|
|
const { rect, attrs, bg } = arg;
|
|
const clone = this.cloneObjectGroup({ attrs });
|
|
const imageData = konvaNodeToImageData({ node: clone, rect, bg });
|
|
clone.destroy();
|
|
return imageData;
|
|
};
|
|
|
|
destroy = () => {
|
|
this.log.debug('Destroying module');
|
|
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
this.subscriptions.clear();
|
|
for (const renderer of this.renderers.values()) {
|
|
renderer.destroy();
|
|
}
|
|
this.renderers.clear();
|
|
};
|
|
|
|
repr = () => {
|
|
return {
|
|
id: this.id,
|
|
type: this.type,
|
|
path: this.path,
|
|
parent: this.parent.id,
|
|
renderers: Array.from(this.renderers.values()).map((renderer) => renderer.repr()),
|
|
konva: {
|
|
objectGroup: getKonvaNodeDebugAttrs(this.konva.objectGroup),
|
|
},
|
|
};
|
|
};
|
|
}
|