refactor(ui): decouple konva renderer from react

Subscribe to redux store directly, skipping all the react overhead.

With react in dev mode, a typical frame while using the brush tool on almost-empty canvas is reduced from ~7.5ms to ~3.5ms. All things considered, this still feels slow, but it's a massive improvement.
This commit is contained in:
psychedelicious 2024-06-18 17:24:13 +10:00
parent fc5467150e
commit b7f9c5e221
7 changed files with 774 additions and 648 deletions

View File

@ -28,7 +28,8 @@ export type LoggerNamespace =
| 'queue' | 'queue'
| 'dnd' | 'dnd'
| 'controlLayers' | 'controlLayers'
| 'metadata'; | 'metadata'
| 'konva';
export const logger = (namespace: LoggerNamespace) => $logger.get().child({ namespace }); export const logger = (namespace: LoggerNamespace) => $logger.get().child({ namespace });

View File

@ -1,408 +1,27 @@
import { $alt, $ctrl, $meta, $shift, Flex, Heading } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react'; import { useAppStore } from 'app/store/storeHooks';
import { logger } from 'app/logging/logger';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay'; import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay';
import { setStageEventHandlers } from 'features/controlLayers/konva/events'; import { initializeRenderer } from 'features/controlLayers/konva/renderers/renderer';
import { renderBackgroundLayer } from 'features/controlLayers/konva/renderers/background';
import { renderControlAdapters } from 'features/controlLayers/konva/renderers/caLayer';
import {
arrangeEntities,
debouncedRenderers,
renderers as normalRenderers,
} from 'features/controlLayers/konva/renderers/layers';
import { renderDocumentBoundsOverlay } from 'features/controlLayers/konva/renderers/previewLayer';
import { renderLayers } from 'features/controlLayers/konva/renderers/rasterLayer';
import { renderRegions } from 'features/controlLayers/konva/renderers/rgLayer';
import {
$bbox,
$currentFill,
$document,
$isDrawing,
$isMouseDown,
$lastAddedPoint,
$lastCursorPos,
$lastMouseDownPos,
$selectedEntity,
$spaceKey,
$stageAttrs,
$toolState,
bboxChanged,
brushWidthChanged,
caBboxChanged,
caTranslated,
eraserWidthChanged,
layerBboxChanged,
layerBrushLineAdded,
layerEraserLineAdded,
layerLinePointAdded,
layerRectAdded,
layerTranslated,
rgBboxChanged,
rgBrushLineAdded,
rgEraserLineAdded,
rgLinePointAdded,
rgRectAdded,
rgTranslated,
toolBufferChanged,
toolChanged,
} from 'features/controlLayers/store/canvasV2Slice';
import { selectEntityCount } from 'features/controlLayers/store/selectors';
import type {
BboxChangedArg,
BrushLineAddedArg,
CanvasEntity,
EraserLineAddedArg,
PointAddedToLineArg,
PosChangedArg,
RectShapeAddedArg,
Tool,
} from 'features/controlLayers/store/types';
import Konva from 'konva'; import Konva from 'konva';
import type { IRect } from 'konva/lib/types'; import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getImageDTO } from 'services/api/endpoints/images';
import { useDevicePixelRatio } from 'use-device-pixel-ratio'; import { useDevicePixelRatio } from 'use-device-pixel-ratio';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
// This will log warnings when layers > 5 - maybe use `import.meta.env.MODE === 'development'` instead? // This will log warnings when layers > 5 - maybe use `import.meta.env.MODE === 'development'` instead?
Konva.showWarnings = false; Konva.showWarnings = false;
const log = logger('controlLayers');
const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null, asPreview: boolean) => { const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null, asPreview: boolean) => {
const dispatch = useAppDispatch(); const store = useAppStore();
const controlAdapters = useAppSelector((s) => s.canvasV2.controlAdapters);
const ipAdapters = useAppSelector((s) => s.canvasV2.ipAdapters);
const layers = useAppSelector((s) => s.canvasV2.layers);
const regions = useAppSelector((s) => s.canvasV2.regions);
const tool = useAppSelector((s) => s.canvasV2.tool);
const selectedEntityIdentifier = useAppSelector((s) => s.canvasV2.selectedEntityIdentifier);
const maskOpacity = useAppSelector((s) => s.canvasV2.settings.maskOpacity);
const bbox = useAppSelector((s) => s.canvasV2.bbox);
const document = useAppSelector((s) => s.canvasV2.document);
const lastCursorPos = useStore($lastCursorPos);
const lastMouseDownPos = useStore($lastMouseDownPos);
const isMouseDown = useStore($isMouseDown);
const isDrawing = useStore($isDrawing);
const selectedEntity = useMemo(() => {
const identifier = selectedEntityIdentifier;
if (!identifier) {
return null;
} else if (identifier.type === 'layer') {
return layers.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'control_adapter') {
return controlAdapters.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'ip_adapter') {
return ipAdapters.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'regional_guidance') {
return regions.find((i) => i.id === identifier.id) ?? null;
} else {
return null;
}
}, [controlAdapters, ipAdapters, layers, regions, selectedEntityIdentifier]);
const currentFill = useMemo(() => {
if (selectedEntity && selectedEntity.type === 'regional_guidance') {
return { ...selectedEntity.fill, a: maskOpacity };
}
return tool.fill;
}, [maskOpacity, selectedEntity, tool.fill]);
const renderers = useMemo(() => (asPreview ? debouncedRenderers : normalRenderers), [asPreview]);
const dpr = useDevicePixelRatio({ round: false }); const dpr = useDevicePixelRatio({ round: false });
useLayoutEffect(() => { useLayoutEffect(() => {
$toolState.set(tool); const cleanup = initializeRenderer(store, stage, container);
$selectedEntity.set(selectedEntity); return cleanup;
$bbox.set(bbox); }, [asPreview, container, stage, store]);
$currentFill.set(currentFill);
$document.set(document);
}, [selectedEntity, tool, bbox, currentFill, document]);
const onPosChanged = useCallback(
(arg: PosChangedArg, entityType: CanvasEntity['type']) => {
if (entityType === 'layer') {
dispatch(layerTranslated(arg));
} else if (entityType === 'control_adapter') {
dispatch(caTranslated(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgTranslated(arg));
}
},
[dispatch]
);
const onBboxChanged = useCallback(
(arg: BboxChangedArg, entityType: CanvasEntity['type']) => {
if (entityType === 'layer') {
dispatch(layerBboxChanged(arg));
} else if (entityType === 'control_adapter') {
dispatch(caBboxChanged(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgBboxChanged(arg));
}
},
[dispatch]
);
const onBrushLineAdded = useCallback(
(arg: BrushLineAddedArg, entityType: CanvasEntity['type']) => {
if (entityType === 'layer') {
dispatch(layerBrushLineAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgBrushLineAdded(arg));
}
},
[dispatch]
);
const onEraserLineAdded = useCallback(
(arg: EraserLineAddedArg, entityType: CanvasEntity['type']) => {
if (entityType === 'layer') {
dispatch(layerEraserLineAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgEraserLineAdded(arg));
}
},
[dispatch]
);
const onPointAddedToLine = useCallback(
(arg: PointAddedToLineArg, entityType: CanvasEntity['type']) => {
if (entityType === 'layer') {
dispatch(layerLinePointAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgLinePointAdded(arg));
}
},
[dispatch]
);
const onRectShapeAdded = useCallback(
(arg: RectShapeAddedArg, entityType: CanvasEntity['type']) => {
if (entityType === 'layer') {
dispatch(layerRectAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgRectAdded(arg));
}
},
[dispatch]
);
const onBboxTransformed = useCallback(
(bbox: IRect) => {
dispatch(bboxChanged(bbox));
},
[dispatch]
);
const onBrushWidthChanged = useCallback(
(width: number) => {
dispatch(brushWidthChanged(width));
},
[dispatch]
);
const onEraserWidthChanged = useCallback(
(width: number) => {
dispatch(eraserWidthChanged(width));
},
[dispatch]
);
const setTool = useCallback(
(tool: Tool) => {
dispatch(toolChanged(tool));
},
[dispatch]
);
const setToolBuffer = useCallback(
(toolBuffer: Tool | null) => {
dispatch(toolBufferChanged(toolBuffer));
},
[dispatch]
);
useLayoutEffect(() => {
log.trace('Initializing stage');
if (!container) {
return;
}
stage.container(container);
return () => {
log.trace('Cleaning up stage');
stage.destroy();
};
}, [container, stage]);
useLayoutEffect(() => {
log.trace('Adding stage listeners');
if (asPreview || !container) {
return;
}
const cleanup = setStageEventHandlers({
stage,
getToolState: $toolState.get,
setTool,
setToolBuffer,
getIsDrawing: $isDrawing.get,
setIsDrawing: $isDrawing.set,
getIsMouseDown: $isMouseDown.get,
setIsMouseDown: $isMouseDown.set,
getSelectedEntity: $selectedEntity.get,
getLastAddedPoint: $lastAddedPoint.get,
setLastAddedPoint: $lastAddedPoint.set,
getLastCursorPos: $lastCursorPos.get,
setLastCursorPos: $lastCursorPos.set,
getLastMouseDownPos: $lastMouseDownPos.get,
setLastMouseDownPos: $lastMouseDownPos.set,
getSpaceKey: $spaceKey.get,
setStageAttrs: $stageAttrs.set,
getDocument: $document.get,
getBbox: $bbox.get,
onBrushLineAdded,
onEraserLineAdded,
onPointAddedToLine,
onRectShapeAdded,
onBrushWidthChanged,
onEraserWidthChanged,
getCurrentFill: $currentFill.get,
});
return () => {
log.trace('Removing stage listeners');
cleanup();
};
}, [
asPreview,
onBrushLineAdded,
onBrushWidthChanged,
onEraserLineAdded,
onPointAddedToLine,
onRectShapeAdded,
stage,
container,
onEraserWidthChanged,
setTool,
setToolBuffer,
]);
useLayoutEffect(() => {
log.trace('Updating stage dimensions');
if (!container) {
return;
}
const fitStageToContainer = () => {
stage.width(container.offsetWidth);
stage.height(container.offsetHeight);
$stageAttrs.set({
x: stage.x(),
y: stage.y(),
width: stage.width(),
height: stage.height(),
scale: stage.scaleX(),
});
renderBackgroundLayer(stage);
renderDocumentBoundsOverlay(stage, $document.get);
};
const resizeObserver = new ResizeObserver(fitStageToContainer);
resizeObserver.observe(container);
fitStageToContainer();
return () => {
resizeObserver.disconnect();
};
}, [stage, container]);
useLayoutEffect(() => {
if (asPreview) {
// Preview should not display tool
return;
}
log.trace('Rendering tool preview');
renderers.renderToolPreview(
stage,
tool,
currentFill,
selectedEntity,
lastCursorPos,
lastMouseDownPos,
isDrawing,
isMouseDown
);
}, [
asPreview,
currentFill,
document,
isDrawing,
isMouseDown,
lastCursorPos,
lastMouseDownPos,
renderers,
selectedEntity,
stage,
tool,
]);
useLayoutEffect(() => {
if (asPreview) {
// Preview should not display tool
return;
}
log.trace('Rendering bbox preview');
renderers.renderBboxPreview(
stage,
bbox,
tool.selected,
$bbox.get,
onBboxTransformed,
$shift.get,
$ctrl.get,
$meta.get,
$alt.get
);
}, [asPreview, bbox, onBboxTransformed, renderers, stage, tool.selected]);
useLayoutEffect(() => {
log.trace('Rendering layers');
renderLayers(stage, layers, tool.selected, onPosChanged);
}, [layers, onPosChanged, stage, tool.selected]);
useLayoutEffect(() => {
log.trace('Rendering regions');
renderRegions(stage, regions, maskOpacity, tool.selected, selectedEntity, onPosChanged);
}, [maskOpacity, onPosChanged, regions, selectedEntity, stage, tool.selected]);
useLayoutEffect(() => {
log.trace('Rendering layers');
renderControlAdapters(stage, controlAdapters, getImageDTO);
}, [controlAdapters, stage]);
useLayoutEffect(() => {
renderDocumentBoundsOverlay(stage, $document.get);
}, [stage, document]);
useLayoutEffect(() => {
arrangeEntities(stage, layers, controlAdapters, regions);
}, [layers, controlAdapters, regions, stage]);
// useLayoutEffect(() => {
// if (asPreview) {
// // Preview should not check for transparency
// return;
// }
// log.trace('Updating bboxes');
// debouncedRenderers.updateBboxes(stage, state.layers, onBboxChanged);
// }, [stage, asPreview, state.layers, onBboxChanged]);
useLayoutEffect(() => { useLayoutEffect(() => {
Konva.pixelRatio = dpr; Konva.pixelRatio = dpr;
}, [dpr]); }, [dpr]);
useEffect(
() => () => {
stage.destroy();
},
[stage]
);
}; };
type Props = { type Props = {
@ -426,9 +45,15 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
useStageRenderer(stage, container, asPreview); useStageRenderer(stage, container, asPreview);
useEffect(
() => () => {
stage.destroy();
},
[stage]
);
return ( return (
<Flex position="relative" w="full" h="full"> <Flex position="relative" w="full" h="full">
{!asPreview && <NoEntitiesFallback />}
<Flex <Flex
position="absolute" position="absolute"
top={0} top={0}
@ -454,18 +79,3 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
}); });
StageComponent.displayName = 'StageComponent'; StageComponent.displayName = 'StageComponent';
const NoEntitiesFallback = () => {
const { t } = useTranslation();
const entityCount = useAppSelector(selectEntityCount);
if (entityCount) {
return null;
}
return (
<Flex position="absolute" w="full" h="full" alignItems="center" justifyContent="center">
<Heading color="base.200">{t('controlLayers.noLayersAdded')}</Heading>
</Flex>
);
};

View File

@ -1,5 +1,10 @@
import { renderBackgroundLayer } from 'features/controlLayers/konva/renderers/background'; import { renderBackgroundLayer } from 'features/controlLayers/konva/renderers/background';
import { renderDocumentBoundsOverlay, scaleToolPreview } from 'features/controlLayers/konva/renderers/previewLayer'; import {
renderDocumentBoundsOverlay,
renderToolPreview,
scaleToolPreview,
} from 'features/controlLayers/konva/renderers/previewLayer';
import { fitDocumentToStage } from 'features/controlLayers/konva/renderers/stage';
import { getScaledFlooredCursorPosition } from 'features/controlLayers/konva/util'; import { getScaledFlooredCursorPosition } from 'features/controlLayers/konva/util';
import type { import type {
BrushLineAddedArg, BrushLineAddedArg,
@ -19,7 +24,6 @@ import { clamp } from 'lodash-es';
import { import {
BRUSH_SPACING_TARGET_SCALE, BRUSH_SPACING_TARGET_SCALE,
CANVAS_SCALE_BY, CANVAS_SCALE_BY,
DOCUMENT_FIT_PADDING_PX,
MAX_BRUSH_SPACING_PX, MAX_BRUSH_SPACING_PX,
MAX_CANVAS_SCALE, MAX_CANVAS_SCALE,
MIN_BRUSH_SPACING_PX, MIN_BRUSH_SPACING_PX,
@ -164,6 +168,16 @@ export const setStageEventHandlers = ({
} }
const tool = getToolState().selected; const tool = getToolState().selected;
stage.findOne<Konva.Layer>(`#${PREVIEW_TOOL_GROUP_ID}`)?.visible(tool === 'brush' || tool === 'eraser'); stage.findOne<Konva.Layer>(`#${PREVIEW_TOOL_GROUP_ID}`)?.visible(tool === 'brush' || tool === 'eraser');
renderToolPreview(
stage,
getToolState(),
getCurrentFill(),
getSelectedEntity(),
getLastCursorPos(),
getLastAddedPoint(),
getIsDrawing(),
getIsMouseDown()
);
}); });
//#region mousedown //#region mousedown
@ -176,33 +190,50 @@ export const setStageEventHandlers = ({
const toolState = getToolState(); const toolState = getToolState();
const pos = updateLastCursorPos(stage, setLastCursorPos); const pos = updateLastCursorPos(stage, setLastCursorPos);
const selectedEntity = getSelectedEntity(); const selectedEntity = getSelectedEntity();
if (!pos || !selectedEntity) {
return;
}
if (selectedEntity.type !== 'regional_guidance' && selectedEntity.type !== 'layer') {
return;
}
if (getSpaceKey()) { if (
// No drawing when space is down - we are panning the stage pos &&
return; selectedEntity &&
} (selectedEntity?.type === 'regional_guidance' || selectedEntity?.type === 'layer') &&
!getSpaceKey()
) {
setIsDrawing(true);
setLastMouseDownPos(pos);
setIsDrawing(true); if (toolState.selected === 'brush') {
setLastMouseDownPos(pos); const bbox = getBbox();
if (e.evt.shiftKey) {
if (toolState.selected === 'brush') { const lastAddedPoint = getLastAddedPoint();
const bbox = getBbox(); // Create a straight line if holding shift
if (e.evt.shiftKey) { if (lastAddedPoint) {
const lastAddedPoint = getLastAddedPoint(); onBrushLineAdded(
// Create a straight line if holding shift {
if (lastAddedPoint) { id: selectedEntity.id,
points: [
lastAddedPoint.x - selectedEntity.x,
lastAddedPoint.y - selectedEntity.y,
pos.x - selectedEntity.x,
pos.y - selectedEntity.y,
],
color: getCurrentFill(),
width: toolState.brush.width,
clip: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
},
selectedEntity.type
);
}
} else {
onBrushLineAdded( onBrushLineAdded(
{ {
id: selectedEntity.id, id: selectedEntity.id,
points: [ points: [
lastAddedPoint.x - selectedEntity.x, pos.x - selectedEntity.x,
lastAddedPoint.y - selectedEntity.y, pos.y - selectedEntity.y,
pos.x - selectedEntity.x, pos.x - selectedEntity.x,
pos.y - selectedEntity.y, pos.y - selectedEntity.y,
], ],
@ -218,43 +249,42 @@ export const setStageEventHandlers = ({
selectedEntity.type selectedEntity.type
); );
} }
} else { setLastAddedPoint(pos);
onBrushLineAdded(
{
id: selectedEntity.id,
points: [
pos.x - selectedEntity.x,
pos.y - selectedEntity.y,
pos.x - selectedEntity.x,
pos.y - selectedEntity.y,
],
color: getCurrentFill(),
width: toolState.brush.width,
clip: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
},
selectedEntity.type
);
} }
setLastAddedPoint(pos);
}
if (toolState.selected === 'eraser') { if (toolState.selected === 'eraser') {
const bbox = getBbox(); const bbox = getBbox();
if (e.evt.shiftKey) { if (e.evt.shiftKey) {
// Create a straight line if holding shift // Create a straight line if holding shift
const lastAddedPoint = getLastAddedPoint(); const lastAddedPoint = getLastAddedPoint();
if (lastAddedPoint) { if (lastAddedPoint) {
onEraserLineAdded(
{
id: selectedEntity.id,
points: [
lastAddedPoint.x - selectedEntity.x,
lastAddedPoint.y - selectedEntity.y,
pos.x - selectedEntity.x,
pos.y - selectedEntity.y,
],
width: toolState.eraser.width,
clip: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
},
selectedEntity.type
);
}
} else {
onEraserLineAdded( onEraserLineAdded(
{ {
id: selectedEntity.id, id: selectedEntity.id,
points: [ points: [
lastAddedPoint.x - selectedEntity.x, pos.x - selectedEntity.x,
lastAddedPoint.y - selectedEntity.y, pos.y - selectedEntity.y,
pos.x - selectedEntity.x, pos.x - selectedEntity.x,
pos.y - selectedEntity.y, pos.y - selectedEntity.y,
], ],
@ -269,29 +299,19 @@ export const setStageEventHandlers = ({
selectedEntity.type selectedEntity.type
); );
} }
} else { setLastAddedPoint(pos);
onEraserLineAdded(
{
id: selectedEntity.id,
points: [
pos.x - selectedEntity.x,
pos.y - selectedEntity.y,
pos.x - selectedEntity.x,
pos.y - selectedEntity.y,
],
width: toolState.eraser.width,
clip: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
},
selectedEntity.type
);
} }
setLastAddedPoint(pos);
} }
renderToolPreview(
stage,
getToolState(),
getCurrentFill(),
getSelectedEntity(),
getLastCursorPos(),
getLastAddedPoint(),
getIsDrawing(),
getIsMouseDown()
);
}); });
//#region mouseup //#region mouseup
@ -304,41 +324,47 @@ export const setStageEventHandlers = ({
const pos = getLastCursorPos(); const pos = getLastCursorPos();
const selectedEntity = getSelectedEntity(); const selectedEntity = getSelectedEntity();
if (!pos || !selectedEntity) { if (
return; pos &&
} selectedEntity &&
if (selectedEntity.type !== 'regional_guidance' && selectedEntity.type !== 'layer') { (selectedEntity?.type === 'regional_guidance' || selectedEntity?.type === 'layer') &&
return; !getSpaceKey()
} ) {
const toolState = getToolState();
if (getSpaceKey()) { if (toolState.selected === 'rect') {
// No drawing when space is down - we are panning the stage const lastMouseDownPos = getLastMouseDownPos();
return; if (lastMouseDownPos) {
} onRectShapeAdded(
{
const toolState = getToolState(); id: selectedEntity.id,
rect: {
if (toolState.selected === 'rect') { x: Math.min(pos.x, lastMouseDownPos.x),
const lastMouseDownPos = getLastMouseDownPos(); y: Math.min(pos.y, lastMouseDownPos.y),
if (lastMouseDownPos) { width: Math.abs(pos.x - lastMouseDownPos.x),
onRectShapeAdded( height: Math.abs(pos.y - lastMouseDownPos.y),
{ },
id: selectedEntity.id, color: getCurrentFill(),
rect: {
x: Math.min(pos.x, lastMouseDownPos.x),
y: Math.min(pos.y, lastMouseDownPos.y),
width: Math.abs(pos.x - lastMouseDownPos.x),
height: Math.abs(pos.y - lastMouseDownPos.y),
}, },
color: getCurrentFill(), selectedEntity.type
}, );
selectedEntity.type }
);
} }
setIsDrawing(false);
setLastMouseDownPos(null);
} }
setIsDrawing(false); renderToolPreview(
setLastMouseDownPos(null); stage,
getToolState(),
getCurrentFill(),
getSelectedEntity(),
getLastCursorPos(),
getLastAddedPoint(),
getIsDrawing(),
getIsMouseDown()
);
}); });
//#region mousemove //#region mousemove
@ -355,84 +381,101 @@ export const setStageEventHandlers = ({
.findOne<Konva.Layer>(`#${PREVIEW_TOOL_GROUP_ID}`) .findOne<Konva.Layer>(`#${PREVIEW_TOOL_GROUP_ID}`)
?.visible(toolState.selected === 'brush' || toolState.selected === 'eraser'); ?.visible(toolState.selected === 'brush' || toolState.selected === 'eraser');
if (!pos || !selectedEntity) { if (
return; pos &&
} selectedEntity &&
if (selectedEntity.type !== 'regional_guidance' && selectedEntity.type !== 'layer') { (selectedEntity.type === 'regional_guidance' || selectedEntity.type === 'layer') &&
return; !getSpaceKey() &&
} getIsMouseDown()
) {
if (getSpaceKey()) { if (toolState.selected === 'brush') {
// No drawing when space is down - we are panning the stage if (getIsDrawing()) {
return; // Continue the last line
} maybeAddNextPoint(
selectedEntity,
if (!getIsMouseDown()) { pos,
return; getToolState,
} getLastAddedPoint,
setLastAddedPoint,
if (toolState.selected === 'brush') { onPointAddedToLine
if (getIsDrawing()) { );
// Continue the last line } else {
maybeAddNextPoint(selectedEntity, pos, getToolState, getLastAddedPoint, setLastAddedPoint, onPointAddedToLine); const bbox = getBbox();
} else { // Start a new line
const bbox = getBbox(); onBrushLineAdded(
// Start a new line {
onBrushLineAdded( id: selectedEntity.id,
{ points: [
id: selectedEntity.id, pos.x - selectedEntity.x,
points: [ pos.y - selectedEntity.y,
pos.x - selectedEntity.x, pos.x - selectedEntity.x,
pos.y - selectedEntity.y, pos.y - selectedEntity.y,
pos.x - selectedEntity.x, ],
pos.y - selectedEntity.y, width: toolState.brush.width,
], color: getCurrentFill(),
width: toolState.brush.width, clip: {
color: getCurrentFill(), x: bbox.x,
clip: { y: bbox.y,
x: bbox.x, width: bbox.width,
y: bbox.y, height: bbox.height,
width: bbox.width, },
height: bbox.height,
}, },
}, selectedEntity.type
selectedEntity.type );
); setLastAddedPoint(pos);
setLastAddedPoint(pos); setIsDrawing(true);
setIsDrawing(true); }
}
if (toolState.selected === 'eraser') {
if (getIsDrawing()) {
// Continue the last line
maybeAddNextPoint(
selectedEntity,
pos,
getToolState,
getLastAddedPoint,
setLastAddedPoint,
onPointAddedToLine
);
} else {
const bbox = getBbox();
// Start a new line
onEraserLineAdded(
{
id: selectedEntity.id,
points: [
pos.x - selectedEntity.x,
pos.y - selectedEntity.y,
pos.x - selectedEntity.x,
pos.y - selectedEntity.y,
],
width: toolState.eraser.width,
clip: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
},
selectedEntity.type
);
setLastAddedPoint(pos);
setIsDrawing(true);
}
} }
} }
if (toolState.selected === 'eraser') { renderToolPreview(
if (getIsDrawing()) { stage,
// Continue the last line getToolState(),
maybeAddNextPoint(selectedEntity, pos, getToolState, getLastAddedPoint, setLastAddedPoint, onPointAddedToLine); getCurrentFill(),
} else { getSelectedEntity(),
const bbox = getBbox(); getLastCursorPos(),
// Start a new line getLastAddedPoint(),
onEraserLineAdded( getIsDrawing(),
{ getIsMouseDown()
id: selectedEntity.id, );
points: [
pos.x - selectedEntity.x,
pos.y - selectedEntity.y,
pos.x - selectedEntity.x,
pos.y - selectedEntity.y,
],
width: toolState.eraser.width,
clip: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
},
selectedEntity.type
);
setLastAddedPoint(pos);
setIsDrawing(true);
}
}
}); });
//#region mouseleave //#region mouseleave
@ -450,24 +493,33 @@ export const setStageEventHandlers = ({
stage.findOne<Konva.Layer>(`#${PREVIEW_TOOL_GROUP_ID}`)?.visible(false); stage.findOne<Konva.Layer>(`#${PREVIEW_TOOL_GROUP_ID}`)?.visible(false);
if (!pos || !selectedEntity) { if (
return; pos &&
} selectedEntity &&
if (selectedEntity.type !== 'regional_guidance' && selectedEntity.type !== 'layer') { (selectedEntity.type === 'regional_guidance' || selectedEntity.type === 'layer') &&
return; !getSpaceKey() &&
} getIsMouseDown()
if (getSpaceKey()) { ) {
// No drawing when space is down - we are panning the stage if (getIsMouseDown()) {
return; if (toolState.selected === 'brush') {
} onPointAddedToLine({ id: selectedEntity.id, point: [pos.x, pos.y] }, selectedEntity.type);
if (getIsMouseDown()) { }
if (toolState.selected === 'brush') { if (toolState.selected === 'eraser') {
onPointAddedToLine({ id: selectedEntity.id, point: [pos.x, pos.y] }, selectedEntity.type); onPointAddedToLine({ id: selectedEntity.id, point: [pos.x, pos.y] }, selectedEntity.type);
} }
if (toolState.selected === 'eraser') {
onPointAddedToLine({ id: selectedEntity.id, point: [pos.x, pos.y] }, selectedEntity.type);
} }
} }
renderToolPreview(
stage,
getToolState(),
getCurrentFill(),
getSelectedEntity(),
getLastCursorPos(),
getLastAddedPoint(),
getIsDrawing(),
getIsMouseDown()
);
}); });
//#region wheel //#region wheel
@ -489,31 +541,40 @@ export const setStageEventHandlers = ({
} else { } else {
// We need the absolute cursor position - not the scaled position // We need the absolute cursor position - not the scaled position
const cursorPos = stage.getPointerPosition(); const cursorPos = stage.getPointerPosition();
if (!cursorPos) { if (cursorPos) {
return; // Stage's x and y scale are always the same
} const stageScale = stage.scaleX();
// Stage's x and y scale are always the same // When wheeling on trackpad, e.evt.ctrlKey is true - in that case, let's reverse the direction
const stageScale = stage.scaleX(); const delta = e.evt.ctrlKey ? -e.evt.deltaY : e.evt.deltaY;
// When wheeling on trackpad, e.evt.ctrlKey is true - in that case, let's reverse the direction const mousePointTo = {
const delta = e.evt.ctrlKey ? -e.evt.deltaY : e.evt.deltaY; x: (cursorPos.x - stage.x()) / stageScale,
const mousePointTo = { y: (cursorPos.y - stage.y()) / stageScale,
x: (cursorPos.x - stage.x()) / stageScale, };
y: (cursorPos.y - stage.y()) / stageScale, const newScale = clamp(stageScale * CANVAS_SCALE_BY ** delta, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE);
}; const newPos = {
const newScale = clamp(stageScale * CANVAS_SCALE_BY ** delta, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE); x: cursorPos.x - mousePointTo.x * newScale,
const newPos = { y: cursorPos.y - mousePointTo.y * newScale,
x: cursorPos.x - mousePointTo.x * newScale, };
y: cursorPos.y - mousePointTo.y * newScale,
};
stage.scaleX(newScale); stage.scaleX(newScale);
stage.scaleY(newScale); stage.scaleY(newScale);
stage.position(newPos); stage.position(newPos);
setStageAttrs({ ...newPos, width: stage.width(), height: stage.height(), scale: newScale }); setStageAttrs({ ...newPos, width: stage.width(), height: stage.height(), scale: newScale });
renderBackgroundLayer(stage); renderBackgroundLayer(stage);
scaleToolPreview(stage, getToolState()); scaleToolPreview(stage, getToolState());
renderDocumentBoundsOverlay(stage, getDocument); renderDocumentBoundsOverlay(stage, getDocument);
}
} }
renderToolPreview(
stage,
getToolState(),
getCurrentFill(),
getSelectedEntity(),
getLastCursorPos(),
getLastAddedPoint(),
getIsDrawing(),
getIsMouseDown()
);
}); });
//#region dragmove //#region dragmove
@ -527,6 +588,16 @@ export const setStageEventHandlers = ({
}); });
renderBackgroundLayer(stage); renderBackgroundLayer(stage);
renderDocumentBoundsOverlay(stage, getDocument); renderDocumentBoundsOverlay(stage, getDocument);
renderToolPreview(
stage,
getToolState(),
getCurrentFill(),
getSelectedEntity(),
getLastCursorPos(),
getLastAddedPoint(),
getIsDrawing(),
getIsMouseDown()
);
}); });
//#region dragend //#region dragend
@ -539,6 +610,16 @@ export const setStageEventHandlers = ({
height: stage.height(), height: stage.height(),
scale: stage.scaleX(), scale: stage.scaleX(),
}); });
renderToolPreview(
stage,
getToolState(),
getCurrentFill(),
getSelectedEntity(),
getLastCursorPos(),
getLastAddedPoint(),
getIsDrawing(),
getIsMouseDown()
);
}); });
//#region key //#region key
@ -558,21 +639,22 @@ export const setStageEventHandlers = ({
setToolBuffer(getToolState().selected); setToolBuffer(getToolState().selected);
setTool('view'); setTool('view');
} else if (e.key === 'r') { } else if (e.key === 'r') {
// Fit & center the document on the stage const stageAttrs = fitDocumentToStage(stage, getDocument());
const width = stage.width(); setStageAttrs(stageAttrs);
const height = stage.height();
const document = getDocument();
const docWidthWithBuffer = document.width + DOCUMENT_FIT_PADDING_PX * 2;
const docHeightWithBuffer = document.height + DOCUMENT_FIT_PADDING_PX * 2;
const scale = Math.min(Math.min(width / docWidthWithBuffer, height / docHeightWithBuffer), 1);
const x = (width - docWidthWithBuffer * scale) / 2 + DOCUMENT_FIT_PADDING_PX * scale;
const y = (height - docHeightWithBuffer * scale) / 2 + DOCUMENT_FIT_PADDING_PX * scale;
stage.setAttrs({ x, y, width, height, scaleX: scale, scaleY: scale });
setStageAttrs({ x, y, width, height, scale });
scaleToolPreview(stage, getToolState()); scaleToolPreview(stage, getToolState());
renderBackgroundLayer(stage); renderBackgroundLayer(stage);
renderDocumentBoundsOverlay(stage, getDocument); renderDocumentBoundsOverlay(stage, getDocument);
} }
renderToolPreview(
stage,
getToolState(),
getCurrentFill(),
getSelectedEntity(),
getLastCursorPos(),
getLastAddedPoint(),
getIsDrawing(),
getIsMouseDown()
);
}; };
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
@ -589,6 +671,16 @@ export const setStageEventHandlers = ({
setTool(toolBuffer ?? 'move'); setTool(toolBuffer ?? 'move');
setToolBuffer(null); setToolBuffer(null);
} }
renderToolPreview(
stage,
getToolState(),
getCurrentFill(),
getSelectedEntity(),
getLastCursorPos(),
getLastAddedPoint(),
getIsDrawing(),
getIsMouseDown()
);
}; };
window.addEventListener('keyup', onKeyUp); window.addEventListener('keyup', onKeyUp);

View File

@ -6,8 +6,14 @@ import {
RG_LAYER_OBJECT_GROUP_NAME, RG_LAYER_OBJECT_GROUP_NAME,
} from 'features/controlLayers/konva/naming'; } from 'features/controlLayers/konva/naming';
import { createBboxRect } from 'features/controlLayers/konva/renderers/objects'; import { createBboxRect } from 'features/controlLayers/konva/renderers/objects';
import { imageDataToDataURL } from "features/controlLayers/konva/util"; import { imageDataToDataURL } from 'features/controlLayers/konva/util';
import type { ControlAdapterEntity, LayerEntity, RegionEntity } from 'features/controlLayers/store/types'; import type {
BboxChangedArg,
CanvasEntity,
ControlAdapterEntity,
LayerEntity,
RegionEntity,
} from 'features/controlLayers/store/types';
import Konva from 'konva'; import Konva from 'konva';
import type { IRect } from 'konva/lib/types'; import type { IRect } from 'konva/lib/types';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
@ -186,10 +192,12 @@ const filterCAChildren = (node: Konva.Node): boolean => node.name() === CA_LAYER
*/ */
export const updateBboxes = ( export const updateBboxes = (
stage: Konva.Stage, stage: Konva.Stage,
entityStates: (ControlAdapterEntity | LayerEntity | RegionEntity)[], layers: LayerEntity[],
onBboxChanged: (layerId: string, bbox: IRect | null) => void controlAdapters: ControlAdapterEntity[],
regions: RegionEntity[],
onBboxChanged: (arg: BboxChangedArg, entityType: CanvasEntity['type']) => void
): void => { ): void => {
for (const entityState of entityStates) { for (const entityState of [...layers, ...controlAdapters, ...regions]) {
const konvaLayer = stage.findOne<Konva.Layer>(`#${entityState.id}`); const konvaLayer = stage.findOne<Konva.Layer>(`#${entityState.id}`);
assert(konvaLayer, `Layer ${entityState.id} not found in stage`); assert(konvaLayer, `Layer ${entityState.id} not found in stage`);
// We only need to recalculate the bbox if the layer has changed // We only need to recalculate the bbox if the layer has changed
@ -203,23 +211,29 @@ export const updateBboxes = (
if (entityState.type === 'layer') { if (entityState.type === 'layer') {
if (entityState.objects.length === 0) { if (entityState.objects.length === 0) {
// No objects - no bbox to calculate // No objects - no bbox to calculate
onBboxChanged(entityState.id, null); onBboxChanged({ id: entityState.id, bbox: null }, 'layer');
} else { } else {
onBboxChanged(entityState.id, getLayerBboxPixels(konvaLayer, filterLayerChildren)); onBboxChanged({ id: entityState.id, bbox: getLayerBboxPixels(konvaLayer, filterLayerChildren) }, 'layer');
} }
} else if (entityState.type === 'control_adapter') { } else if (entityState.type === 'control_adapter') {
if (!entityState.image && !entityState.processedImage) { if (!entityState.image && !entityState.processedImage) {
// No objects - no bbox to calculate // No objects - no bbox to calculate
onBboxChanged(entityState.id, null); onBboxChanged({ id: entityState.id, bbox: null }, 'control_adapter');
} else { } else {
onBboxChanged(entityState.id, getLayerBboxPixels(konvaLayer, filterCAChildren)); onBboxChanged(
{ id: entityState.id, bbox: getLayerBboxPixels(konvaLayer, filterCAChildren) },
'control_adapter'
);
} }
} else if (entityState.type === 'regional_guidance') { } else if (entityState.type === 'regional_guidance') {
if (entityState.objects.length === 0) { if (entityState.objects.length === 0) {
// No objects - no bbox to calculate // No objects - no bbox to calculate
onBboxChanged(entityState.id, null); onBboxChanged({ id: entityState.id, bbox: null }, 'regional_guidance');
} else { } else {
onBboxChanged(entityState.id, getLayerBboxPixels(konvaLayer, filterRGChildren)); onBboxChanged(
{ id: entityState.id, bbox: getLayerBboxPixels(konvaLayer, filterRGChildren) },
'regional_guidance'
);
} }
} }

View File

@ -0,0 +1,393 @@
import { $alt, $ctrl, $meta, $shift } from '@invoke-ai/ui-library';
import type { Store } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { $isDebugging } from 'app/store/nanostores/isDebugging';
import type { RootState } from 'app/store/store';
import { setStageEventHandlers } from 'features/controlLayers/konva/events';
import { renderBackgroundLayer } from 'features/controlLayers/konva/renderers/background';
import { updateBboxes } from 'features/controlLayers/konva/renderers/bbox';
import { renderControlAdapters } from 'features/controlLayers/konva/renderers/caLayer';
import { arrangeEntities } from 'features/controlLayers/konva/renderers/layers';
import {
renderBboxPreview,
renderDocumentBoundsOverlay,
scaleToolPreview,
} from 'features/controlLayers/konva/renderers/previewLayer';
import { renderLayers } from 'features/controlLayers/konva/renderers/rasterLayer';
import { renderRegions } from 'features/controlLayers/konva/renderers/rgLayer';
import { fitDocumentToStage } from 'features/controlLayers/konva/renderers/stage';
import {
$isDrawing,
$isMouseDown,
$lastAddedPoint,
$lastCursorPos,
$lastMouseDownPos,
$spaceKey,
$stageAttrs,
bboxChanged,
brushWidthChanged,
caBboxChanged,
caTranslated,
eraserWidthChanged,
layerBboxChanged,
layerBrushLineAdded,
layerEraserLineAdded,
layerLinePointAdded,
layerRectAdded,
layerTranslated,
rgBboxChanged,
rgBrushLineAdded,
rgEraserLineAdded,
rgLinePointAdded,
rgRectAdded,
rgTranslated,
toolBufferChanged,
toolChanged,
} from 'features/controlLayers/store/canvasV2Slice';
import type {
BboxChangedArg,
BrushLineAddedArg,
CanvasEntity,
CanvasEntityIdentifier,
CanvasV2State,
EraserLineAddedArg,
PointAddedToLineArg,
PosChangedArg,
RectShapeAddedArg,
Tool,
} from 'features/controlLayers/store/types';
import type Konva from 'konva';
import type { IRect } from 'konva/lib/types';
import { debounce } from 'lodash-es';
import type { RgbaColor } from 'react-colorful';
import { getImageDTO } from 'services/api/endpoints/images';
/**
* Initializes the canvas renderer. It subscribes to the redux store and listens for changes directly, bypassing the
* react rendering cycle entirely, improving canvas performance.
* @param store The Redux store
* @param stage The Konva stage
* @param container The stage's target container element
* @returns A cleanup function
*/
export const initializeRenderer = (
store: Store<RootState>,
stage: Konva.Stage,
container: HTMLDivElement | null
): (() => void) => {
const _log = logger('konva');
/**
* Logs a message to the console if debugging is enabled.
*/
const logIfDebugging = (message: string) => {
if ($isDebugging.get()) {
_log.trace(message);
}
};
logIfDebugging('Initializing renderer');
if (!container) {
// Nothing to clean up
logIfDebugging('No stage container, skipping initialization');
return () => {};
}
stage.container(container);
// Set up callbacks for various events
const onPosChanged = (arg: PosChangedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Position changed');
if (entityType === 'layer') {
dispatch(layerTranslated(arg));
} else if (entityType === 'control_adapter') {
dispatch(caTranslated(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgTranslated(arg));
}
};
const onBboxChanged = (arg: BboxChangedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Entity bbox changed');
if (entityType === 'layer') {
dispatch(layerBboxChanged(arg));
} else if (entityType === 'control_adapter') {
dispatch(caBboxChanged(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgBboxChanged(arg));
}
};
const onBrushLineAdded = (arg: BrushLineAddedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Brush line added');
if (entityType === 'layer') {
dispatch(layerBrushLineAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgBrushLineAdded(arg));
}
};
const onEraserLineAdded = (arg: EraserLineAddedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Eraser line added');
if (entityType === 'layer') {
dispatch(layerEraserLineAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgEraserLineAdded(arg));
}
};
const onPointAddedToLine = (arg: PointAddedToLineArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Point added to line');
if (entityType === 'layer') {
dispatch(layerLinePointAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgLinePointAdded(arg));
}
};
const onRectShapeAdded = (arg: RectShapeAddedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Rect shape added');
if (entityType === 'layer') {
dispatch(layerRectAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgRectAdded(arg));
}
};
const onBboxTransformed = (bbox: IRect) => {
logIfDebugging('Generation bbox transformed');
dispatch(bboxChanged(bbox));
};
const onBrushWidthChanged = (width: number) => {
logIfDebugging('Brush width changed');
dispatch(brushWidthChanged(width));
};
const onEraserWidthChanged = (width: number) => {
logIfDebugging('Eraser width changed');
dispatch(eraserWidthChanged(width));
};
const setTool = (tool: Tool) => {
logIfDebugging('Tool selection changed');
dispatch(toolChanged(tool));
};
const setToolBuffer = (toolBuffer: Tool | null) => {
logIfDebugging('Tool buffer changed');
dispatch(toolBufferChanged(toolBuffer));
};
const _getSelectedEntity = (canvasV2: CanvasV2State): CanvasEntity | null => {
const identifier = canvasV2.selectedEntityIdentifier;
let selectedEntity: CanvasEntity | null = null;
if (!identifier) {
selectedEntity = null;
} else if (identifier.type === 'layer') {
selectedEntity = canvasV2.layers.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'control_adapter') {
selectedEntity = canvasV2.controlAdapters.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'ip_adapter') {
selectedEntity = canvasV2.ipAdapters.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'regional_guidance') {
selectedEntity = canvasV2.regions.find((i) => i.id === identifier.id) ?? null;
} else {
selectedEntity = null;
}
logIfDebugging('Selected entity changed');
return selectedEntity;
};
const _getCurrentFill = (canvasV2: CanvasV2State, selectedEntity: CanvasEntity | null) => {
let currentFill: RgbaColor = canvasV2.tool.fill;
if (selectedEntity && selectedEntity.type === 'regional_guidance') {
currentFill = { ...selectedEntity.fill, a: canvasV2.settings.maskOpacity };
} else {
currentFill = canvasV2.tool.fill;
}
logIfDebugging('Current fill changed');
return currentFill;
};
const { getState, subscribe, dispatch } = store;
// Create closures for the rendering functions, used to check if specific parts of state have changed so we only
// render what needs to be rendered.
let prevCanvasV2 = getState().canvasV2;
let selectedEntityIdentifier: CanvasEntityIdentifier | null = prevCanvasV2.selectedEntityIdentifier;
let selectedEntity: CanvasEntity | null = _getSelectedEntity(prevCanvasV2);
let currentFill: RgbaColor = _getCurrentFill(prevCanvasV2, selectedEntity);
let didSelectedEntityChange: boolean = false;
// On the first render, we need to render everything.
let isFirstRender = true;
// Stage event listeners use a fully imperative approach to event handling, using these helpers to get state.
const getBbox = () => getState().canvasV2.bbox;
const getDocument = () => getState().canvasV2.document;
const getToolState = () => getState().canvasV2.tool;
const getSelectedEntity = () => selectedEntity;
const getCurrentFill = () => currentFill;
// Calculating bounding boxes is expensive, must be debounced to not block the UI thread.
// TODO(psyche): Figure out how to do this in a worker. Probably means running the renderer in a worker and sending
// the entire state over when needed.
const debouncedUpdateBboxes = debounce(updateBboxes, 300);
const cleanupListeners = setStageEventHandlers({
stage,
getToolState,
setTool,
setToolBuffer,
getIsDrawing: $isDrawing.get,
setIsDrawing: $isDrawing.set,
getIsMouseDown: $isMouseDown.get,
setIsMouseDown: $isMouseDown.set,
getSelectedEntity,
getLastAddedPoint: $lastAddedPoint.get,
setLastAddedPoint: $lastAddedPoint.set,
getLastCursorPos: $lastCursorPos.get,
setLastCursorPos: $lastCursorPos.set,
getLastMouseDownPos: $lastMouseDownPos.get,
setLastMouseDownPos: $lastMouseDownPos.set,
getSpaceKey: $spaceKey.get,
setStageAttrs: $stageAttrs.set,
getDocument,
getBbox,
onBrushLineAdded,
onEraserLineAdded,
onPointAddedToLine,
onRectShapeAdded,
onBrushWidthChanged,
onEraserWidthChanged,
getCurrentFill,
});
const renderCanvas = () => {
const { canvasV2 } = store.getState();
if (prevCanvasV2 === canvasV2 && !isFirstRender) {
logIfDebugging('No changes detected, skipping render');
return;
}
// We can save some cycles for specific renderers if we track whether the selected entity has changed.
if (canvasV2.selectedEntityIdentifier !== selectedEntityIdentifier) {
selectedEntityIdentifier = canvasV2.selectedEntityIdentifier;
selectedEntity = _getSelectedEntity(canvasV2);
didSelectedEntityChange = true;
} else {
didSelectedEntityChange = false;
}
// The current fill is either the tool fill or, if a regional guidance region is selected, the mask fill for that
// region. We need to manually sync this state.
if (isFirstRender || canvasV2.tool.fill !== prevCanvasV2.tool.fill || didSelectedEntityChange) {
currentFill = _getCurrentFill(canvasV2, selectedEntity);
}
if (
isFirstRender ||
canvasV2.layers !== prevCanvasV2.layers ||
canvasV2.tool.selected !== prevCanvasV2.tool.selected
) {
logIfDebugging('Rendering layers');
renderLayers(stage, canvasV2.layers, canvasV2.tool.selected, onPosChanged);
}
if (
isFirstRender ||
canvasV2.regions !== prevCanvasV2.regions ||
canvasV2.settings.maskOpacity !== prevCanvasV2.settings.maskOpacity ||
canvasV2.tool.selected !== prevCanvasV2.tool.selected ||
didSelectedEntityChange
) {
logIfDebugging('Rendering regions');
renderRegions(
stage,
canvasV2.regions,
canvasV2.settings.maskOpacity,
canvasV2.tool.selected,
selectedEntity,
onPosChanged
);
}
if (isFirstRender || canvasV2.controlAdapters !== prevCanvasV2.controlAdapters) {
logIfDebugging('Rendering control adapters');
renderControlAdapters(stage, canvasV2.controlAdapters, getImageDTO);
}
if (isFirstRender || canvasV2.document !== prevCanvasV2.document) {
logIfDebugging('Rendering document bounds overlay');
renderDocumentBoundsOverlay(stage, getDocument);
}
if (isFirstRender || canvasV2.bbox !== prevCanvasV2.bbox || canvasV2.tool.selected !== prevCanvasV2.tool.selected) {
logIfDebugging('Rendering generation bbox');
renderBboxPreview(
stage,
canvasV2.bbox,
canvasV2.tool.selected,
getBbox,
onBboxTransformed,
$shift.get,
$ctrl.get,
$meta.get,
$alt.get
);
}
if (
isFirstRender ||
canvasV2.layers !== prevCanvasV2.layers ||
canvasV2.controlAdapters !== prevCanvasV2.controlAdapters ||
canvasV2.regions !== prevCanvasV2.regions
) {
logIfDebugging('Updating entity bboxes');
debouncedUpdateBboxes(stage, canvasV2.layers, canvasV2.controlAdapters, canvasV2.regions, onBboxChanged);
}
if (
isFirstRender ||
canvasV2.layers !== prevCanvasV2.layers ||
canvasV2.controlAdapters !== prevCanvasV2.controlAdapters ||
canvasV2.regions !== prevCanvasV2.regions
) {
logIfDebugging('Arranging entities');
arrangeEntities(stage, canvasV2.layers, canvasV2.controlAdapters, canvasV2.regions);
}
prevCanvasV2 = canvasV2;
if (isFirstRender) {
isFirstRender = false;
}
};
// We can use a resize observer to ensure the stage always fits the container. We also need to re-render the bg and
// document bounds overlay when the stage is resized.
const fitStageToContainer = () => {
stage.width(container.offsetWidth);
stage.height(container.offsetHeight);
$stageAttrs.set({
x: stage.x(),
y: stage.y(),
width: stage.width(),
height: stage.height(),
scale: stage.scaleX(),
});
renderBackgroundLayer(stage);
renderDocumentBoundsOverlay(stage, getDocument);
};
const resizeObserver = new ResizeObserver(fitStageToContainer);
resizeObserver.observe(container);
fitStageToContainer();
const unsubscribeRenderer = subscribe(renderCanvas);
logIfDebugging('First render of konva stage');
// On first render, the document should be fit to the stage.
const stageAttrs = fitDocumentToStage(stage, prevCanvasV2.document);
// The HUD displays some of the stage attributes, so we need to update it here.
$stageAttrs.set(stageAttrs);
scaleToolPreview(stage, getToolState());
renderCanvas();
return () => {
logIfDebugging('Cleaning up konva renderer');
unsubscribeRenderer();
cleanupListeners();
resizeObserver.disconnect();
};
};

View File

@ -0,0 +1,16 @@
import { DOCUMENT_FIT_PADDING_PX } from 'features/controlLayers/konva/constants';
import type { CanvasV2State, StageAttrs } from 'features/controlLayers/store/types';
import type Konva from 'konva';
export const fitDocumentToStage = (stage: Konva.Stage, document: CanvasV2State['document']): StageAttrs => {
// Fit & center the document on the stage
const width = stage.width();
const height = stage.height();
const docWidthWithBuffer = document.width + DOCUMENT_FIT_PADDING_PX * 2;
const docHeightWithBuffer = document.height + DOCUMENT_FIT_PADDING_PX * 2;
const scale = Math.min(Math.min(width / docWidthWithBuffer, height / docHeightWithBuffer), 1);
const x = (width - docWidthWithBuffer * scale) / 2 + DOCUMENT_FIT_PADDING_PX * scale;
const y = (height - docHeightWithBuffer * scale) / 2 + DOCUMENT_FIT_PADDING_PX * scale;
stage.setAttrs({ x, y, width, height, scaleX: scale, scaleY: scale });
return { x, y, width, height, scale };
};

View File

@ -881,7 +881,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 BboxChangedArg = { id: string; bbox: IRect }; export type BboxChangedArg = { id: string; bbox: Rect | null };
export type EraserLineAddedArg = { export type EraserLineAddedArg = {
id: string; id: string;
points: [number, number, number, number]; points: [number, number, number, number];