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

View File

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

View File

@ -1,38 +1,53 @@
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 { 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 { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid';
export class CanvasLayer {
id: string;
manager: KonvaNodeManager;
layer: Konva.Layer;
group: Konva.Group;
transformer: Konva.Transformer;
objects: Map<string, KonvaBrushLine | KonvaEraserLine | KonvaRect | KonvaImage>;
constructor(entity: LayerEntity, onPosChanged: StateApi['onPosChanged']) {
constructor(entity: LayerEntity, manager: KonvaNodeManager) {
this.id = entity.id;
this.manager = manager;
this.layer = new Konva.Layer({
id: entity.id,
draggable: true,
dragDistance: 0,
listening: false,
});
// When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing
// 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({
this.group = new Konva.Group({
id: getObjectGroupId(this.layer.id(), uuidv4()),
listening: false,
});
this.group = 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();
}
@ -40,20 +55,24 @@ export class CanvasLayer {
this.layer.destroy();
}
async render(layerState: LayerEntity, selectedTool: Tool) {
async render(layerState: LayerEntity) {
// Update the layer's position and listening state
this.layer.setAttrs({
listening: selectedTool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events
x: Math.floor(layerState.x),
y: Math.floor(layerState.y),
this.group.setAttrs({
x: layerState.x,
y: layerState.y,
scaleX: 1,
scaleY: 1,
});
let didDraw = false;
const objectIds = layerState.objects.map(mapId);
// Destroy any objects that are no longer in state
for (const object of this.objects.values()) {
if (!objectIds.includes(object.id)) {
this.objects.delete(object.id);
object.destroy();
didDraw = true;
}
}
@ -63,45 +82,60 @@ export class CanvasLayer {
assert(brushLine instanceof KonvaBrushLine || brushLine === undefined);
if (!brushLine) {
brushLine = new KonvaBrushLine({ brushLine: obj });
brushLine = new KonvaBrushLine(obj);
this.objects.set(brushLine.id, brushLine);
this.group.add(brushLine.konvaLineGroup);
}
if (obj.points.length !== brushLine.konvaLine.points().length) {
brushLine.konvaLine.points(obj.points);
didDraw = true;
} else {
if (brushLine.update(obj)) {
didDraw = true;
}
}
} else if (obj.type === 'eraser_line') {
let eraserLine = this.objects.get(obj.id);
assert(eraserLine instanceof KonvaEraserLine || eraserLine === undefined);
if (!eraserLine) {
eraserLine = new KonvaEraserLine({ eraserLine: obj });
eraserLine = new KonvaEraserLine(obj);
this.objects.set(eraserLine.id, eraserLine);
this.group.add(eraserLine.konvaLineGroup);
}
if (obj.points.length !== eraserLine.konvaLine.points().length) {
eraserLine.konvaLine.points(obj.points);
didDraw = true;
} else {
if (eraserLine.update(obj)) {
didDraw = true;
}
}
} else if (obj.type === 'rect_shape') {
let rect = this.objects.get(obj.id);
assert(rect instanceof KonvaRect || rect === undefined);
if (!rect) {
rect = new KonvaRect({ rectShape: obj });
rect = new KonvaRect(obj);
this.objects.set(rect.id, rect);
this.group.add(rect.konvaRect);
didDraw = true;
} else {
if (rect.update(obj)) {
didDraw = true;
}
}
} else if (obj.type === 'image') {
let image = this.objects.get(obj.id);
assert(image instanceof KonvaImage || image === undefined);
if (!image) {
image = await new KonvaImage({ imageObject: obj });
image = await new KonvaImage(obj, {
onLoad: () => {
this.updateGroup(true);
},
});
this.objects.set(image.id, image);
this.group.add(image.konvaImageGroup);
}
if (image.imageName !== obj.image.name) {
image.updateImageSource(obj.image.name);
await image.updateImageSource(obj.image.name);
} else {
if (await image.update(obj)) {
didDraw = true;
}
}
}
}
@ -111,22 +145,71 @@ export class CanvasLayer {
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);
// 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;
konvaLineGroup: Konva.Group;
konvaLine: Konva.Line;
lastBrushLine: BrushLine;
constructor(arg: { brushLine: BrushLine }) {
const { brushLine } = arg;
const { id, strokeWidth, clip, color } = brushLine;
constructor(brushLine: BrushLine) {
const { id, strokeWidth, clip, color, points } = brushLine;
this.id = id;
this.konvaLineGroup = new Konva.Group({
clip,
@ -46,8 +46,26 @@ export class KonvaBrushLine {
lineJoin: 'round',
globalCompositeOperation: 'source-over',
stroke: rgbaColorToString(color),
points,
});
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() {
@ -59,10 +77,10 @@ export class KonvaEraserLine {
id: string;
konvaLineGroup: Konva.Group;
konvaLine: Konva.Line;
lastEraserLine: EraserLine;
constructor(arg: { eraserLine: EraserLine }) {
const { eraserLine } = arg;
const { id, strokeWidth, clip } = eraserLine;
constructor(eraserLine: EraserLine) {
const { id, strokeWidth, clip, points } = eraserLine;
this.id = id;
this.konvaLineGroup = new Konva.Group({
clip,
@ -78,8 +96,25 @@ export class KonvaEraserLine {
lineJoin: 'round',
globalCompositeOperation: 'destination-out',
stroke: rgbaColorToString(RGBA_RED),
points,
});
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() {
@ -90,9 +125,9 @@ export class KonvaEraserLine {
export class KonvaRect {
id: string;
konvaRect: Konva.Rect;
lastRectShape: RectShape;
constructor(arg: { rectShape: RectShape }) {
const { rectShape } = arg;
constructor(rectShape: RectShape) {
const { id, x, y, width, height } = rectShape;
this.id = id;
const konvaRect = new Konva.Rect({
@ -105,6 +140,24 @@ export class KonvaRect {
fill: rgbaColorToString(rectShape.color),
});
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() {
@ -126,15 +179,18 @@ export class KonvaImage {
onLoading: () => void;
onLoad: (imageName: string, imageEl: HTMLImageElement) => void;
onError: () => void;
lastImageObject: ImageObject;
constructor(arg: {
imageObject: ImageObject;
getImageDTO?: (imageName: string) => Promise<ImageDTO | null>;
onLoading?: () => void;
onLoad?: (konvaImage: Konva.Image) => void;
onError?: () => void;
}) {
const { imageObject, getImageDTO, onLoading, onLoad, onError } = arg;
constructor(
imageObject: ImageObject,
options: {
getImageDTO?: (imageName: string) => Promise<ImageDTO | null>;
onLoading?: () => void;
onLoad?: (konvaImage: Konva.Image) => void;
onError?: () => void;
}
) {
const { getImageDTO, onLoading, onLoad, onError } = options;
const { id, width, height, x, y } = imageObject;
this.konvaImageGroup = new Konva.Group({ id, listening: false, x, y });
this.konvaPlaceholderGroup = new Konva.Group({ listening: false });
@ -188,6 +244,8 @@ export class KonvaImage {
id: this.id,
listening: false,
image: imageEl,
width,
height,
});
this.konvaImageGroup.add(this.konvaImage);
}
@ -213,6 +271,7 @@ export class KonvaImage {
onError();
}
};
this.lastImageObject = imageObject;
}
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() {
this.konvaImageGroup.destroy();
}

View File

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

View File

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

View File

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

View File

@ -172,6 +172,36 @@ export const layersReducers = {
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: {
reducer: (state, action: PayloadAction<EraserLineAddedArg & { lineId: string }>) => {
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']);
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']);
@ -891,6 +894,7 @@ export type CanvasV2State = {
export type StageAttrs = { x: number; y: number; width: number; height: number; scale: 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 EraserLineAddedArg = {
id: string;