feat: implements multiselect

This commit is contained in:
Mark Mankarious
2023-07-10 12:01:21 +01:00
parent 6cb7c109f5
commit f68daacc29
19 changed files with 703 additions and 254 deletions

View File

@@ -11,10 +11,7 @@ interface Props {
export const NodeContextMenu = ({ node }: Props) => {
const renderer = useGlobalState((state) => state.renderer);
const position = renderer.getTileScreenPosition(
node.position.x,
node.position.y
);
const position = renderer.getTileScreenPosition(node.position);
return (
<ContextMenu position={position}>

View File

@@ -12,7 +12,7 @@ interface Props {
export const TileContextMenu = ({ tile }: Props) => {
const renderer = useGlobalState((state) => state.renderer);
const icons = useGlobalState((state) => state.initialScene.icons);
const position = renderer.getTileScreenPosition(tile.x, tile.y);
const position = renderer.getTileScreenPosition(tile);
return (
<ContextMenu position={position}>

View File

@@ -13,11 +13,16 @@ export const ContextMenu = () => {
}
if (targetElement instanceof Node) {
return <NodeContextMenu node={targetElement} />;
return <NodeContextMenu node={targetElement} key={targetElement.id} />;
}
if (targetElement instanceof Coords) {
return <TileContextMenu tile={targetElement} />;
return (
<TileContextMenu
tile={targetElement}
key={JSON.stringify(targetElement)}
/>
);
}
return null;

View File

@@ -12,10 +12,11 @@ interface GlobalState {
initialScene: SceneI;
setInitialScene: (scene: SceneI) => void;
selectedSideNavItem: number | null;
setSelectedElements: (elements: Node[]) => void;
setSelectedSideNavItem: (index: number) => void;
closeSideNav: () => void;
renderer: Renderer;
selectedElements: string[];
selectedElements: Node[];
setRenderer: (containerEl: HTMLDivElement) => Renderer;
onRendererEvent: (event: SceneEventI) => void;
}
@@ -35,6 +36,15 @@ export const useGlobalState = create<GlobalState>((set, get) => ({
setInitialScene: (scene) => {
set({ initialScene: scene });
},
setSelectedElements: (elements: Node[]) => {
const { renderer } = get();
renderer.unfocusAll();
elements.forEach((element) => {
element.setFocus(true);
});
set({ selectedElements: elements });
},
setSelectedSideNavItem: (val) => {
set({ selectedSideNavItem: val });
},
@@ -43,34 +53,37 @@ export const useGlobalState = create<GlobalState>((set, get) => ({
},
renderer: new Renderer(document.createElement("div")),
onRendererEvent: (event) => {
const { renderer } = get();
const { setSelectedElements } = get();
switch (event.type) {
case "TILE_SELECTED":
set({ showContextMenuFor: event.data.tile, selectedElements: [] });
setSelectedElements([]);
set({ showContextMenuFor: event.data.tile });
break;
case "NODES_SELECTED":
set({ selectedElements: event.data.nodes });
setSelectedElements(event.data.nodes);
if (event.data.nodes.length === 1) {
const node = renderer.sceneElements.nodes.getNodeById(
event.data.nodes[0]
);
set({ showContextMenuFor: node });
set({ showContextMenuFor: event.data.nodes[0] });
}
break;
case "NODE_REMOVED":
setSelectedElements([]);
set({
showContextMenuFor: null,
selectedElements: [],
selectedSideNavItem: null,
});
break;
case "NODE_MOVED":
set({ showContextMenuFor: null, selectedElements: [] });
setSelectedElements([]);
set({ showContextMenuFor: null });
break;
case "ZOOM_CHANGED":
set({ showContextMenuFor: null, selectedElements: [] });
setSelectedElements([]);
set({ showContextMenuFor: null });
break;
case "MULTISELECT_UPDATED":
setSelectedElements(event.data.itemsSelected);
break;
default:
break;

85
src/modes/CreateLasso.ts Normal file
View File

@@ -0,0 +1,85 @@
import { ModeBase } from "./ModeBase";
import { Select } from "./Select";
import { getGridSubset, isWithinBounds } from "../renderer/utils/gridHelpers";
import { Mouse } from "../types";
import { Coords } from "../renderer/elements/Coords";
import { Node } from "../renderer/elements/Node";
import { ManipulateLasso } from "./ManipulateLasso";
export class CreateLasso extends ModeBase {
startTile: Coords | null = null;
nodesSelected: Node[] = [];
selectionGrid: Coords[] = [];
entry(mouse: Mouse) {
if (!this.startTile) {
this.startTile = this.ctx.renderer.getTileFromMouse(mouse.position);
}
this.ctx.renderer.sceneElements.cursor.displayAt(this.startTile, {
skipAnimation: true,
});
this.ctx.renderer.sceneElements.cursor.setVisible(true);
}
setStartTile(tile: Coords) {
this.startTile = tile;
}
exit() {}
MOUSE_MOVE(mouse: Mouse) {
const currentTile = this.ctx.renderer.getTileFromMouse(mouse.position);
if (mouse.delta) {
const prevTile = this.ctx.renderer.getTileFromMouse(
mouse.position.subtract(mouse.delta)
);
if (currentTile.isEqual(prevTile)) return;
}
if (!this.startTile) return;
this.ctx.renderer.sceneElements.cursor.createSelection(
this.startTile,
currentTile
);
this.selectionGrid = getGridSubset([this.startTile, currentTile]);
this.nodesSelected = this.selectionGrid.reduce<Node[]>((acc, tile) => {
const tileItems = this.ctx.renderer.getItemsByTile(tile);
const filtered = tileItems.filter((i) => i?.type === "NODE") as Node[];
return [...acc, ...filtered];
}, []);
this.ctx.emitEvent({
type: "MULTISELECT_UPDATED",
data: {
itemsSelected: this.nodesSelected,
},
});
}
MOUSE_DOWN(mouse: Mouse) {
if (this.nodesSelected.length > 0) {
this.ctx.activateMode(ManipulateLasso, (mode) => {
mode.setSelectedItems(this.nodesSelected);
mode.MOUSE_DOWN(mouse);
});
}
}
MOUSE_UP(mouse: Mouse) {
this.startTile = null;
const currentTile = this.ctx.renderer.getTileFromMouse(mouse.position);
if (
this.nodesSelected.length === 0 ||
!isWithinBounds(currentTile, this.selectionGrid)
) {
this.ctx.activateMode(Select);
}
}
}

View File

@@ -0,0 +1,98 @@
import { ModeBase } from "./ModeBase";
import { Select } from "./Select";
import { getBoundingBox, isWithinBounds } from "../renderer/utils/gridHelpers";
import { Mouse } from "../types";
import { Coords } from "../renderer/elements/Coords";
import { Node } from "../renderer/elements/Node";
import { CreateLasso } from "./CreateLasso";
export class ManipulateLasso extends ModeBase {
selectedItems: Node[] = [];
isMouseDownWithinLassoBounds = false;
dragOffset = new Coords(0, 0);
isDragging = false;
entry(mouse: Mouse) {}
setSelectedItems(items: Node[]) {
this.selectedItems = items;
}
exit() {}
MOUSE_MOVE(mouse: Mouse) {
if (!this.isDragging) return;
const currentTile = this.ctx.renderer.getTileFromMouse(mouse.position);
if (mouse.delta) {
const prevTile = this.ctx.renderer.getTileFromMouse(
mouse.position.subtract(mouse.delta)
);
if (currentTile.isEqual(prevTile)) return;
}
const { renderer } = this.ctx;
const { cursor, grid } = renderer.sceneElements;
if (this.isMouseDownWithinLassoBounds) {
const validTile = grid.getAreaWithinGrid(
currentTile,
cursor.size,
this.dragOffset
);
const oldCursorPosition = cursor.position.clone();
const newCursorPosition = validTile.subtract(this.dragOffset);
cursor.displayAt(newCursorPosition, {
skipAnimation: true,
});
const translateBy = new Coords(
-(oldCursorPosition.x - newCursorPosition.x),
-(oldCursorPosition.y - newCursorPosition.y)
);
renderer.sceneElements.nodes.translateNodes(
this.selectedItems.filter((i) => i.type === "NODE"),
translateBy
);
}
}
MOUSE_DOWN(mouse: Mouse) {
const currentTile = this.ctx.renderer.getTileFromMouse(mouse.position);
const { renderer } = this.ctx;
const { cursor } = renderer.sceneElements;
const boundingBox = getBoundingBox([
renderer.sceneElements.cursor.position,
new Coords(
cursor.position.x + cursor.size.x,
cursor.position.y - cursor.size.y
),
]);
this.isMouseDownWithinLassoBounds = isWithinBounds(
currentTile,
boundingBox
);
if (this.isMouseDownWithinLassoBounds) {
this.isDragging = true;
this.dragOffset.set(
currentTile.x - cursor.position.x,
currentTile.y - cursor.position.y
);
return;
}
this.ctx.activateMode(Select, (mode) => mode.MOUSE_DOWN(mouse));
}
MOUSE_UP(mouse: Mouse) {
this.isDragging = false;
}
}

View File

@@ -1,73 +1,91 @@
import { ModeBase } from "./ModeBase";
import { Mouse } from "../types";
import { getTargetFromSelection, isMouseOverNewTile } from "./utils";
import { getTargetFromSelection } from "./utils";
import { SelectNode } from "./SelectNode";
import { Node } from "../renderer/elements/Node";
import { CreateLasso } from "./CreateLasso";
import { CURSOR_TYPES } from "../renderer/elements/Cursor";
import { Coords } from "../renderer/elements/Coords";
export class Select extends ModeBase {
entry(mouse: Mouse) {
const tile = this.ctx.renderer.getTileFromMouse(
mouse.position.x,
mouse.position.y
);
dragStartTile: Coords | null = null;
this.ctx.renderer.sceneElements.cursor.displayAt(tile.x, tile.y);
this.ctx.renderer.sceneElements.cursor.enable();
entry(mouse: Mouse) {
this.ctx.renderer.unfocusAll();
this.ctx.renderer.sceneElements.cursor.setCursorType(CURSOR_TYPES.TILE);
const tile = this.ctx.renderer.getTileFromMouse(mouse.position);
this.ctx.renderer.sceneElements.cursor.displayAt(tile, {
skipAnimation: true,
});
this.ctx.renderer.sceneElements.cursor.setVisible(true);
}
exit() {
this.ctx.renderer.sceneElements.cursor.disable();
this.ctx.renderer.sceneElements.cursor.setVisible(false);
}
MOUSE_ENTER(mouse: Mouse) {
MOUSE_UP(mouse: Mouse) {
const { renderer } = this.ctx;
const tile = renderer.getTileFromMouse(mouse.position);
const items = renderer.getItemsByTile(tile);
const target = getTargetFromSelection(items);
renderer.sceneElements.cursor.enable();
}
if (!target?.type) {
this.ctx.emitEvent({
type: "TILE_SELECTED",
data: { tile },
});
}
MOUSE_LEAVE() {
this.ctx.renderer.sceneElements.cursor.disable();
this.dragStartTile = null;
}
MOUSE_DOWN(mouse: Mouse) {
this.dragStartTile = this.ctx.renderer.getTileFromMouse(mouse.position);
const { renderer } = this.ctx;
const { x, y } = renderer.getTileFromMouse(
mouse.position.x,
mouse.position.y
);
const items = renderer.getItemsByTile(x, y);
const tile = renderer.getTileFromMouse(mouse.position);
const items = renderer.getItemsByTile(tile);
const target = getTargetFromSelection(items);
if (target instanceof Node) {
if (target?.type === "NODE") {
this.ctx.activateMode(SelectNode, (instance) => (instance.node = target));
return;
}
this.ctx.emitEvent({
type: "TILE_SELECTED",
data: { tile: new Coords(x, y) },
});
}
MOUSE_MOVE(mouse: Mouse) {
const newTile = isMouseOverNewTile(
mouse,
this.ctx.renderer.getTileFromMouse
);
const currentTile = this.ctx.renderer.getTileFromMouse(mouse.position);
if (newTile) {
this.ctx.renderer.sceneElements.cursor.displayAt(newTile.x, newTile.y);
if (mouse.delta) {
const prevTile = this.ctx.renderer.getTileFromMouse(
mouse.position.subtract(mouse.delta)
);
const items = this.ctx.renderer.getItemsByTile(newTile.x, newTile.y);
const target = getTargetFromSelection(items);
if (currentTile.isEqual(prevTile)) return;
}
this.ctx.renderer.unfocusAll();
if (this.dragStartTile && !currentTile.isEqual(this.dragStartTile)) {
this.ctx.activateMode(CreateLasso, (mode) => {
console.log(this.dragStartTile);
this.dragStartTile && mode.setStartTile(this.dragStartTile);
mode.MOUSE_MOVE(mouse);
});
if (target instanceof Node) {
target.setFocus(true);
return;
}
return;
}
this.ctx.renderer.sceneElements.cursor.displayAt(currentTile);
this.ctx.renderer.unfocusAll();
const items = this.ctx.renderer.getItemsByTile(currentTile);
const target = getTargetFromSelection(items);
if (target?.type === "NODE") {
target.setFocus(true);
return;
}
}
}

View File

@@ -1,6 +1,6 @@
import { ModeBase } from "./ModeBase";
import { Select } from "../modes/Select";
import { Mouse, ModeContext } from "../types";
import { Mouse } from "../types";
import { Node } from "../renderer/elements/Node";
export class SelectNode extends ModeBase {
@@ -8,30 +8,24 @@ export class SelectNode extends ModeBase {
hasMoved = false;
entry(mouse: Mouse) {
const tile = this.ctx.renderer.getTileFromMouse(
mouse.position.x,
mouse.position.y
);
const tile = this.ctx.renderer.getTileFromMouse(mouse.position);
this.ctx.renderer.sceneElements.cursor.displayAt(tile.x, tile.y);
this.ctx.renderer.sceneElements.cursor.enable();
this.ctx.renderer.sceneElements.cursor.displayAt(tile);
this.ctx.renderer.sceneElements.cursor.setVisible(true);
}
exit() {
this.ctx.renderer.sceneElements.cursor.disable();
this.ctx.renderer.sceneElements.cursor.setVisible(false);
}
MOUSE_MOVE(mouse: Mouse) {
if (!this.node) return;
const tile = this.ctx.renderer.getTileFromMouse(
mouse.position.x,
mouse.position.y
);
const tile = this.ctx.renderer.getTileFromMouse(mouse.position);
if (this.node.position.x !== tile.x || this.node.position.y !== tile.y) {
this.node.moveTo(tile.x, tile.y);
this.ctx.renderer.sceneElements.cursor.displayAt(tile.x, tile.y);
this.node.moveTo(tile);
this.ctx.renderer.sceneElements.cursor.displayAt(tile);
this.hasMoved = true;
}
}

View File

@@ -0,0 +1,113 @@
import { ModeManager } from "../ModeManager";
import { Renderer } from "../../renderer/Renderer";
import { Select } from "../Select";
import { CreateLasso } from "../CreateLasso";
import { SelectNode } from "../SelectNode";
import { Coords } from "../../renderer/elements/Coords";
import { Node } from "../../renderer/elements/Node";
import * as utils from "../utils";
jest.mock("../utils", () => ({
getTargetFromSelection: jest.fn(),
}));
jest.mock("../../renderer/elements/Cursor", () => ({
CURSOR_TYPES: {
TILE: "TILE",
},
}));
jest.mock("../../renderer/Renderer", () => ({
Renderer: jest.fn().mockImplementation(() => ({
getTileFromMouse: (coords: Coords) => coords,
getItemsByTile: jest.fn(() => []),
unfocusAll: jest.fn(),
sceneElements: {
cursor: {
displayAt: jest.fn(),
setVisible: jest.fn(),
setCursorType: jest.fn(),
createSelection: jest.fn(),
},
},
})),
}));
jest.mock("../../renderer/elements/Node", () => ({
Node: jest.fn().mockImplementation(() => ({
type: "NODE",
isFocussed: false,
setFocus: jest.fn(),
moveTo: jest.fn(),
})),
}));
const MockNode = Node as jest.Mock<Node>;
const createRenderer = () => {
const renderer = new Renderer({} as unknown as HTMLDivElement);
const modeManager = new ModeManager();
modeManager.setRenderer(renderer);
modeManager.activateMode(Select);
return { renderer, modeManager };
};
describe("Select mode functions correctly", () => {
it("Cursor repositions when tile is hovered", () => {
const { renderer, modeManager } = createRenderer();
const displayAtSpy = jest.spyOn(renderer.sceneElements.cursor, "displayAt");
modeManager.onMouseEvent("MOUSE_MOVE", {
position: new Coords(2, 2),
delta: null,
});
expect(displayAtSpy).toHaveBeenCalled();
expect(displayAtSpy.mock.calls[1][0]).toStrictEqual(new Coords(2, 2));
});
it("Node gains focus when mouse hovers over it", () => {
const { modeManager } = createRenderer();
const mockNode = new MockNode();
jest.spyOn(utils, "getTargetFromSelection").mockReturnValueOnce(mockNode);
modeManager.onMouseEvent("MOUSE_MOVE", {
position: new Coords(1, 1),
delta: null,
});
expect(mockNode.setFocus).toHaveBeenCalled();
});
it("All focussed elements reset when user hovers over a different tile", () => {
const { renderer, modeManager } = createRenderer();
jest.spyOn(utils, "getTargetFromSelection").mockReturnValueOnce(null);
const unfocusAllSpy = jest.spyOn(renderer, "unfocusAll");
modeManager.onMouseEvent("MOUSE_MOVE", {
position: new Coords(1, 1),
delta: null,
});
expect(unfocusAllSpy).toHaveBeenCalled();
});
it("Activates multiselect mode when mouse is dragged (dragging must start from an empty tile)", () => {
const activateModeSpy = jest.spyOn(ModeManager.prototype, "activateMode");
const { modeManager } = createRenderer();
jest.spyOn(utils, "getTargetFromSelection").mockReturnValue(null);
modeManager.onMouseEvent("MOUSE_DOWN", {
position: new Coords(0, 0),
delta: null,
});
modeManager.onMouseEvent("MOUSE_MOVE", {
position: new Coords(2, 2),
delta: new Coords(2, 2),
});
expect(activateModeSpy).toHaveBeenCalledTimes(2);
expect(activateModeSpy.mock.calls[1][0]).toStrictEqual(CreateLasso);
});
it("Activates Node selection mode when user clicks on a node", () => {
const activateModeSpy = jest.spyOn(ModeManager.prototype, "activateMode");
const { modeManager } = createRenderer();
const mockNode = new MockNode();
jest.spyOn(utils, "getTargetFromSelection").mockReturnValue(mockNode);
modeManager.onMouseEvent("MOUSE_DOWN", {
position: new Coords(0, 0),
delta: null,
});
expect(activateModeSpy).toHaveBeenCalledTimes(2);
expect(activateModeSpy.mock.calls[1][0]).toStrictEqual(SelectNode);
});
});

View File

@@ -1,6 +1,7 @@
import { Node } from "../renderer/elements/Node";
import { Mouse } from "../types";
import { Renderer } from "../renderer/Renderer";
import { Coords } from "../renderer/elements/Coords";
export const getTargetFromSelection = (items: (Node | undefined)[]) => {
const node = items.find((item) => item instanceof Node);
@@ -21,11 +22,13 @@ export const isMouseOverNewTile = (
}
const prevTile = getTileFromMouse(
mouse.position.x - mouse.delta.x,
mouse.position.y - mouse.delta.y
new Coords(
mouse.position.x - mouse.delta.x,
mouse.position.y - mouse.delta.y
)
);
const currentTile = getTileFromMouse(mouse.position.x, mouse.position.y);
const currentTile = getTileFromMouse(mouse.position);
if (prevTile.x !== currentTile.x || prevTile.y !== currentTile.y) {
return currentTile;

View File

@@ -1,20 +1,16 @@
import { makeAutoObservable, toJS } from "mobx";
import { makeAutoObservable } from "mobx";
import Paper, { Group } from "paper";
import gsap from "gsap";
import autobind from "auto-bind";
import { Grid } from "./elements/Grid";
import { Cursor } from "./elements/Cursor";
import { PROJECTED_TILE_WIDTH, PROJECTED_TILE_HEIGHT } from "./constants";
import { clamp } from "../utils";
import { Nodes } from "./elements/Nodes";
import { SceneI, IconI } from "../validation/SceneSchema";
import { Coords } from "./elements/Coords";
import { OnSceneChange, SceneEventI } from "../types";
interface Config {
grid: {
width: number;
height: number;
};
icons: IconI[];
}
@@ -23,10 +19,6 @@ export class Renderer {
zoom = 1;
config: Config = {
grid: {
width: 51,
height: 51,
},
icons: [],
};
callbacks: {
@@ -53,7 +45,6 @@ export class Renderer {
constructor(containerEl: HTMLDivElement) {
makeAutoObservable(this);
autobind(this);
Paper.settings = {
insertelements: false,
@@ -72,7 +63,7 @@ export class Renderer {
Paper.setup(this.domElements.canvas);
this.sceneElements = {
grid: new Grid(this),
grid: new Grid(new Coords(51, 51), this),
cursor: new Cursor(this),
nodes: new Nodes(this),
};
@@ -136,78 +127,74 @@ export class Renderer {
return { canvas };
}
getTileFromMouse(_mouseX: number, _mouseY: number) {
getTileFromMouse(mouse: Coords) {
const halfW = PROJECTED_TILE_WIDTH / 2;
const halfH = PROJECTED_TILE_HEIGHT / 2;
const mouseX =
(_mouseX - this.groups.elements.position.x) * (1 / this.zoom);
(mouse.x - this.groups.elements.position.x) * (1 / this.zoom);
const mouseY =
(_mouseY - this.groups.elements.position.y) * (1 / this.zoom) + halfH;
(mouse.y - this.groups.elements.position.y) * (1 / this.zoom) + halfH;
const row = Math.floor((mouseX / halfW + mouseY / halfH) / 2);
const col = Math.floor((mouseY / halfH - mouseX / halfW) / 2);
const halfRowNum = Math.floor(this.config.grid.width * 0.5);
const halfColNum = Math.floor(this.config.grid.height * 0.5);
const halfRowNum = Math.floor(this.sceneElements.grid.size.x * 0.5);
const halfColNum = Math.floor(this.sceneElements.grid.size.y * 0.5);
return {
x: clamp(row, -halfRowNum, halfRowNum),
y: clamp(col, -halfColNum, halfColNum),
};
return new Coords(
clamp(row, -halfRowNum, halfRowNum),
clamp(col, -halfColNum, halfColNum)
);
}
getTilePosition(x: number, y: number) {
getTilePosition({ x, y }: Coords) {
const halfW = PROJECTED_TILE_WIDTH * 0.5;
const halfH = PROJECTED_TILE_HEIGHT * 0.5;
return {
x: x * halfW - y * halfW,
y: x * halfH + y * halfH,
};
return new Coords(x * halfW - y * halfW, x * halfH + y * halfH);
}
getTileBounds(x: number, y: number) {
const position = this.getTilePosition(x, y);
getTileBounds(coords: Coords) {
const position = this.getTilePosition(coords);
return {
left: {
x: position.x - PROJECTED_TILE_WIDTH * 0.5,
y: position.y - PROJECTED_TILE_HEIGHT * 0.5,
y: position.y,
},
right: {
x: position.x + PROJECTED_TILE_WIDTH * 0.5,
y: position.y - PROJECTED_TILE_HEIGHT * 0.5,
y: position.y,
},
top: { x: position.x, y: position.y - PROJECTED_TILE_HEIGHT },
bottom: { x: position.x, y: position.y },
center: { x: position.x, y: position.y - PROJECTED_TILE_HEIGHT * 0.5 },
top: { x: position.x, y: position.y - PROJECTED_TILE_HEIGHT * 0.5 },
bottom: { x: position.x, y: position.y + PROJECTED_TILE_HEIGHT * 0.5 },
center: { x: position.x, y: position.y },
};
}
getTileScreenPosition(x: number, y: number) {
getTileScreenPosition(position: Coords) {
const { width: viewW, height: viewH } = Paper.view.bounds;
const { offsetLeft: offsetX, offsetTop: offsetY } = this.domElements.canvas;
const tilePosition = this.getTileBounds(x, y).center;
const tilePosition = this.getTileBounds(position).center;
const globalItemsGroupPosition = this.groups.elements.globalToLocal([0, 0]);
const screenPosition = {
x:
(tilePosition.x +
this.scrollPosition.x +
globalItemsGroupPosition.x +
this.groups.elements.position.x +
viewW * 0.5) *
this.zoom +
const screenPosition = new Coords(
(tilePosition.x +
this.scrollPosition.x +
globalItemsGroupPosition.x +
this.groups.elements.position.x +
viewW * 0.5) *
this.zoom +
offsetX,
y:
(tilePosition.y +
this.scrollPosition.y +
globalItemsGroupPosition.y +
this.groups.elements.position.y +
viewH * 0.5) *
this.zoom +
offsetY,
};
(tilePosition.y +
this.scrollPosition.y +
globalItemsGroupPosition.y +
this.groups.elements.position.y +
viewH * 0.5) *
this.zoom +
offsetY
);
return screenPosition;
}
@@ -291,8 +278,8 @@ export class Renderer {
this.callbacks.emitEvent(event);
}
getItemsByTile(x: number, y: number) {
const node = this.sceneElements.nodes.getNodeByTile(x, y);
getItemsByTile(coords: Coords) {
const node = this.sceneElements.nodes.getNodeByTile(coords);
return [node].filter((i) => Boolean(i));
}

View File

@@ -11,4 +11,44 @@ export class Coords {
this.x = x;
this.y = y;
}
setX(x: number) {
this.x = x;
}
setY(y: number) {
this.y = y;
}
isEqual(comparator: Coords) {
return this.x === comparator.x && this.y === comparator.y;
}
subtract(operand: Coords) {
return new Coords(this.x - operand.x, this.y - operand.y);
}
subtractX(operand: number) {
return new Coords(this.x - operand, this.y);
}
subtractY(operand: number) {
return new Coords(this.x, this.y - operand);
}
add(operand: Coords) {
return new Coords(this.x + operand.x, this.y + operand.y);
}
addX(operand: number) {
return new Coords(this.x + operand, this.y);
}
addY(operand: number) {
return new Coords(this.x, this.y + operand);
}
clone() {
return new Coords(this.x, this.y);
}
}

View File

@@ -1,4 +1,4 @@
import { Shape, Point } from "paper";
import { Shape, Point, Group } from "paper";
import { gsap } from "gsap";
import { applyProjectionMatrix } from "../utils/projection";
import { TILE_SIZE, PIXEL_UNIT } from "../constants";
@@ -21,6 +21,8 @@ export enum CURSOR_TYPES {
}
export class Cursor extends SceneElement {
container = new Group();
renderElements = {
rectangle: new Shape.Rectangle([0, 0]),
};
@@ -29,20 +31,15 @@ export class Cursor extends SceneElement {
highlight: gsap.core.Tween;
};
position = {
x: 0,
y: 0,
};
position = new Coords(0, 0);
size = {
width: 1,
height: 1,
};
size: Coords = new Coords(1, 1);
currentType?: CURSOR_TYPES;
constructor(ctx: Context) {
super(ctx);
this.displayAt = this.displayAt.bind(this);
this.renderElements.rectangle = new Shape.Rectangle({});
@@ -62,8 +59,8 @@ export class Cursor extends SceneElement {
applyProjectionMatrix(this.container);
this.setCursorType(CURSOR_TYPES.TILE);
this.displayAt(0, 0);
this.enable();
this.displayAt(new Coords(0, 0));
this.setVisible(true);
}
setCursorType(type: CURSOR_TYPES) {
@@ -71,10 +68,7 @@ export class Cursor extends SceneElement {
this.currentType = type;
this.container.set({ pivot: [0, 0] });
this.size = {
width: 1,
height: 1,
};
this.size = new Coords(1, 1);
switch (type) {
case CURSOR_TYPES.OUTLINE:
@@ -134,12 +128,8 @@ export class Cursor extends SceneElement {
}
}
enable() {
this.container.visible = true;
}
disable() {
this.container.visible = false;
setVisible(state: boolean) {
this.container.visible = state;
}
createSelection(from: Coords, to: Coords) {
@@ -151,21 +141,19 @@ export class Cursor extends SceneElement {
this.setCursorType(CURSOR_TYPES.LASSO);
const sorted = sortByPosition(boundingBox);
const position = new Coords(sorted.lowX, sorted.highY);
this.position = {
x: sorted.lowX,
y: sorted.lowY,
};
this.position.set(position.x, position.y);
this.size = {
width: sorted.highX - sorted.lowX,
height: sorted.highY - sorted.lowY,
};
this.size = new Coords(
sorted.highX - sorted.lowX,
sorted.highY - sorted.lowY
);
this.renderElements.rectangle.set({
size: [
(this.size.width + 1) * (TILE_SIZE - PIXEL_UNIT * 3),
(this.size.height + 1) * (TILE_SIZE - PIXEL_UNIT * 3),
(this.size.x + 1) * (TILE_SIZE - PIXEL_UNIT * 3),
(this.size.y + 1) * (TILE_SIZE - PIXEL_UNIT * 3),
],
});
@@ -175,38 +163,29 @@ export class Cursor extends SceneElement {
const targetTile = boundingBox[3];
this.container.position = new Point(
getTileBounds(targetTile.x, targetTile.y).left
);
this.container.position = new Point(getTileBounds(targetTile).left);
}
predictBoundsAt(tile: Coords) {
const bounds = [
{ x: tile.x, y: tile.y },
{ x: tile.x, y: tile.y - this.size.height },
{ x: tile.x + this.size.width, y: tile.y - this.size.height },
{ x: tile.x + this.size.width, y: tile.y },
{ x: tile.x, y: tile.y - this.size.y },
{ x: tile.x + this.size.x, y: tile.y - this.size.y },
{ x: tile.x + this.size.x, y: tile.y },
];
return bounds;
}
getInfo() {
return { ...this.position, ...this.size };
}
displayAt(position: Coords, opts?: { skipAnimation: boolean }) {
if (this.position.isEqual(position)) return;
displayAt(x: number, y: number, opts?: { skipAnimation: boolean }) {
if (x === this.position.x && y === this.position.y) return;
this.position = {
x,
y,
};
this.position.set(position.x, position.y);
const tileBoundsPosition =
this.currentType === CURSOR_TYPES.LASSO ? "left" : "center";
const tile = getTileBounds(x, y)[tileBoundsPosition];
const tile = getTileBounds(position)[tileBoundsPosition];
tweenPosition(this.container, {
...tile,

View File

@@ -3,6 +3,8 @@ import { applyProjectionMatrix } from "../utils/projection";
import type { Context } from "../../types";
import { TILE_SIZE, PIXEL_UNIT, SCALING_CONST } from "../constants";
import { SceneElement } from "../SceneElement";
import { Coords } from "./Coords";
import { sortByPosition, getBoundingBox } from "../utils/gridHelpers";
export class Grid extends SceneElement {
container = new Group();
@@ -10,13 +12,16 @@ export class Grid extends SceneElement {
grid: new Group({ applyMatrix: true }),
};
constructor(ctx: Context) {
size: Coords;
constructor(size: Coords, ctx: Context) {
super(ctx);
this.size = size;
this.container.addChild(this.renderElements.grid);
for (let x = 0; x <= this.ctx.config.grid.width; x++) {
const lineLength = this.ctx.config.grid.height * TILE_SIZE;
for (let x = 0; x <= this.size.x; x++) {
const lineLength = this.size.y * TILE_SIZE;
const start = x * TILE_SIZE - lineLength * 0.5;
const line = new Path({
segments: [
@@ -30,8 +35,8 @@ export class Grid extends SceneElement {
this.renderElements.grid.addChild(line);
}
for (let y = 0; y <= this.ctx.config.grid.height; y++) {
const lineLength = this.ctx.config.grid.width * TILE_SIZE;
for (let y = 0; y <= this.size.y; y++) {
const lineLength = this.size.x * TILE_SIZE;
const start = y * TILE_SIZE - lineLength * 0.5;
const line = new Path({
segments: [
@@ -48,4 +53,52 @@ export class Grid extends SceneElement {
this.renderElements.grid.scaling = new Point(SCALING_CONST, SCALING_CONST);
applyProjectionMatrix(this.renderElements.grid);
}
getGridBounds() {
const halfW = Math.floor(this.size.x * 0.5);
const halfH = Math.floor(this.size.y * 0.5);
return getBoundingBox([
new Coords(-halfW, -halfH),
new Coords(-halfW, halfH),
new Coords(halfW, halfH),
new Coords(halfW, -halfH),
]);
}
getAreaWithinGrid(
tile: Coords,
size: Coords,
offset: Coords = new Coords(0, 0)
) {
const position = tile.subtract(offset);
const areaBounds = sortByPosition([
position,
position.subtractY(size.y),
new Coords(position.x + size.x, position.y - size.y),
position.addX(size.x),
]);
const gridBounds = sortByPosition(this.getGridBounds());
const delta = new Coords(0, 0);
if (areaBounds.highX > gridBounds.highX) {
delta.setX(-(areaBounds.highX - gridBounds.highX));
}
if (areaBounds.lowX < gridBounds.lowX) {
delta.setX(gridBounds.lowX - areaBounds.lowX);
}
if (areaBounds.highY > gridBounds.highY) {
delta.setY(-(areaBounds.highY - gridBounds.highY));
}
if (areaBounds.lowY < gridBounds.lowY) {
delta.setY(gridBounds.lowY - areaBounds.lowY);
}
return new Coords(tile.x + delta.x, tile.y + delta.y);
}
}

View File

@@ -14,16 +14,20 @@ export interface NodeOptions {
}
interface Callbacks {
onMove: (x: number, y: number, node: Node) => void;
onMove: (
coords: Coords,
node: Node,
opts?: { skipAnimation: boolean }
) => void;
onDestroy: (node: Node) => void;
}
export class Node {
ctx: Context;
container = new Group();
type = "NODE";
id;
selected = false;
callbacks: Callbacks;
position;
color: string = theme.customVars.diagramPalette.purple;
@@ -46,7 +50,7 @@ export class Node {
this.container.addChild(this.tile.container);
this.container.addChild(this.icon.container);
this.moveTo(this.position.x, this.position.y);
this.moveTo(this.position);
this.destroy = this.destroy.bind(this);
}
@@ -68,8 +72,8 @@ export class Node {
this.tile.setFocus(state);
}
moveTo(x: number, y: number) {
this.callbacks.onMove(x, y, this);
moveTo(coords: Coords, opts?: { skipAnimation: boolean }) {
this.callbacks.onMove(coords, this, opts);
}
export() {

View File

@@ -1,5 +1,4 @@
import { Group } from "paper";
import autobind from "auto-bind";
import { makeAutoObservable, toJS } from "mobx";
import { Context } from "../../types";
import { Node, NodeOptions } from "./Node";
@@ -15,7 +14,6 @@ export class Nodes {
constructor(ctx: Context) {
makeAutoObservable(this);
autobind(this);
this.ctx = ctx;
}
@@ -50,15 +48,15 @@ export class Nodes {
});
}
onMove(x: number, y: number, node: Node, opts?: { skipAnimation: boolean }) {
const from = new Coords(node.position.x, node.position.y);
const to = new Coords(x, y);
onMove(coords: Coords, node: Node, opts?: { skipAnimation: boolean }) {
const from = node.position;
const to = coords;
const tile = this.ctx.getTileBounds(x, y);
node.position = new Coords(x, y);
const tile = this.ctx.getTileBounds(coords);
node.position = coords;
tweenPosition(node.container, {
...tile.bottom,
...tile.center,
duration: opts?.skipAnimation ? 0 : 0.05,
});
@@ -86,9 +84,9 @@ export class Nodes {
return this.nodes.find((node) => node.id === id);
}
getNodeByTile(x: number, y: number) {
getNodeByTile(coords: Coords) {
return this.nodes.find(
(node) => node.position.x === x && node.position.y === y
(node) => node.position.x === coords.x && node.position.y === coords.y
);
}
@@ -104,16 +102,47 @@ export class Nodes {
}
setSelectedNodes(ids: string[]) {
this.nodes.forEach((node) => {
node.selected = ids.includes(node.id);
});
const nodes = ids
.map((id) => {
const node = this.getNodeById(id);
node?.setSelected(true);
return node;
})
.filter((node) => node !== undefined) as Node[];
this.ctx.emitEvent({
type: "NODES_SELECTED",
data: { nodes: ids },
data: { nodes },
});
}
translateNodes(nodes: Node[], translation: Coords) {
// const updatedConnectors = [];
nodes.forEach((node) => {
// const connectors = this.connectors.getConnectorsByNode(node.id);
// connectors.forEach((con) => {
// if (updatedConnectors.includes(con.id)) return;
// const connectedNode = con.from.id === node.id ? con.to : con.from;
// if (!nodes.find(({ id }) => id === connectedNode.id)) {
// con.removeAllAnchors();
// } else {
// con.translateAnchors(translate);
// }
// updatedConnectors.push(con.id);
// });
node.moveTo(node.position.add(translation), { skipAnimation: true });
});
// this.connectors.updateAllPaths();
}
export() {
const exported = this.nodes.map((node) => node.export());
return exported;

View File

@@ -57,47 +57,44 @@ export const getBoundingBox = (
];
};
export const getTilePosition = (x: number, y: number) => {
export const getTilePosition = ({ x, y }: Coords) => {
const halfW = PROJECTED_TILE_WIDTH * 0.5;
const halfH = PROJECTED_TILE_HEIGHT * 0.5;
return new Coords(x * halfW - y * halfW, x * halfH + y * halfH);
};
export const getTileBounds = (coords: Coords) => {
const position = getTilePosition(coords);
return {
x: x * halfW - y * halfW,
y: x * halfH + y * halfH,
left: new Coords(position.x - PROJECTED_TILE_WIDTH * 0.5, position.y),
right: new Coords(position.x + PROJECTED_TILE_WIDTH * 0.5, position.y),
top: new Coords(position.x, position.y - PROJECTED_TILE_HEIGHT * 0.5),
bottom: new Coords(position.x, position.y + PROJECTED_TILE_HEIGHT * 0.5),
center: new Coords(position.x, position.y),
};
};
export const getTileBounds = (x: number, y: number) => {
const position = getTilePosition(x, y);
export const getGridSubset = (tiles: Coords[]) => {
const { lowX, lowY, highX, highY } = sortByPosition(tiles);
return {
left: { x: position.x - PROJECTED_TILE_WIDTH * 0.5, y: position.y },
right: { x: position.x + PROJECTED_TILE_WIDTH * 0.5, y: position.y },
top: { x: position.x, y: position.y - PROJECTED_TILE_HEIGHT * 0.5 },
bottom: { x: position.x, y: position.y + PROJECTED_TILE_HEIGHT * 0.5 },
center: { x: position.x, y: position.y },
};
const subset = [];
for (let x = lowX; x < highX + 1; x += 1) {
for (let y = lowY; y < highY + 1; y += 1) {
subset.push(new Coords(x, y));
}
}
return subset;
};
// function getGridSubset(tiles) {
// const { lowX, lowY, highX, highY } = sortByPosition(tiles);
export const isWithinBounds = (tile: Coords, bounds: Coords[]) => {
const { lowX, lowY, highX, highY } = sortByPosition(bounds);
// const subset = [];
// for (let x = lowX; x < highX + 1; x += 1) {
// for (let y = lowY; y < highY + 1; y += 1) {
// subset.push({ x, y });
// }
// }
// return subset;
// }
// function isWithinBounds(tile, bounds) {
// const { lowX, lowY, highX, highY } = sortByPosition(bounds);
// return tile.x >= lowX && tile.x <= highX && tile.y >= lowY && tile.y <= highY;
// }
return tile.x >= lowX && tile.x <= highX && tile.y >= lowY && tile.y <= highY;
};
// function getTranslation(start, end) {
// return { x: start.x - end.x, y: start.y - end.y };
@@ -137,12 +134,3 @@ export const getTileBounds = (x: number, y: number) => {
// return changes;
// }
// module.exports = {
// tileIterator,
// sortByPosition,
// getBoundingBox,
// getGridSubset,
// isWithinBounds,
// diffItems,
// };

View File

@@ -0,0 +1,37 @@
import { getGridSubset, isWithinBounds } from "../gridHelpers";
import { Coords } from "../../elements/Coords";
describe("Tests gridhelper functions", () => {
test("Gets grid subset correctly", () => {
const gridSubset = getGridSubset([new Coords(5, 5), new Coords(7, 7)]);
expect(gridSubset).toEqual([
new Coords(5, 5),
new Coords(5, 6),
new Coords(5, 7),
new Coords(6, 5),
new Coords(6, 6),
new Coords(6, 7),
new Coords(7, 5),
new Coords(7, 6),
new Coords(7, 7),
]);
});
test("Calculates within bounds correctly", () => {
const BOUNDS: Coords[] = [
new Coords(4, 4),
new Coords(6, 4),
new Coords(6, 6),
new Coords(4, 6),
];
const withinBounds = isWithinBounds(new Coords(5, 5), BOUNDS);
const onBorder = isWithinBounds(new Coords(4, 4), BOUNDS);
const outsideBounds = isWithinBounds(new Coords(3, 3), BOUNDS);
expect(withinBounds).toBe(true);
expect(onBorder).toBe(true);
expect(outsideBounds).toBe(false);
});
});

View File

@@ -1,6 +1,7 @@
import { Renderer } from "./renderer/Renderer";
import type { ModeManager } from "./modes/ModeManager";
import { Coords } from "./renderer/elements/Coords";
import { Node } from "./renderer/elements/Node";
export interface Mode {
initial: string;
@@ -19,19 +20,31 @@ export interface ModeContext {
emitEvent: OnSceneChange;
}
export type GeneralEventI = {
type: "SCENE_LOAD";
data: {};
};
export type NodeEventI =
// Grid Events
export type GeneralEventI =
| {
type: "SCENE_LOAD";
data: {};
}
| {
type: "TILE_SELECTED";
data: {
tile: Coords;
};
}
| {
type: "MULTISELECT_UPDATED";
data: {
itemsSelected: Node[];
};
}
| {
type: "ZOOM_CHANGED";
data: {
level: number;
};
};
export type NodeEventI =
// Node Events
| {
type: "NODE_CREATED";
@@ -48,7 +61,7 @@ export type NodeEventI =
| {
type: "NODES_SELECTED";
data: {
nodes: string[];
nodes: Node[];
};
}
| {
@@ -58,13 +71,6 @@ export type NodeEventI =
from: Coords;
to: Coords;
};
}
// Utility Events
| {
type: "ZOOM_CHANGED";
data: {
level: number;
};
};
export type SceneEventI = NodeEventI | GeneralEventI;