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

This commit is contained in:
psychedelicious 2024-04-17 11:57:57 +10:00 committed by Kent Keirsey
parent bbbb5479e8
commit 525e6d697c
2 changed files with 111 additions and 69 deletions

View File

@ -6,6 +6,11 @@ import { rgbColorToString } from 'features/canvas/util/colorToString';
import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition';
import {
$cursorPosition,
BRUSH_PREVIEW_BORDER_INNER_ID,
BRUSH_PREVIEW_BORDER_OUTER_ID,
BRUSH_PREVIEW_FILL_ID,
BRUSH_PREVIEW_LAYER_ID,
getPromptRegionLayerObjectGroupId,
layerBboxChanged,
layerSelected,
layerTranslated,
@ -17,6 +22,7 @@ import Konva from 'konva';
import type { Node, NodeConfig } from 'konva/lib/Node';
import { atom } from 'nanostores';
import { useLayoutEffect } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useMouseDown, useMouseEnter, useMouseLeave, useMouseMove, useMouseUp } from './mouseEventHooks';
@ -29,10 +35,6 @@ type Props = {
export const selectPromptLayerObjectGroup = (item: Node<NodeConfig>) =>
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';
@ -41,8 +43,8 @@ const isKonvaRect = (node: Node<NodeConfig>): node is Konva.Rect => node.nodeTyp
const $brushPreviewNodes = atom<{
layer: Konva.Layer;
fill: Konva.Circle;
outlineInner: Konva.Circle;
outlineOuter: Konva.Circle;
borderInner: Konva.Circle;
borderOuter: Konva.Circle;
} | null>(null);
export const LogicalStage = (props: Props) => {
@ -68,32 +70,32 @@ export const LogicalStage = (props: Props) => {
container: props.container,
});
const brushPreviewLayer = new Konva.Layer({ id: 'brushPreviewLayer' });
const brushPreviewLayer = new Konva.Layer({ id: BRUSH_PREVIEW_LAYER_ID });
stage.add(brushPreviewLayer);
const fill = new Konva.Circle({
id: BRUSH_PREVIEW_FILL,
id: BRUSH_PREVIEW_FILL_ID,
listening: false,
strokeEnabled: false,
strokeHitEnabled: false,
});
const outlineInner = new Konva.Circle({
id: BRUSH_PREVIEW_OUTLINE_INNER,
const borderInner = new Konva.Circle({
id: BRUSH_PREVIEW_BORDER_INNER_ID,
listening: false,
stroke: 'rgba(0,0,0,1)',
strokeWidth: 1,
strokeEnabled: true,
});
const outlineOuter = new Konva.Circle({
id: BRUSH_PREVIEW_OUTLINE_OUTER,
const borderOuter = new Konva.Circle({
id: BRUSH_PREVIEW_BORDER_OUTER_ID,
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 });
brushPreviewLayer.add(borderInner);
brushPreviewLayer.add(borderOuter);
$brushPreviewNodes.set({ layer: brushPreviewLayer, fill, borderInner: borderInner, borderOuter: borderOuter });
$stage.set(stage);
@ -156,8 +158,8 @@ export const LogicalStage = (props: Props) => {
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({
brushPreviewNodes.borderInner.setAttrs({ x: cursorPosition.x, y: cursorPosition.y, radius: state.brushSize / 2 });
brushPreviewNodes.borderOuter.setAttrs({
x: cursorPosition.x,
y: cursorPosition.y,
radius: state.brushSize / 2 + 1,
@ -189,6 +191,8 @@ export const LogicalStage = (props: Props) => {
name: REGIONAL_PROMPT_LAYER_NAME,
draggable: true,
listening: reduxLayer.id === state.selectedLayer,
x: reduxLayer.x,
y: reduxLayer.y,
});
konvaLayer.on('dragmove', function (e) {
dispatch(
@ -212,12 +216,22 @@ export const LogicalStage = (props: Props) => {
return pos;
});
stage.add(konvaLayer);
konvaLayer.add(
new Konva.Group({
id: getPromptRegionLayerObjectGroupId(reduxLayer.id, uuidv4()),
name: REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME,
listening: false,
})
);
$brushPreviewNodes.get()?.layer.moveToTop();
} else {
konvaLayer.listening(reduxLayer.id === state.selectedLayer);
konvaLayer.x(reduxLayer.x);
konvaLayer.y(reduxLayer.y);
}
const color = rgbColorToString(reduxLayer.color);
const konvaObjectGroup = konvaLayer.findOne(`.${REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME}`) as Konva.Group;
// Remove deleted objects
const objectIds = reduxLayer.objects.map((o) => o.id);
@ -228,35 +242,41 @@ export const LogicalStage = (props: Props) => {
}
for (const reduxObject of reduxLayer.objects) {
// TODO: Handle rects, images, etc
if (reduxObject.kind !== 'line') {
return;
}
let konvaObject = stage.findOne(`#${reduxObject.id}`) as Konva.Line | undefined;
const konvaObject = stage.findOne(`#${reduxObject.id}`) as Konva.Line | undefined;
if (!konvaObject) {
konvaObject = new Konva.Line({
id: reduxObject.id,
key: reduxObject.id,
name: `${reduxLayer.id}-object`,
points: reduxObject.points,
strokeWidth: reduxObject.strokeWidth,
stroke: color,
tension: 0,
lineCap: 'round',
lineJoin: 'round',
shadowForStrokeEnabled: false,
globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out',
listening: false,
visible: reduxLayer.isVisible,
});
konvaLayer.add(konvaObject);
// This object hasn't been added to the konva state yet.
konvaObjectGroup.add(
new Konva.Line({
id: reduxObject.id,
key: reduxObject.id,
name: `${reduxLayer.id}-object`,
points: reduxObject.points,
strokeWidth: reduxObject.strokeWidth,
stroke: color,
tension: 0,
lineCap: 'round',
lineJoin: 'round',
shadowForStrokeEnabled: false,
globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out',
listening: false,
visible: reduxLayer.isVisible,
})
);
} else {
// Only update the points if they have changed. The point values are never mutated, they are only added to the array.
if (konvaObject.points().length !== reduxObject.points.length) {
konvaObject.points(reduxObject.points);
}
// Only update the color if it has changed.
if (konvaObject.stroke() !== color) {
konvaObject.stroke(color);
}
// Only update layer visibility if it has changed.
if (konvaObject.visible() !== reduxLayer.isVisible) {
konvaObject.visible(reduxLayer.isVisible);
}
@ -269,6 +289,14 @@ export const LogicalStage = (props: Props) => {
if (!stage) {
return;
}
stage.container().style.cursor = state.tool === 'move' ? 'default' : 'none';
}, [stage, state.tool]);
useLayoutEffect(() => {
console.log('bbox effect');
if (!stage) {
return;
}
if (state.tool !== 'move') {
// Tool was just changed to something other than move - hide all layer bounding boxes

View File

@ -83,12 +83,22 @@ export const regionalPromptsSlice = createSlice({
initialState: initialRegionalPromptsState,
reducers: {
layerAdded: {
reducer: (state, action: PayloadAction<Layer['kind'], string, { id: string }>) => {
const newLayer = buildLayer(action.meta.id, action.payload, state.layers.length);
state.layers.push(newLayer);
state.selectedLayer = newLayer.id;
reducer: (state, action: PayloadAction<Layer['kind'], string, { uuid: string; color: RgbColor }>) => {
const layer: PromptRegionLayer = {
id: getPromptRegionLayerId(action.meta.uuid),
isVisible: true,
bbox: null,
kind: action.payload,
prompt: '',
objects: [],
color: action.meta.color,
x: 0,
y: 0,
};
state.layers.push(layer);
state.selectedLayer = layer.id;
},
prepare: (payload: Layer['kind']) => ({ payload, meta: { id: uuidv4() } }),
prepare: (payload: Layer['kind']) => ({ payload, meta: { uuid: uuidv4(), color: LayerColors.next() } }),
},
layerSelected: (state, action: PayloadAction<string>) => {
state.selectedLayer = action.payload;
@ -166,20 +176,21 @@ export const regionalPromptsSlice = createSlice({
layer.color = color;
},
lineAdded: {
reducer: (state, action: PayloadAction<[number, number], string, { id: string }>) => {
reducer: (state, action: PayloadAction<[number, number], string, { uuid: string }>) => {
const layer = state.layers.find((l) => l.id === state.selectedLayer);
if (!layer || layer.kind !== 'promptRegionLayer') {
return;
}
const lineId = getPromptRegionLayerLineId(layer.id, action.meta.uuid);
layer.objects.push({
kind: 'line',
tool: state.tool,
id: action.meta.id,
id: lineId,
points: [action.payload[0] - layer.x, action.payload[1] - layer.y],
strokeWidth: state.brushSize,
});
},
prepare: (payload: [number, number]) => ({ payload, meta: { id: uuidv4() } }),
prepare: (payload: [number, number]) => ({ payload, meta: { uuid: uuidv4() } }),
},
pointsAdded: (state, action: PayloadAction<[number, number]>) => {
const layer = state.layers.find((l) => l.id === state.selectedLayer);
@ -204,33 +215,29 @@ export const regionalPromptsSlice = createSlice({
},
});
const DEFAULT_COLORS = [
{ r: 200, g: 0, b: 0 },
{ r: 0, g: 200, b: 0 },
{ r: 0, g: 0, b: 200 },
{ r: 200, g: 200, b: 0 },
{ r: 0, g: 200, b: 200 },
{ r: 200, g: 0, b: 200 },
];
const buildLayer = (id: string, kind: Layer['kind'], layerCount: number): Layer => {
if (kind === 'promptRegionLayer') {
const color = DEFAULT_COLORS[layerCount % DEFAULT_COLORS.length];
assert(color, 'Color not found');
return {
id,
isVisible: true,
bbox: null,
kind,
prompt: '',
objects: [],
color,
x: 0,
y: 0,
};
/**
* This class is used to cycle through a set of colors for the prompt region layers.
*/
class LayerColors {
static COLORS: RgbColor[] = [
{ r: 200, g: 0, b: 0 },
{ r: 0, g: 200, b: 0 },
{ r: 0, g: 0, b: 200 },
{ r: 200, g: 200, b: 0 },
{ r: 0, g: 200, b: 200 },
{ r: 200, g: 0, b: 200 },
];
static i = this.COLORS.length - 1;
/**
* Get the next color in the sequence.
*/
static next(): RgbColor {
this.i = (this.i + 1) % this.COLORS.length;
const color = this.COLORS[this.i];
assert(color);
return color;
}
assert(false, `Unknown layer kind: ${kind}`);
};
}
export const {
layerAdded,
@ -276,5 +283,12 @@ export const getStage = (): Stage => {
assert(stage);
return stage;
};
export const BRUSH_PREVIEW_LAYER_ID = 'brushPreviewLayer';
export const BRUSH_PREVIEW_FILL_ID = 'brushPreviewFill';
export const BRUSH_PREVIEW_BORDER_INNER_ID = 'brushPreviewBorderInner';
export const BRUSH_PREVIEW_BORDER_OUTER_ID = 'brushPreviewBorderOuter';
export const REGIONAL_PROMPT_LAYER_NAME = 'regionalPromptLayer';
export const REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME = 'regionalPromptLayerObjectGroup';
export const REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME = 'regionalPromptLayerObjectGroup';
export const getPromptRegionLayerId = (layerId: string) => `layer_${layerId}`;
export const getPromptRegionLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`;
export const getPromptRegionLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`;