feat(ui): re-implement with imperative konva api (wip)

This commit is contained in:
psychedelicious 2024-04-16 23:06:48 +10:00 committed by Kent Keirsey
parent ae7797f662
commit bbbb5479e8
2 changed files with 152 additions and 66 deletions

View File

@ -1,5 +1,6 @@
import { chakra } from '@invoke-ai/ui-library'; import { chakra } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { getStore } from 'app/store/nanostores/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { rgbColorToString } from 'features/canvas/util/colorToString'; import { rgbColorToString } from 'features/canvas/util/colorToString';
import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition'; import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition';
@ -14,7 +15,6 @@ import {
import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox'; import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox';
import Konva from 'konva'; import Konva from 'konva';
import type { Node, NodeConfig } from 'konva/lib/Node'; import type { Node, NodeConfig } from 'konva/lib/Node';
import type { StageConfig } from 'konva/lib/Stage';
import { atom } from 'nanostores'; import { atom } from 'nanostores';
import { useLayoutEffect } from 'react'; import { useLayoutEffect } from 'react';
@ -22,18 +22,6 @@ import { useMouseDown, useMouseEnter, useMouseLeave, useMouseMove, useMouseUp }
export const $stage = atom<Konva.Stage | null>(null); export const $stage = atom<Konva.Stage | null>(null);
const initStage = (container: StageConfig['container']) => {
const stage = new Konva.Stage({
container,
});
$stage.set(stage);
const layer = new Konva.Layer();
const circle = new Konva.Circle({ id: 'cursor', radius: 5, fill: 'red' });
layer.add(circle);
stage.add(layer);
};
type Props = { type Props = {
container: HTMLDivElement | null; container: HTMLDivElement | null;
}; };
@ -41,6 +29,22 @@ type Props = {
export const selectPromptLayerObjectGroup = (item: Node<NodeConfig>) => export const selectPromptLayerObjectGroup = (item: Node<NodeConfig>) =>
item.name() !== REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME; item.name() !== REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME;
const BRUSH_PREVIEW_FILL = 'brushPreviewFill';
const BRUSH_PREVIEW_OUTLINE_INNER = 'brushPreviewOutlineInner';
const BRUSH_PREVIEW_OUTLINE_OUTER = 'brushPreviewOutlineOuter';
const isKonvaLayer = (node: Node<NodeConfig>): node is Konva.Layer => node.nodeType === 'Layer';
const isKonvaLine = (node: Node<NodeConfig>): node is Konva.Line => node.nodeType === 'Line';
const isKonvaGroup = (node: Node<NodeConfig>): node is Konva.Group => node.nodeType === 'Group';
const isKonvaRect = (node: Node<NodeConfig>): node is Konva.Rect => node.nodeType === 'Rect';
const $brushPreviewNodes = atom<{
layer: Konva.Layer;
fill: Konva.Circle;
outlineInner: Konva.Circle;
outlineOuter: Konva.Circle;
} | null>(null);
export const LogicalStage = (props: Props) => { export const LogicalStage = (props: Props) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const width = useAppSelector((s) => s.generation.width); const width = useAppSelector((s) => s.generation.width);
@ -59,7 +63,40 @@ export const LogicalStage = (props: Props) => {
if (!props.container) { if (!props.container) {
return; return;
} }
initStage(props.container);
const stage = new Konva.Stage({
container: props.container,
});
const brushPreviewLayer = new Konva.Layer({ id: 'brushPreviewLayer' });
stage.add(brushPreviewLayer);
const fill = new Konva.Circle({
id: BRUSH_PREVIEW_FILL,
listening: false,
strokeEnabled: false,
strokeHitEnabled: false,
});
const outlineInner = new Konva.Circle({
id: BRUSH_PREVIEW_OUTLINE_INNER,
listening: false,
stroke: 'rgba(0,0,0,1)',
strokeWidth: 1,
strokeEnabled: true,
});
const outlineOuter = new Konva.Circle({
id: BRUSH_PREVIEW_OUTLINE_OUTER,
listening: false,
stroke: 'rgba(255,255,255,0.8)',
strokeWidth: 1,
strokeEnabled: true,
});
brushPreviewLayer.add(fill);
brushPreviewLayer.add(outlineInner);
brushPreviewLayer.add(outlineOuter);
$brushPreviewNodes.set({ layer: brushPreviewLayer, fill, outlineInner, outlineOuter });
$stage.set(stage);
return () => { return () => {
const stage = $stage.get(); const stage = $stage.get();
if (!stage) { if (!stage) {
@ -99,17 +136,33 @@ export const LogicalStage = (props: Props) => {
}, [stage, width, height, props.container]); }, [stage, width, height, props.container]);
useLayoutEffect(() => { useLayoutEffect(() => {
console.log('cursor effect'); console.log('brush preview effect');
if (!stage || !cursorPosition) { const brushPreviewNodes = $brushPreviewNodes.get();
brushPreviewNodes?.layer.visible(state.tool !== 'move');
if (!stage || !cursorPosition || !brushPreviewNodes) {
return; return;
} }
const cursor = stage.findOne('#cursor'); const color = getStore()
if (!cursor) { .getState()
.regionalPrompts.layers.find((l) => l.id === state.selectedLayer)?.color;
if (!color) {
return; return;
} }
cursor.x(cursorPosition?.x); const fill = rgbColorToString(color);
cursor.y(cursorPosition?.y); brushPreviewNodes.fill.setAttrs({
}, [cursorPosition, stage]); x: cursorPosition.x,
y: cursorPosition.y,
radius: state.brushSize / 2,
fill,
globalCompositeOperation: state.tool === 'brush' ? 'source-over' : 'destination-out',
});
brushPreviewNodes.outlineInner.setAttrs({ x: cursorPosition.x, y: cursorPosition.y, radius: state.brushSize / 2 });
brushPreviewNodes.outlineOuter.setAttrs({
x: cursorPosition.x,
y: cursorPosition.y,
radius: state.brushSize / 2 + 1,
});
}, [cursorPosition, stage, state.brushSize, state.selectedLayer, state.tool]);
useLayoutEffect(() => { useLayoutEffect(() => {
console.log('obj effect'); console.log('obj effect');
@ -117,15 +170,36 @@ export const LogicalStage = (props: Props) => {
return; return;
} }
// TODO: Handle layer getting deleted and reset const reduxLayerIds = state.layers.map((l) => l.id);
for (const l of state.layers) {
let layer = stage.findOne(`#${l.id}`) as Konva.Layer | null; // Remove deleted layers - we know these are of type Layer
if (!layer) { for (const konvaLayer of stage.find(`.${REGIONAL_PROMPT_LAYER_NAME}`) as Konva.Layer[]) {
layer = new Konva.Layer({ id: l.id, name: REGIONAL_PROMPT_LAYER_NAME, draggable: true }); if (!reduxLayerIds.includes(konvaLayer.id())) {
layer.on('dragmove', (e) => { konvaLayer.destroy();
dispatch(layerTranslated({ layerId: l.id, x: e.target.x(), y: e.target.y() })); }
}
for (const reduxLayer of state.layers) {
let konvaLayer = stage.findOne(`#${reduxLayer.id}`) as Konva.Layer | undefined;
// New layer - create a new Konva layer
if (!konvaLayer) {
konvaLayer = new Konva.Layer({
id: reduxLayer.id,
name: REGIONAL_PROMPT_LAYER_NAME,
draggable: true,
listening: reduxLayer.id === state.selectedLayer,
}); });
layer.dragBoundFunc(function (pos) { konvaLayer.on('dragmove', function (e) {
dispatch(
layerTranslated({
layerId: reduxLayer.id,
x: e.target.x(),
y: e.target.y(),
})
);
});
konvaLayer.dragBoundFunc(function (pos) {
const cursorPos = getScaledCursorPosition(stage); const cursorPos = getScaledCursorPosition(stage);
if (!cursorPos) { if (!cursorPos) {
return this.getAbsolutePosition(); return this.getAbsolutePosition();
@ -137,44 +211,55 @@ export const LogicalStage = (props: Props) => {
return pos; return pos;
}); });
stage.add(layer); stage.add(konvaLayer);
} $brushPreviewNodes.get()?.layer.moveToTop();
if (state.tool === 'move') {
layer.listening(true);
} else { } else {
layer.listening(l.id === state.selectedLayer); konvaLayer.listening(reduxLayer.id === state.selectedLayer);
} }
for (const o of l.objects) { const color = rgbColorToString(reduxLayer.color);
if (o.kind !== 'line') {
// Remove deleted objects
const objectIds = reduxLayer.objects.map((o) => o.id);
for (const objectNode of stage.find(`.${reduxLayer.id}-object`)) {
if (!objectIds.includes(objectNode.id())) {
objectNode.destroy();
}
}
for (const reduxObject of reduxLayer.objects) {
if (reduxObject.kind !== 'line') {
return; return;
} }
let obj = stage.findOne(`#${o.id}`) as Konva.Line | null; let konvaObject = stage.findOne(`#${reduxObject.id}`) as Konva.Line | undefined;
if (!obj) {
obj = new Konva.Line({ if (!konvaObject) {
id: o.id, konvaObject = new Konva.Line({
key: o.id, id: reduxObject.id,
strokeWidth: o.strokeWidth, key: reduxObject.id,
stroke: rgbColorToString(l.color), name: `${reduxLayer.id}-object`,
points: reduxObject.points,
strokeWidth: reduxObject.strokeWidth,
stroke: color,
tension: 0, tension: 0,
lineCap: 'round', lineCap: 'round',
lineJoin: 'round', lineJoin: 'round',
shadowForStrokeEnabled: false, shadowForStrokeEnabled: false,
globalCompositeOperation: o.tool === 'brush' ? 'source-over' : 'destination-out', globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out',
listening: false, listening: false,
visible: l.isVisible, visible: reduxLayer.isVisible,
}); });
layer.add(obj); konvaLayer.add(konvaObject);
} else {
if (konvaObject.points().length !== reduxObject.points.length) {
konvaObject.points(reduxObject.points);
} }
if (obj.points().length < o.points.length) { if (konvaObject.stroke() !== color) {
obj.points(o.points); konvaObject.stroke(color);
} }
if (obj.stroke() !== rgbColorToString(l.color)) { if (konvaObject.visible() !== reduxLayer.isVisible) {
obj.stroke(rgbColorToString(l.color)); konvaObject.visible(reduxLayer.isVisible);
} }
if (obj.visible() !== l.isVisible) {
obj.visible(l.isVisible);
} }
} }
} }
@ -186,25 +271,26 @@ export const LogicalStage = (props: Props) => {
} }
if (state.tool !== 'move') { if (state.tool !== 'move') {
// Tool was just changed to something other than move - hide all layer bounding boxes
for (const n of stage.find('.layer-bbox')) { for (const n of stage.find('.layer-bbox')) {
n.visible(false); n.visible(false);
} }
return; return;
} }
for (const layer of stage.find(`.${REGIONAL_PROMPT_LAYER_NAME}`) as Konva.Layer[]) { for (const konvaLayer of stage.find(`.${REGIONAL_PROMPT_LAYER_NAME}`) as Konva.Layer[]) {
const bbox = getKonvaLayerBbox(layer); const bbox = getKonvaLayerBbox(konvaLayer);
dispatch(layerBboxChanged({ layerId: layer.id(), bbox })); dispatch(layerBboxChanged({ layerId: konvaLayer.id(), bbox }));
let rect = layer.findOne('.layer-bbox') as Konva.Rect | null; let rect = konvaLayer.findOne('.layer-bbox') as Konva.Rect | undefined;
if (!rect) { if (!rect) {
rect = new Konva.Rect({ rect = new Konva.Rect({
id: `${layer.id()}-bbox`, id: `${konvaLayer.id()}-bbox`,
name: 'layer-bbox', name: 'layer-bbox',
strokeWidth: 1, strokeWidth: 1,
}); });
layer.add(rect); konvaLayer.add(rect);
layer.on('mousedown', () => { konvaLayer.on('mousedown', () => {
dispatch(layerSelected(layer.id())); dispatch(layerSelected(konvaLayer.id()));
}); });
} }
rect.visible(true); rect.visible(true);
@ -212,7 +298,7 @@ export const LogicalStage = (props: Props) => {
rect.y(bbox.y); rect.y(bbox.y);
rect.width(bbox.width); rect.width(bbox.width);
rect.height(bbox.height); rect.height(bbox.height);
rect.stroke(state.selectedLayer === layer.id() ? 'rgba(153, 187, 189, 1)' : 'rgba(255, 255, 255, 0.149)'); rect.stroke(state.selectedLayer === konvaLayer.id() ? 'rgba(153, 187, 189, 1)' : 'rgba(255, 255, 255, 0.149)');
} }
}, [dispatch, stage, state.tool, state.selectedLayer]); }, [dispatch, stage, state.tool, state.selectedLayer]);