mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-24 06:58:48 -05:00
feat: shows animated outline around focussed nodes
This commit is contained in:
24
package-lock.json
generated
24
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -54,7 +54,7 @@ export const nodes: NodeI[] = [
|
||||
{
|
||||
id: "Node1",
|
||||
label: "Node 1",
|
||||
icon: "block",
|
||||
iconId: "block",
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -252,6 +252,10 @@ export class Renderer {
|
||||
);
|
||||
}
|
||||
|
||||
unfocusAll() {
|
||||
this.sceneElements.nodes.unfocusAll();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.sceneElements.nodes.clear();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { tweenPosition } from "../../utils";
|
||||
export enum CURSOR_TYPES {
|
||||
OUTLINE = "OUTLINE",
|
||||
CIRCLE = "CIRCLE",
|
||||
TILE = "TITLE",
|
||||
TILE = "TILE",
|
||||
LASSO = "LASSO",
|
||||
DOT = "DOT",
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
60
src/renderer/elements/NodeIcon.ts
Normal file
60
src/renderer/elements/NodeIcon.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
75
src/renderer/elements/NodeTile.ts
Normal file
75
src/renderer/elements/NodeTile.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
6
src/tests/fixtures/scene.ts
vendored
6
src/tests/fixtures/scene.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user