feat: shows animated outline around focussed nodes

This commit is contained in:
Mark Mankarious
2023-07-05 18:03:16 +01:00
parent 6052c80dfe
commit 53bd3f2c2f
15 changed files with 258 additions and 53 deletions

24
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@mui/icons-material": "^5.11.9",
"@mui/material": "^5.11.10",
"auto-bind": "^5.0.1",
"chroma-js": "^2.4.2",
"cuid": "^3.0.0",
"deep-diff": "^1.0.2",
"gsap": "^3.11.4",
@@ -29,6 +30,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/chroma-js": "^2.4.0",
"@types/deep-diff": "^1.0.2",
"@types/jest": "^27.5.2",
"@types/jsdom": "^21.1.0",
@@ -2311,6 +2313,12 @@
"@types/node": "*"
}
},
"node_modules/@types/chroma-js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.0.tgz",
"integrity": "sha512-JklMxityrwjBTjGY2anH8JaTx3yjRU3/sEHSblLH1ba5lqcSh1LnImXJZO5peJfXyqKYWjHTGy4s5Wz++hARrw==",
"dev": true
},
"node_modules/@types/connect": {
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@@ -3546,6 +3554,11 @@
"fsevents": "~2.3.2"
}
},
"node_modules/chroma-js": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz",
"integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A=="
},
"node_modules/chrome-trace-event": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
@@ -13142,6 +13155,12 @@
"@types/node": "*"
}
},
"@types/chroma-js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.0.tgz",
"integrity": "sha512-JklMxityrwjBTjGY2anH8JaTx3yjRU3/sEHSblLH1ba5lqcSh1LnImXJZO5peJfXyqKYWjHTGy4s5Wz++hARrw==",
"dev": true
},
"@types/connect": {
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@@ -14172,6 +14191,11 @@
"readdirp": "~3.6.0"
}
},
"chroma-js": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz",
"integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A=="
},
"chrome-trace-event": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",

View File

@@ -20,6 +20,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/chroma-js": "^2.4.0",
"@types/deep-diff": "^1.0.2",
"@types/jest": "^27.5.2",
"@types/jsdom": "^21.1.0",
@@ -48,6 +49,7 @@
"@mui/icons-material": "^5.11.9",
"@mui/material": "^5.11.10",
"auto-bind": "^5.0.1",
"chroma-js": "^2.4.2",
"cuid": "^3.0.0",
"deep-diff": "^1.0.2",
"gsap": "^3.11.4",

View File

@@ -54,7 +54,7 @@ export const nodes: NodeI[] = [
{
id: "Node1",
label: "Node 1",
icon: "block",
iconId: "block",
position: {
x: 0,
y: 0,

View File

@@ -1,6 +1,6 @@
import { ModeBase } from "./ModeBase";
import { Mouse } from "../types";
import { getTargetFromSelection } from "./utils";
import { getTargetFromSelection, isMouseOverNewTile } from "./utils";
import { SelectNode } from "./SelectNode";
import { Node } from "../renderer/elements/Node";
@@ -19,8 +19,10 @@ export class Select extends ModeBase {
this.ctx.renderer.sceneElements.cursor.disable();
}
MOUSE_ENTER() {
this.ctx.renderer.sceneElements.cursor.enable();
MOUSE_ENTER(mouse: Mouse) {
const { renderer } = this.ctx;
renderer.sceneElements.cursor.enable();
}
MOUSE_LEAVE() {
@@ -45,11 +47,23 @@ export class Select extends ModeBase {
}
MOUSE_MOVE(mouse: Mouse) {
const tile = this.ctx.renderer.getTileFromMouse(
mouse.position.x,
mouse.position.y
const newTile = isMouseOverNewTile(
mouse,
this.ctx.renderer.getTileFromMouse
);
this.ctx.renderer.sceneElements.cursor.displayAt(tile.x, tile.y);
if (newTile) {
this.ctx.renderer.sceneElements.cursor.displayAt(newTile.x, newTile.y);
const items = this.ctx.renderer.getItemsByTile(newTile.x, newTile.y);
const target = getTargetFromSelection(items);
this.ctx.renderer.unfocusAll();
if (target instanceof Node) {
target.setFocus(true);
return;
}
}
}
}

View File

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

View File

@@ -252,6 +252,10 @@ export class Renderer {
);
}
unfocusAll() {
this.sceneElements.nodes.unfocusAll();
}
clear() {
this.sceneElements.nodes.clear();
}

View File

@@ -14,7 +14,7 @@ import { tweenPosition } from "../../utils";
export enum CURSOR_TYPES {
OUTLINE = "OUTLINE",
CIRCLE = "CIRCLE",
TILE = "TITLE",
TILE = "TILE",
LASSO = "LASSO",
DOT = "DOT",
}

View File

@@ -1,14 +1,15 @@
import { makeAutoObservable } from "mobx";
import { Group, Raster } from "paper";
import { Group } from "paper";
import { Coords, Context } from "../../types";
import { PROJECTED_TILE_WIDTH, PIXEL_UNIT } from "../constants";
const NODE_IMG_PADDING = 0 * PIXEL_UNIT;
import { theme } from "../../theme";
import { NodeTile } from "./NodeTile";
import { NodeIcon } from "./NodeIcon";
export interface NodeOptions {
id: string;
position: Coords;
icon: string;
iconId: string;
color?: string;
}
interface Callbacks {
@@ -24,11 +25,12 @@ export class Node {
selected = false;
callbacks: Callbacks;
position;
icon;
renderElements = {
iconContainer: new Group(),
icon: new Raster(),
};
color: string = theme.customVars.diagramPalette.purple;
isSelected = false;
isFocussed = false;
icon: NodeIcon;
tile: NodeTile;
constructor(ctx: Context, options: NodeOptions, callbacks: Callbacks) {
makeAutoObservable(this);
@@ -36,40 +38,31 @@ export class Node {
this.ctx = ctx;
this.id = options.id;
this.position = options.position;
this.icon = options.icon;
this.callbacks = callbacks;
this.renderElements.iconContainer.addChild(this.renderElements.icon);
this.container.addChild(this.renderElements.iconContainer);
this.icon = new NodeIcon(options.iconId, ctx);
this.tile = new NodeTile();
this.container.addChild(this.tile.container);
this.container.addChild(this.icon.container);
this.moveTo(this.position.x, this.position.y);
this.destroy = this.destroy.bind(this);
this.init();
}
async init() {
await this.updateIcon(this.icon);
this.moveTo(this.position.x, this.position.y);
// although focus and selection appear to be the same thing, selection happens when a user
// activates a node, and focus happens when a user hovers over a node.
setSelected(state: boolean) {
this.isSelected = state;
this.setFocus(state);
}
async updateIcon(icon: string) {
this.icon = icon;
const { iconContainer, icon: iconEl } = this.renderElements;
setFocus(state: boolean) {
if (!state && this.isSelected) {
return;
}
await new Promise((resolve) => {
iconEl.onLoad = () => {
iconEl.scale(
(PROJECTED_TILE_WIDTH - NODE_IMG_PADDING) / iconEl.bounds.width
);
iconContainer.pivot = iconEl.bounds.bottomCenter;
iconContainer.position.set(0, 0);
resolve(null);
};
iconEl.source = this.ctx.getIconById(this.icon).url;
});
this.tile.setFocus(state);
}
moveTo(x: number, y: number) {
@@ -80,7 +73,7 @@ export class Node {
return {
id: this.id,
position: this.position,
icon: this.icon,
...this.icon.export(),
};
}

View File

@@ -0,0 +1,60 @@
import { Group, Raster } from "paper";
import { PROJECTED_TILE_WIDTH, PIXEL_UNIT } from "../constants";
import { Coords, Context } from "../../types";
const NODE_IMG_PADDING = 0 * PIXEL_UNIT;
export class NodeIcon {
container = new Group();
ctx: Context;
iconId: string;
renderElements = {
iconRaster: new Raster(),
};
constructor(iconId: string, ctx: Context) {
this.ctx = ctx;
this.iconId = iconId;
this.container.addChild(this.renderElements.iconRaster);
this.update(iconId);
}
async update(iconId: string) {
const { iconRaster } = this.renderElements;
this.iconId = iconId;
const icon = this.ctx.getIconById(iconId);
if (!icon) {
return new Error("Icon not found");
}
await new Promise((resolve) => {
iconRaster.onLoad = () => {
iconRaster.scale(
(PROJECTED_TILE_WIDTH - NODE_IMG_PADDING) / iconRaster.bounds.width
);
const raster = iconRaster.rasterize();
this.container.removeChildren();
this.renderElements.iconRaster = raster;
this.container.addChild(raster);
this.container.pivot = iconRaster.bounds.bottomCenter;
resolve(null);
};
iconRaster.source = icon.url;
});
}
export() {
return { iconId: this.iconId };
}
}

View File

@@ -0,0 +1,75 @@
import { Group, Shape, Color } from "paper";
import { PIXEL_UNIT, TILE_SIZE } from "../constants";
import chroma from "chroma-js";
import gsap from "gsap";
import { applyProjectionMatrix } from "../utils/projection";
import { theme } from "../../theme";
const TILE_PADDING = 10 * PIXEL_UNIT;
const TILE_STYLE = {
radius: PIXEL_UNIT * 8,
strokeCap: "round",
strokeWidth: PIXEL_UNIT,
size: [TILE_SIZE + TILE_PADDING * 2, TILE_SIZE + TILE_PADDING * 2],
position: [0, 0],
};
export class NodeTile {
container = new Group();
color: string;
renderElements = {
tile: new Shape.Rectangle({}),
focussedOutline: new Shape.Rectangle({}),
};
animations = {
highlight: gsap
.fromTo(
this.renderElements.focussedOutline,
{ dashOffset: 0 },
{ dashOffset: PIXEL_UNIT * 12, ease: "none", duration: 0.25 }
)
.repeat(-1)
.pause(),
};
constructor(color: string = theme.customVars.diagramPalette.purple) {
this.color = color;
const { tile, focussedOutline } = this.renderElements;
this.renderElements.tile.set(TILE_STYLE);
this.renderElements.focussedOutline.set({
...TILE_STYLE,
radius: PIXEL_UNIT * 12,
strokeWidth: PIXEL_UNIT * 3,
pivot: [0, 0],
dashArray: [PIXEL_UNIT * 6, PIXEL_UNIT * 6],
scaling: 1.2,
visible: false,
});
this.container.addChild(tile);
this.container.addChild(focussedOutline);
applyProjectionMatrix(this.container);
this.setColor(this.color);
}
setFocus(state: boolean) {
this.renderElements.focussedOutline.visible = state;
this.animations.highlight.play();
}
setColor(color: string) {
this.color = color;
this.renderElements.tile.fillColor = new Color(color);
this.renderElements.tile.strokeColor = new Color(
chroma(color).darken(1.5).hex()
);
this.renderElements.focussedOutline.strokeColor = new Color(color);
}
}

View File

@@ -4,7 +4,6 @@ import { makeAutoObservable, toJS } from "mobx";
import { Context } from "../../types";
import { Node, NodeOptions } from "./Node";
import cuid from "cuid";
import { SceneEvent } from "../SceneEvent";
import { tweenPosition } from "../../utils";
export class Nodes {
@@ -20,7 +19,7 @@ export class Nodes {
this.ctx = ctx;
}
addNode(options: NodeOptions, sceneEvent?: SceneEvent) {
addNode(options: NodeOptions) {
const node = new Node(
this.ctx,
{
@@ -87,6 +86,10 @@ export class Nodes {
);
}
unfocusAll() {
this.nodes.forEach((node) => node.setFocus(false));
}
clear() {
this.nodes.forEach((node) => node.destroy());
this.nodes = [];

View File

@@ -17,7 +17,7 @@ export const scene: SceneI = {
{
id: "node1",
label: "Node1",
icon: "icon1",
iconId: "icon1",
position: {
x: 0,
y: 0,
@@ -26,7 +26,7 @@ export const scene: SceneI = {
{
id: "node2",
label: "Node2",
icon: "icon2",
iconId: "icon2",
position: {
x: 1,
y: 1,
@@ -35,7 +35,7 @@ export const scene: SceneI = {
{
id: "node3",
label: "Node3",
icon: "icon1",
iconId: "icon1",
position: {
x: 2,
y: 2,

View File

@@ -25,7 +25,7 @@ describe("scene validation works correctly", () => {
const { icons } = scene;
const invalidNode = {
id: "invalidNode",
icon: "doesntExist",
iconId: "doesntExist",
position: { x: -1, y: -1 },
};
const nodes: NodeI[] = [...scene.nodes, invalidNode];

View File

@@ -7,6 +7,9 @@ interface CustomThemeVars {
toolMenu: {
height: number;
};
diagramPalette: {
purple: string;
};
}
declare module "@mui/material/styles" {
@@ -26,6 +29,9 @@ const customVars: CustomThemeVars = {
toolMenu: {
height: 55,
},
diagramPalette: {
purple: "#cabffa",
},
};
export const theme = createTheme({

View File

@@ -10,7 +10,7 @@ export const IconSchema = z.object({
export const NodeSchema = z.object({
id: z.string(),
label: z.string().optional(),
icon: z.string(),
iconId: z.string(),
position: z.object({
x: z.number(),
y: z.number(),
@@ -37,7 +37,7 @@ export type GroupI = z.infer<typeof GroupSchema>;
export const findInvalidNode = (nodes: NodeI[], icons: IconI[]) => {
return nodes.find((node) => {
const validIcon = icons.find((icon) => node.icon === icon.id);
const validIcon = icons.find((icon) => node.iconId === icon.id);
return !Boolean(validIcon);
});
};