feat: enables dragging of connector anchors

This commit is contained in:
Mark Mankarious
2023-10-07 18:58:42 +01:00
parent 9270924756
commit f80815976d
15 changed files with 233 additions and 124 deletions

View File

@@ -28,7 +28,7 @@
"react/no-unused-prop-types": ["warn"],
"react/require-default-props": [0],
"react/prop-types": [0],
"no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["draftState", "draft"] }],
"no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["draft"] }],
"arrow-body-style": ["error", "always"]
},
"ignorePatterns": [

View File

@@ -9,7 +9,7 @@ import {
setWindowCursor,
getAllAnchors
} from 'src/utils';
import { ModeActions } from 'src/types';
import { ModeActions, SceneItemTypeEnum } from 'src/types';
export const Connector: ModeActions = {
entry: () => {
@@ -34,37 +34,35 @@ export const Connector: ModeActions = {
});
if (itemAtTile && itemAtTile.type === 'NODE') {
const newMode = produce(uiState.mode, (draftState) => {
if (!draftState.connector) return;
const newMode = produce(uiState.mode, (draft) => {
if (!draft.connector) return;
draftState.connector.anchors[1] = {
draft.connector.anchors[1] = {
id: generateId(),
type: SceneItemTypeEnum.CONNECTOR_ANCHOR,
ref: {
type: 'NODE',
id: itemAtTile.id
}
};
draftState.connector.path = getConnectorPath({
anchors: draftState.connector.anchors,
nodes: scene.nodes,
allAnchors: getAllAnchors(scene.connectors)
});
});
uiState.actions.setMode(newMode);
} else {
const newMode = produce(uiState.mode, (draftState) => {
if (!draftState.connector) return;
const newMode = produce(uiState.mode, (draft) => {
if (!draft.connector) return;
draftState.connector.anchors[1] = {
draft.connector.anchors[1] = {
id: generateId(),
type: SceneItemTypeEnum.CONNECTOR_ANCHOR,
ref: {
type: 'TILE',
coords: uiState.mouse.position.tile
}
};
draftState.connector.path = getConnectorPath({
anchors: draftState.connector.anchors,
draft.connector.path = getConnectorPath({
anchors: draft.connector.anchors,
nodes: scene.nodes,
allAnchors: getAllAnchors(scene.connectors)
});
@@ -82,8 +80,8 @@ export const Connector: ModeActions = {
});
if (itemAtTile && itemAtTile.type === 'NODE') {
const newMode = produce(uiState.mode, (draftState) => {
draftState.connector = connectorInputToConnector(
const newMode = produce(uiState.mode, (draft) => {
draft.connector = connectorInputToConnector(
{
id: generateId(),
anchors: [
@@ -98,8 +96,8 @@ export const Connector: ModeActions = {
uiState.actions.setMode(newMode);
} else {
const newMode = produce(uiState.mode, (draftState) => {
draftState.connector = connectorInputToConnector(
const newMode = produce(uiState.mode, (draft) => {
draft.connector = connectorInputToConnector(
{
id: generateId(),
anchors: [

View File

@@ -1,6 +1,42 @@
import { produce } from 'immer';
import { ModeActions, ModeActionsAction } from 'src/types';
import { getItemAtTile, hasMovedTile } from 'src/utils';
import {
ConnectorAnchor,
ModeActions,
ModeActionsAction,
SceneItemTypeEnum,
SceneStore,
Coords
} from 'src/types';
import {
getItemAtTile,
hasMovedTile,
getAnchorAtTile,
getItemById,
generateId,
CoordsUtils
} from 'src/utils';
const getAnchor = (connectorId: string, tile: Coords, scene: SceneStore) => {
const connector = getItemById(scene.connectors, connectorId).item;
const anchor = getAnchorAtTile(tile, connector.anchors);
if (!anchor) {
const newAnchor: ConnectorAnchor = {
id: generateId(),
type: SceneItemTypeEnum.CONNECTOR_ANCHOR,
ref: { type: 'TILE', coords: tile }
};
const newConnector = produce(connector, (draft) => {
draft.anchors.push(newAnchor);
});
scene.actions.updateConnector(connector.id, newConnector);
return newAnchor;
}
return anchor;
};
const mousedown: ModeActionsAction = ({
uiState,
@@ -9,26 +45,23 @@ const mousedown: ModeActionsAction = ({
}) => {
if (uiState.mode.type !== 'CURSOR' || !isRendererInteraction) return;
const itemAtTile = getItemAtTile({
const item = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
if (itemAtTile) {
if (item) {
uiState.actions.setMode(
produce(uiState.mode, (draftState) => {
draftState.mousedownItem = {
type: itemAtTile.type,
id: itemAtTile.id
};
produce(uiState.mode, (draft) => {
draft.mousedownItem = item;
})
);
uiState.actions.setItemControls(itemAtTile);
uiState.actions.setItemControls(item);
} else {
uiState.actions.setMode(
produce(uiState.mode, (draftState) => {
draftState.mousedownItem = null;
produce(uiState.mode, (draft) => {
draft.mousedownItem = null;
})
);
@@ -46,23 +79,32 @@ export const Cursor: ModeActions = {
mousedown(state);
}
},
mousemove: ({ uiState }) => {
mousemove: ({ scene, uiState }) => {
if (uiState.mode.type !== 'CURSOR' || !hasMovedTile(uiState.mouse)) return;
const { mousedownItem } = uiState.mode;
let item = uiState.mode.mousedownItem;
if (mousedownItem) {
if (item?.type === 'CONNECTOR') {
const prevTile = uiState.mouse.delta
? CoordsUtils.subtract(
uiState.mouse.position.tile,
uiState.mouse.delta.tile
)
: CoordsUtils.zero();
const anchor = getAnchor(item.id, prevTile, scene);
item = anchor;
}
if (item) {
uiState.actions.setMode({
type: 'DRAG_ITEMS',
showCursor: true,
items: [mousedownItem],
items: [item],
isInitialMovement: true
});
}
},
mousedown: (state) => {
mousedown(state);
},
mousedown,
mouseup: ({ uiState, isRendererInteraction }) => {
if (uiState.mode.type !== 'CURSOR' || !isRendererInteraction) return;
@@ -102,8 +144,8 @@ export const Cursor: ModeActions = {
}
uiState.actions.setMode(
produce(uiState.mode, (draftState) => {
draftState.mousedownItem = null;
produce(uiState.mode, (draft) => {
draft.mousedownItem = null;
})
);
}

View File

@@ -1,9 +1,15 @@
import { produce } from 'immer';
import { ModeActions, Coords, SceneItemReference, SceneStore } from 'src/types';
import { getItemById, CoordsUtils, hasMovedTile } from 'src/utils';
import {
getItemById,
CoordsUtils,
hasMovedTile,
getAnchorParent
} from 'src/utils';
const dragItems = (
items: SceneItemReference[],
tile: Coords,
delta: Coords,
scene: SceneStore
) => {
@@ -26,6 +32,27 @@ const dragItems = (
scene.actions.updateTextBox(item.id, {
tile: CoordsUtils.add(textBox.tile, delta)
});
} else if (item.type === 'CONNECTOR_ANCHOR') {
const connector = getAnchorParent(item.id, scene.connectors);
const newConnector = produce(connector, (draft) => {
const { item: anchor, index: anchorIndex } = getItemById(
connector.anchors,
item.id
);
if (anchor.ref.type !== 'TILE') return;
draft.anchors[anchorIndex] = {
...anchor,
ref: {
type: 'TILE',
coords: tile
}
};
});
scene.actions.updateConnector(connector.id, newConnector);
}
});
};
@@ -50,11 +77,11 @@ export const DragItems: ModeActions = {
uiState.mouse.mousedown.tile
);
dragItems(uiState.mode.items, delta, scene);
dragItems(uiState.mode.items, uiState.mouse.position.tile, delta, scene);
uiState.actions.setMode(
produce(uiState.mode, (draftState) => {
draftState.isInitialMovement = false;
produce(uiState.mode, (draft) => {
draft.isInitialMovement = false;
})
);
@@ -65,7 +92,7 @@ export const DragItems: ModeActions = {
const delta = uiState.mouse.delta.tile;
dragItems(uiState.mode.items, delta, scene);
dragItems(uiState.mode.items, uiState.mouse.position.tile, delta, scene);
},
mouseup: ({ uiState }) => {
uiState.actions.setMode({

View File

@@ -44,17 +44,17 @@
// );
// }
// },
// mousedown: (draftState) => {
// if (draftState.mode.type !== 'LASSO') return;
// mousedown: (draft) => {
// if (draft.mode.type !== 'LASSO') return;
// if (draftState.mode.selection) {
// const isWithinSelection = isWithinBounds(draftState.mouse.position.tile, [
// draftState.mode.selection.startTile,
// draftState.mode.selection.endTile
// if (draft.mode.selection) {
// const isWithinSelection = isWithinBounds(draft.mouse.position.tile, [
// draft.mode.selection.startTile,
// draft.mode.selection.endTile
// ]);
// if (!isWithinSelection) {
// draftState.mode = {
// draft.mode = {
// type: 'CURSOR',
// showCursor: true,
// mousedown: null
@@ -64,13 +64,13 @@
// }
// if (isWithinSelection) {
// draftState.mode.isDragging = true;
// draft.mode.isDragging = true;
// return;
// }
// }
// draftState.mode = {
// draft.mode = {
// type: 'CURSOR',
// showCursor: true,
// mousedown: null

View File

@@ -13,10 +13,10 @@ export const Pan: ModeActions = {
if (uiState.mode.type !== 'PAN') return;
if (uiState.mouse.mousedown !== null) {
const newScroll = produce(uiState.scroll, (draftState) => {
draftState.position = uiState.mouse.delta?.screen
? CoordsUtils.add(draftState.position, uiState.mouse.delta.screen)
: draftState.position;
const newScroll = produce(uiState.scroll, (draft) => {
draft.position = uiState.mouse.delta?.screen
? CoordsUtils.add(draft.position, uiState.mouse.delta.screen)
: draft.position;
});
uiState.actions.setScroll(newScroll);

View File

@@ -34,8 +34,8 @@ export const PlaceElement: ModeActions = {
}
uiState.actions.setMode(
produce(uiState.mode, (draftState) => {
draftState.icon = null;
produce(uiState.mode, (draft) => {
draft.icon = null;
})
);
}

View File

@@ -19,10 +19,10 @@ export const DrawRectangle: ModeActions = {
)
return;
const newMode = produce(uiState.mode, (draftState) => {
if (!draftState.area) return;
const newMode = produce(uiState.mode, (draft) => {
if (!draft.area) return;
draftState.area.to = uiState.mouse.position.tile;
draft.area.to = uiState.mouse.position.tile;
});
uiState.actions.setMode(newMode);
@@ -30,8 +30,8 @@ export const DrawRectangle: ModeActions = {
mousedown: ({ uiState }) => {
if (uiState.mode.type !== 'RECTANGLE.DRAW') return;
const newMode = produce(uiState.mode, (draftState) => {
draftState.area = {
const newMode = produce(uiState.mode, (draft) => {
draft.area = {
from: uiState.mouse.position.tile,
to: uiState.mouse.position.tile
};

View File

@@ -100,8 +100,8 @@ export const TransformRectangle: ModeActions = {
Object.values(AnchorPositionsEnum)[activeAnchorIndex];
uiState.actions.setMode(
produce(uiState.mode, (draftState) => {
draftState.selectedAnchor = activeAnchor;
produce(uiState.mode, (draft) => {
draft.selectedAnchor = activeAnchor;
})
);
return;
@@ -139,8 +139,8 @@ export const TransformRectangle: ModeActions = {
if (uiState.mode.selectedAnchor) {
uiState.actions.setMode(
produce(uiState.mode, (draftState) => {
draftState.selectedAnchor = null;
produce(uiState.mode, (draft) => {
draft.selectedAnchor = null;
})
);
}

View File

@@ -48,32 +48,32 @@ const initialState = () => {
},
createNode: (node) => {
const newScene = produce(get(), (draftState) => {
draftState.nodes.push(nodeInputToNode(node));
const newScene = produce(get(), (draft) => {
draft.nodes.push(nodeInputToNode(node));
});
set({ nodes: newScene.nodes });
},
updateNode: (id, updates) => {
const newScene = produce(get(), (draftState) => {
const { item: node, index } = getItemById(draftState.nodes, id);
const newScene = produce(get(), (draft) => {
const { item: node, index } = getItemById(draft.nodes, id);
draftState.nodes[index] = {
draft.nodes[index] = {
...node,
...updates
};
draftState.connectors.forEach((connector, i) => {
draft.connectors.forEach((connector, i) => {
const needsUpdate = connector.anchors.find((anchor) => {
return anchor.ref.type === 'NODE' && anchor.ref.id === id;
});
if (needsUpdate) {
draftState.connectors[i].path = getConnectorPath({
draft.connectors[i].path = getConnectorPath({
anchors: connector.anchors,
nodes: draftState.nodes,
allAnchors: getAllAnchors(draftState.connectors)
nodes: draft.nodes,
allAnchors: getAllAnchors(draft.connectors)
});
}
});
@@ -83,12 +83,12 @@ const initialState = () => {
},
deleteNode: (id: string) => {
const newScene = produce(get(), (draftState) => {
const { index } = getItemById(draftState.nodes, id);
const newScene = produce(get(), (draft) => {
const { index } = getItemById(draft.nodes, id);
draftState.nodes.splice(index, 1);
draft.nodes.splice(index, 1);
draftState.connectors = draftState.connectors.filter(
draft.connectors = draft.connectors.filter(
(connector) => {
return !connector.anchors.find((anchor) => {
return anchor.ref.type === 'NODE' && anchor.ref.id === id;
@@ -101,12 +101,12 @@ const initialState = () => {
},
createConnector: (connector) => {
const newScene = produce(get(), (draftState) => {
draftState.connectors.push(
const newScene = produce(get(), (draft) => {
draft.connectors.push(
connectorInputToConnector(
connector,
draftState.nodes,
getAllAnchors(draftState.connectors)
draft.nodes,
getAllAnchors(draft.connectors)
)
);
});
@@ -115,56 +115,63 @@ const initialState = () => {
},
updateConnector: (id, updates) => {
const newScene = produce(get(), (draftState) => {
const { item: connector, index } = getItemById(
draftState.connectors,
id
);
const scene = get();
const { item: connector, index } = getItemById(scene.connectors, id);
draftState.connectors[index] = {
const newScene = produce(scene, (draft) => {
draft.connectors[index] = {
...connector,
...updates
};
if (updates.anchors) {
draft.connectors[index].path = getConnectorPath({
anchors: updates.anchors,
nodes: scene.nodes,
allAnchors: getAllAnchors(scene.connectors)
});
}
});
set({ connectors: newScene.connectors });
console.log(newScene.connectors)
},
deleteConnector: (id: string) => {
const newScene = produce(get(), (draftState) => {
const { index } = getItemById(draftState.connectors, id);
const newScene = produce(get(), (draft) => {
const { index } = getItemById(draft.connectors, id);
draftState.connectors.splice(index, 1);
draft.connectors.splice(index, 1);
});
set({ connectors: newScene.connectors });
},
createRectangle: (rectangle) => {
const newScene = produce(get(), (draftState) => {
draftState.rectangles.push(rectangleInputToRectangle(rectangle));
const newScene = produce(get(), (draft) => {
draft.rectangles.push(rectangleInputToRectangle(rectangle));
});
set({ rectangles: newScene.rectangles });
},
createTextBox: (textBox) => {
const newScene = produce(get(), (draftState) => {
draftState.textBoxes.push(textBoxInputToTextBox(textBox));
const newScene = produce(get(), (draft) => {
draft.textBoxes.push(textBoxInputToTextBox(textBox));
});
set({ textBoxes: newScene.textBoxes });
},
updateTextBox: (id, updates) => {
const newScene = produce(get(), (draftState) => {
const newScene = produce(get(), (draft) => {
const { item: textBox, index } = getItemById(
draftState.textBoxes,
draft.textBoxes,
id
);
if (updates.text !== undefined || updates.fontSize !== undefined) {
draftState.textBoxes[index].size = {
draft.textBoxes[index].size = {
width: getTextWidth(updates.text ?? textBox.text, {
fontSize: updates.fontSize ?? textBox.fontSize,
fontFamily: DEFAULT_FONT_FAMILY,
@@ -174,7 +181,7 @@ const initialState = () => {
};
}
draftState.textBoxes[index] = {
draft.textBoxes[index] = {
...textBox,
...updates
};
@@ -184,23 +191,23 @@ const initialState = () => {
},
deleteTextBox: (id: string) => {
const newScene = produce(get(), (draftState) => {
const { index } = getItemById(draftState.textBoxes, id);
const newScene = produce(get(), (draft) => {
const { index } = getItemById(draft.textBoxes, id);
draftState.textBoxes.splice(index, 1);
draft.textBoxes.splice(index, 1);
});
set({ textBoxes: newScene.textBoxes });
},
updateRectangle: (id, updates) => {
const newScene = produce(get(), (draftState) => {
const newScene = produce(get(), (draft) => {
const { item: rectangle, index } = getItemById(
draftState.rectangles,
draft.rectangles,
id
);
draftState.rectangles[index] = {
draft.rectangles[index] = {
...rectangle,
...updates
};
@@ -210,10 +217,10 @@ const initialState = () => {
},
deleteRectangle: (id: string) => {
const newScene = produce(get(), (draftState) => {
const { index } = getItemById(draftState.rectangles, id);
const newScene = produce(get(), (draft) => {
const { index } = getItemById(draft.rectangles, id);
draftState.rectangles.splice(index, 1);
draft.rectangles.splice(index, 1);
});
set({ rectangles: newScene.rectangles });

View File

@@ -20,6 +20,7 @@ export enum TileOriginEnum {
export enum SceneItemTypeEnum {
NODE = 'NODE',
CONNECTOR = 'CONNECTOR',
CONNECTOR_ANCHOR = 'CONNECTOR_ANCHOR',
TEXTBOX = 'TEXTBOX',
RECTANGLE = 'RECTANGLE'
}
@@ -49,7 +50,8 @@ export type ConnectorAnchorRef =
};
export type ConnectorAnchor = {
id?: string;
id: string;
type: SceneItemTypeEnum.CONNECTOR_ANCHOR;
ref: ConnectorAnchorRef;
};
@@ -84,7 +86,7 @@ export interface Rectangle {
to: Coords;
}
export type SceneItem = Node | Connector | TextBox | Rectangle;
export type SceneItem = Node | Connector | TextBox | Rectangle | ConnectorAnchor;
export type SceneItemReference = {
type: SceneItemTypeEnum;
id: string;

View File

@@ -17,6 +17,11 @@ interface TextBoxControls {
id: string;
}
interface ConnectorAnchorControls {
type: 'CONNECTOR_ANCHOR';
id: string;
}
interface RectangleControls {
type: 'RECTANGLE';
id: string;
@@ -32,6 +37,7 @@ export type ItemControls =
| RectangleControls
| AddItemControls
| TextBoxControls
| ConnectorAnchorControls
| null;
export interface Mouse {

View File

@@ -23,7 +23,7 @@ import {
TEXTBOX_DEFAULTS,
DEFAULT_FONT_FAMILY
} from 'src/config';
import { getConnectorPath, getTextWidth } from 'src/utils';
import { getConnectorPath, getTextWidth, generateId } from 'src/utils';
export const iconInputToIcon = (iconInput: IconInput): Icon => {
return {
@@ -62,9 +62,14 @@ export const rectangleInputToRectangle = (
const connectorAnchorInputToConnectorAnchor = (
anchor: ConnectorAnchorInput
): ConnectorAnchor => {
const anchorBase: Required<Pick<ConnectorAnchor, 'id' | 'type'>> = {
id: anchor.id ?? generateId(),
type: SceneItemTypeEnum.CONNECTOR_ANCHOR
};
if (anchor.ref.node) {
return {
id: anchor.id,
...anchorBase,
ref: {
type: 'NODE',
id: anchor.ref.node
@@ -74,7 +79,7 @@ const connectorAnchorInputToConnectorAnchor = (
if (anchor.ref.tile) {
return {
id: anchor.id,
...anchorBase,
ref: {
type: 'TILE',
coords: anchor.ref.tile
@@ -84,7 +89,7 @@ const connectorAnchorInputToConnectorAnchor = (
if (anchor.ref.anchor) {
return {
id: anchor.id,
...anchorBase,
ref: {
type: 'ANCHOR',
id: anchor.ref.anchor

View File

@@ -211,9 +211,9 @@ const isoProjectionBaseValues = [0.707, -0.409, 0.707, 0.409, 0, -0.816];
export const getIsoMatrix = (orientation?: ProjectionOrientationEnum) => {
switch (orientation) {
case ProjectionOrientationEnum.Y:
return produce(isoProjectionBaseValues, (draftState) => {
draftState[1] = -draftState[1];
draftState[2] = -draftState[2];
return produce(isoProjectionBaseValues, (draft) => {
draft[1] = -draft[1];
draft[2] = -draft[2];
});
case ProjectionOrientationEnum.X:
default:
@@ -568,3 +568,25 @@ export const convertBoundsToNamedAnchors = (boundingBox: BoundingBox) => {
[AnchorPositionsEnum.TOP_LEFT]: boundingBox[3]
};
};
export const getAnchorAtTile = (tile: Coords, anchors: ConnectorAnchor[]) => {
return anchors.find((anchor) => {
return (
anchor.ref.type === 'TILE' && CoordsUtils.isEqual(anchor.ref.coords, tile)
);
});
};
export const getAnchorParent = (anchorId: string, connectors: Connector[]) => {
const connector = connectors.find((con) => {
return con.anchors.find((anchor) => {
return anchor.id === anchorId;
});
});
if (!connector) {
throw new Error(`Could not find connector with anchor id ${anchorId}`);
}
return connector;
};

View File

@@ -19,8 +19,8 @@ describe('Tests immer', () => {
test('Array equivalence with immer', () => {
const arr = [createItem(0, 0), createItem(1, 1)];
const newArr = produce(arr, (draftState) => {
draftState[1] = createItem(2, 2);
const newArr = produce(arr, (draft) => {
draft[1] = createItem(2, 2);
});
expect(arr[0]).toBe(newArr[0]);