diff --git a/.storybook/styles/global.css b/.storybook/styles/global.css index 861c7de..ef0217c 100644 --- a/.storybook/styles/global.css +++ b/.storybook/styles/global.css @@ -1,7 +1,47 @@ +@mixin nv-legacy-text { + @font-face { + font-family: "YS Text"; + src: url("https://yastatic.net/s3/home/fonts/ys/1/text-light.woff2") format("woff2"); + font-weight: 300; + font-style: normal; + font-stretch: normal; + font-display: swap; + } + + @font-face { + font-family: "YS Text"; + src: url("https://yastatic.net/s3/home/fonts/ys/1/text-regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-stretch: normal; + font-display: swap; + } + + @font-face { + font-family: "YS Text"; + src: url("https://yastatic.net/s3/home/fonts/ys/1/text-medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-stretch: normal; + font-display: swap; + } + + @font-face { + font-family: "YS Text"; + src: url("https://yastatic.net/s3/home/fonts/ys/1/text-bold.woff2") format("woff2"); + font-weight: bold; + font-style: normal; + font-stretch: normal; + font-display: swap; + } +} + + .sb-show-main { padding: 0 !important; - margin: 0; + margin: 0; + font-family: "YS Text"; } .toolbox { diff --git a/package-lock.json b/package-lock.json index 38f932f..cb781f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@monaco-editor/react": "^4.6.0", "@preact/signals-core": "^1.5.1", "intersects": "^2.7.2", "lodash-es": "^4.17.21", @@ -4029,6 +4030,30 @@ "react": ">=16" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz", + "integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==", + "dependencies": { + "state-local": "^1.0.6" + }, + "peerDependencies": { + "monaco-editor": ">= 0.21.0 < 1" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz", + "integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==", + "dependencies": { + "@monaco-editor/loader": "^1.4.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -13984,8 +14009,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -14440,7 +14464,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -14774,6 +14797,12 @@ "node": ">=0.4.0" } }, + "node_modules/monaco-editor": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz", + "integrity": "sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw==", + "peer": true + }, "node_modules/moo-color": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", @@ -16216,7 +16245,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -16315,7 +16343,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -16974,7 +17001,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -17301,6 +17327,11 @@ "node": ">=8" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/package.json b/package.json index 87c3635..de49b20 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ } ], "dependencies": { + "@monaco-editor/react": "^4.6.0", "@preact/signals-core": "^1.5.1", "intersects": "^2.7.2", "lodash-es": "^4.17.21", diff --git a/src/components/canvas/anchors/index.ts b/src/components/canvas/anchors/index.ts index 81a9f6e..1c8276e 100644 --- a/src/components/canvas/anchors/index.ts +++ b/src/components/canvas/anchors/index.ts @@ -1,5 +1,5 @@ import { EventedComponent } from "../../../mixins/withEvents"; -import { withHitTest } from "../../../mixins/withHitTest"; +import { debugHitBox, withHitTest } from "../../../mixins/withHitTest"; import { ECameraScaleLevel } from "../../../services/camera/CameraService"; import { frameDebouncer } from "../../../services/optimizations/frameDebouncer"; import { AnchorState, EAnchorType } from "../../../store/anchor/Anchor"; @@ -13,7 +13,7 @@ export type TAnchor = { id: string; blockId: TBlockId; type: EAnchorType | string; - index: number; + index?: number; }; export type TAnchorProps = TAnchor & { @@ -30,6 +30,9 @@ type TAnchorState = { }; export class Anchor extends withHitTest(EventedComponent) { + + public readonly cursor = 'pointer'; + public get zIndex() { // @ts-ignore this.__comp.parent instanceOf Block return this.__comp.parent.zIndex + 1; @@ -45,7 +48,7 @@ export class Anchor extends withHitTest(EventedComponent) { private shift: number; - private hitBoxHash: number; + private hitBoxHash: string; private debouncedSetHitBox: (...args: any[]) => void; @@ -75,6 +78,10 @@ export class Anchor extends withHitTest(EventedComponent) { this.shift = this.props.size / 2 + props.lineWidth; } + public getPosition() { + return this.props.getPosition(this.props); + } + protected subscribe() { return [ this.connectedState.$selected.subscribe((selected) => { @@ -95,9 +102,21 @@ export class Anchor extends withHitTest(EventedComponent) { public willIterate() { super.willIterate(); + const { x, y, width, height } = this.hitBox.getRect(); this.shouldRender = width && height ? this.context.camera.isRectVisible(x, y, width, height) : true; + + } + + public didIterate(): void { + const { x: poxX, y: posY } = this.props.getPosition(this.props); + const hash = `${poxX}/${posY}/${this.shift}`; + + if (this.hitBoxHash !== hash) { + this.hitBoxHash = hash; + this.debouncedSetHitBox(); + } } public handleEvent(event: MouseEvent | KeyboardEvent) { @@ -108,14 +127,16 @@ export class Anchor extends withHitTest(EventedComponent) { case "click": this.toggleSelected(); break; - case "mouseenter": + case "mouseenter":{ this.setState({ raised: true }); this.computeRenderSize(this.props.size, true); break; - case "mouseleave": + } + case "mouseleave": { this.setState({ raised: false }); this.computeRenderSize(this.props.size, false); break; + } } } @@ -124,18 +145,6 @@ export class Anchor extends withHitTest(EventedComponent) { this.setHitBox(x - this.shift, y - this.shift, x + this.shift, y + this.shift); } - public didRender() { - super.didRender(); - const { x, y } = this.props.getPosition(this.props); - - const hash = x / y + this.shift; - - if (this.hitBoxHash !== hash) { - this.hitBoxHash = hash; - this.debouncedSetHitBox(); - } - } - private computeRenderSize(size: number, raised: boolean) { if (raised) { this.setState({ size: size * 1.8 }); @@ -145,25 +154,21 @@ export class Anchor extends withHitTest(EventedComponent) { } protected render() { - // debugHitBox(this.context.ctx, this); - if (this.context.camera.getCameraBlockScaleLevel() === ECameraScaleLevel.Detailed) { return; } const { x, y } = this.props.getPosition(this.props); - render(this.context.ctx, (ctx) => { - ctx.save(); - ctx.fillStyle = this.context.colors.anchor.background; - ctx.beginPath(); - ctx.arc(x, y, this.state.size * 0.5, 0, 2 * Math.PI); - ctx.fill(); - - if (this.state.selected) { - ctx.strokeStyle = this.context.colors.anchor.selectedBorder; - ctx.lineWidth = this.props.lineWidth + 3; - ctx.stroke(); - } - ctx.closePath(); - }) + const ctx = this.context.ctx; + ctx.fillStyle = this.context.colors.anchor.background; + ctx.beginPath(); + ctx.arc(x, y, this.state.size * 0.5, 0, 2 * Math.PI); + ctx.fill(); + + if (this.state.selected) { + ctx.strokeStyle = this.context.colors.anchor.selectedBorder; + ctx.lineWidth = this.props.lineWidth + 3; + ctx.stroke(); + } + ctx.closePath(); } } diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index deb8777..f1b8e1f 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -103,6 +103,8 @@ export class Block { + this.shouldUpdateChildren = true; + this.performRender(); + }) ]; } @@ -334,10 +340,11 @@ export class Block this.context.constants.block.SCALES[0]; + public renderSchematicView(ctx: CanvasRenderingContext2D) { + this.renderBody(ctx); - if (shouldRenderText) { + if (this.shouldRenderText) { ctx.fillStyle = this.context.colors.block.text; ctx.textAlign = "center"; this.renderText(this.state.name, ctx); } - if (this.state.selected) { - this.renderStroke(this.context.colors.block.selectedBorder); - } + } - ctx.globalAlpha = 1; + public setHiddenBlock(hidden: boolean) { + if (this.hidden !== hidden) { + this.hidden = hidden; + this.performRender(); + } } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public renderDetailedView(_ctx: CanvasRenderingContext2D) { - return; + public renderDetailedView(ctx: CanvasRenderingContext2D) { + return this.renderBody(ctx); } protected render() { diff --git a/src/components/canvas/connections/BlockConnection.ts b/src/components/canvas/connections/BlockConnection.ts index 7f48092..e83e127 100644 --- a/src/components/canvas/connections/BlockConnection.ts +++ b/src/components/canvas/connections/BlockConnection.ts @@ -38,6 +38,8 @@ type TConnectionState = TConnection & { }; export class BlockConnection extends withBatchedConnection(withHitTest(EventedComponent)) { + + public readonly cursor = 'pointer'; public declare props: TConnectionProps; public declare state: TConnectionState; diff --git a/src/components/canvas/layers/graphLayer/GraphLayer.ts b/src/components/canvas/layers/graphLayer/GraphLayer.ts index b8bda78..79a5d2a 100644 --- a/src/components/canvas/layers/graphLayer/GraphLayer.ts +++ b/src/components/canvas/layers/graphLayer/GraphLayer.ts @@ -206,13 +206,18 @@ export class GraphLayer extends Layer { this.eventByTargetComponent = event; - const point = this.getTargetPoint(event); + const point = this.context.graph.getPointInCameraSpace(event); this.targetComponent = this.context.graph.getElementOverPoint(point) || this.$.camera; } private onRootPointerMove(event: MouseEvent) { if (this.targetComponent !== this.prevTargetComponent) { + if(this.targetComponent?.cursor) { + this.root.style.cursor = this.targetComponent?.cursor; + } else { + this.root.style.removeProperty('cursor'); + } this.applyEventToTargetComponent( new CustomEvent("mouseleave", { bubbles: false, @@ -264,13 +269,6 @@ export class GraphLayer extends Layer { return this.onRootPointerStart(event); }; - private getTargetPoint(event: MouseEvent) { - const xy = getXY(this.context.canvas, event); - - const applied = this.camera.applyToPoint(xy[0], xy[1]); - return new Point(applied[0], applied[1], { x: xy[0], y: xy[1] }); - } - private onRootPointerStart(event: MouseEvent) { if (event.button === 2 /* Mouse right button */) { /* diff --git a/src/graph.ts b/src/graph.ts index fefa970..d75e9c4 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -29,14 +29,14 @@ export type LayerConfig = Constructor> = [ ? Omit & { root?: Props["root"] } : never, ]; -export type TGraphConfig = {}> = { +export type TGraphConfig = { configurationName?: string; - blocks?: TBlock[]; + blocks?: B[]; connections?: TConnection[]; rect?: TRect; cameraXY?: TPoint; cameraScale?: number; - settings?: Partial>; + settings?: Partial>; layers?: LayerConfig[]; }; diff --git a/src/mixins/withEvents.ts b/src/mixins/withEvents.ts index 1b29c27..4f0f8e6 100644 --- a/src/mixins/withEvents.ts +++ b/src/mixins/withEvents.ts @@ -1,87 +1,86 @@ import { Component } from "../lib/Component"; import { Constructor } from "../lib/tshelpers"; -export function withEvent>(Base: T): T & Constructor { - return class WithEvent extends Base implements IWithEvent { - private _listenEvents: object = {}; +export class EventedComponent extends Component { + private _listenEvents: object = {}; - public unmount() { - super.unmount(); - this._listenEvents = {}; - } + public readonly cursor?: string; - public hasEventListener(type: string) { - return Object.prototype.hasOwnProperty.call(this._listenEvents, type); - } + protected unmount() { + super.unmount(); + this._listenEvents = {}; + } - public addEventListener(type: string, cbOrObject: any) { - if (Array.isArray(this._listenEvents[type])) { - this._listenEvents[type].push(cbOrObject); - } else { - this._listenEvents[type] = [cbOrObject]; - } + public hasEventListener(type: string) { + return Object.prototype.hasOwnProperty.call(this._listenEvents, type); + } + + public addEventListener(type: string, cbOrObject: any) { + if (Array.isArray(this._listenEvents[type])) { + this._listenEvents[type].push(cbOrObject); + } else { + this._listenEvents[type] = [cbOrObject]; } + return () => this.removeEventListener(type, cbOrObject); + } - public removeEventListener(type: string, cbOrObject: any) { - if (Array.isArray(this._listenEvents[type])) { - const i = this._listenEvents[type].indexOf(cbOrObject); + public removeEventListener(type: string, cbOrObject: any) { + if (Array.isArray(this._listenEvents[type])) { + const i = this._listenEvents[type].indexOf(cbOrObject); - if (i !== -1) { - this._listenEvents[type].splice(i, 1); - } + if (i !== -1) { + this._listenEvents[type].splice(i, 1); } } + } - public _fireEvent(cmp: any, event: Event) { - if (!this._hasListener(cmp, event.type)) { - return; - } + public _fireEvent(cmp: any, event: Event) { + if (!this._hasListener(cmp, event.type)) { + return; + } - const fnsOrObjects = cmp._listenEvents[event.type]; + const fnsOrObjects = cmp._listenEvents[event.type]; - for (let i = 0; i < fnsOrObjects.length; i += 1) { - if (typeof fnsOrObjects[i] === "function") { - fnsOrObjects[i](event); - } else if (typeof fnsOrObjects[i] === "object" && typeof fnsOrObjects[i].handleEvent === "function") { - fnsOrObjects[i].handleEvent(event); - } + for (let i = 0; i < fnsOrObjects.length; i += 1) { + if (typeof fnsOrObjects[i] === "function") { + fnsOrObjects[i](event); + } else if (typeof fnsOrObjects[i] === "object" && typeof fnsOrObjects[i].handleEvent === "function") { + fnsOrObjects[i].handleEvent(event); } } + } - public dispatchEvent(event: Event): boolean { - const bubbles = event.bubbles || false; + public dispatchEvent(event: Event): boolean { + const bubbles = event.bubbles || false; - if (bubbles) { - return this._dipping(this, event); - } else if (this._hasListener(this, event.type)) { - this._fireEvent(this, event); - return false; - } + if (bubbles) { + return this._dipping(this, event); + } else if (this._hasListener(this, event.type)) { + this._fireEvent(this, event); return false; } + return false; + } + + public _dipping(startParent: Component, event: Event) { + let stopPropagation = false; + let parent: Component | undefined = startParent; + event.stopPropagation = () => { + stopPropagation = true; + }; + + do { + this._fireEvent(parent, event); + if (stopPropagation) { + return false; + } + parent = parent.getParent() as Component; + } while (parent); - public _dipping(startParent: Component, event: Event) { - let stopPropagation = false; - let parent: Component | undefined = startParent; - event.stopPropagation = () => { - stopPropagation = true; - }; - - do { - this._fireEvent(parent, event); - if (stopPropagation) { - return false; - } - parent = parent.getParent() as Component; - } while (parent); - - return true; - } + return true; + } - public _hasListener(comp: any, type: string) { - return comp._listenEvents !== undefined && comp._listenEvents[type] !== undefined; - } - }; + public _hasListener(comp: any, type: string) { + return comp._listenEvents !== undefined && comp._listenEvents[type] !== undefined; + } } - -export class EventedComponent extends withEvent(Component) {} diff --git a/src/react-component/Anchor.css b/src/react-component/Anchor.css index fa78b1b..956368a 100644 --- a/src/react-component/Anchor.css +++ b/src/react-component/Anchor.css @@ -8,6 +8,7 @@ width: var(--graph-block-anchor-width, 16px); height: var(--graph-block-anchor-height, 16px); cursor: pointer; + will-change: transform; } .graph-block-anchor.graph-block-anchor-selected { diff --git a/src/react-component/Anchor.tsx b/src/react-component/Anchor.tsx index 7338979..2ee0c8c 100644 --- a/src/react-component/Anchor.tsx +++ b/src/react-component/Anchor.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useMemo, useRef } from "react"; +import React, { CSSProperties, useEffect, useMemo, useRef } from "react"; import { computed } from "@preact/signals-core"; import { TAnchor } from "../components/canvas/anchors"; import { Graph } from "../graph"; import { useSignal } from "./hooks"; -import { useBlockAnchorState } from "./hooks/useBlockAnchorState"; +import { useBlockAnchorPosition, useBlockAnchorState } from "./hooks/useBlockAnchorState"; import { AnchorState } from "../store/anchor/Anchor"; import "./Anchor.css"; @@ -22,34 +22,12 @@ export function GraphBlockAnchor({ className?: string; children?: React.ReactNode | ((anchorState: AnchorState) => React.ReactNode); }) { - const container = useRef(null); const anchorState = useBlockAnchorState(graph, anchor); - useEffect(() => { - if (!container.current) { - return; - } - if (position === "absolute") { - const position = anchorState.block.getViewComponent().getAnchorPosition(anchor); - if (position) { - setCssProps(container.current, { - "--graph-block-anchor-x": `${position.x}px`, - "--graph-block-anchor-y": `${position.y}px`, - }); - } else { - removeCssProps(container.current, ["--graph-block-anchor-x", "--graph-block-anchor-y"]); - } - } - }, [container, position, anchorState, anchor]); + const coords = useBlockAnchorPosition(anchorState) - const selectedSignal = useMemo(() => { - return computed(() => { - return anchorState?.$selected.value; - }); - }, [anchorState]); - - const selected = useSignal(selectedSignal); + const selected = useSignal(anchorState?.$selected); const render = typeof children === "function" ? children : () => children; const classNames = useMemo(() => { @@ -59,7 +37,13 @@ export function GraphBlockAnchor({ }, [anchor, position, className, selected]); return ( -
+
{render(anchorState)}
); diff --git a/src/react-component/Block.css b/src/react-component/Block.css index 1cf4684..c364861 100644 --- a/src/react-component/Block.css +++ b/src/react-component/Block.css @@ -8,15 +8,16 @@ will-change: transform, width, height; width: var(--graph-block-geometry-width, 0); height: var(--graph-block-geometry-height, 0); + --z-index: calc(var(--graph-block-z-index, 0) + var(--graph-block-order, 0)); + z-index: var(--z-index); } .graph-block-wrapper { flex: 1; + min-width: 0; cursor: pointer; box-sizing: border-box; - --z-index: calc(var(--graph-block-z-index, 0) + var(--graph-block-order, 0)); - z-index: var(--z-index); background: var(--graph-block-bg); border: 1px solid var(--graph-block-border) diff --git a/src/react-component/Block.tsx b/src/react-component/Block.tsx index 06329b1..e085d9c 100644 --- a/src/react-component/Block.tsx +++ b/src/react-component/Block.tsx @@ -3,7 +3,7 @@ import { TBlock } from "../components/canvas/blocks/Block"; import { Graph } from "../graph"; import "./Block.css"; import { setCssProps } from "../utils/functions/cssProp"; -import { useBlockViewState } from "./hooks/useBlockState"; +import { useBlockState, useBlockViewState } from "./hooks/useBlockState"; export const GraphBlock = ({ graph, @@ -20,6 +20,12 @@ export const GraphBlock = ({ }) => { const containerRef = useRef(null); const viewState = useBlockViewState(graph, block); + const state = useBlockState(graph, block); + + useEffect(() => { + viewState?.setHiddenBlock(true); + return () => viewState?.setHiddenBlock(false); + }, [viewState]); useLayoutEffect(() => { setCssProps(containerRef.current, { @@ -41,10 +47,14 @@ export const GraphBlock = ({ } }, [viewState, containerRef]); + if(!viewState || !state) { + return; + } + return (
-
{children}
+
{children}
); }; diff --git a/src/react-component/hooks/useBlockAnchorState.ts b/src/react-component/hooks/useBlockAnchorState.ts index 2a994dc..e48df1c 100644 --- a/src/react-component/hooks/useBlockAnchorState.ts +++ b/src/react-component/hooks/useBlockAnchorState.ts @@ -4,7 +4,8 @@ import { useSignal } from "./useSignal"; import { useBlockState } from "./useBlockState"; import { AnchorState } from "../../store/anchor/Anchor"; import { computed } from "@preact/signals-core"; -import { useMemo } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import debounce from "lodash/debounce"; export function useBlockAnchorState(graph: Graph, anchor: TAnchor): AnchorState | undefined { const blockState = useBlockState(graph, anchor.blockId); @@ -13,3 +14,20 @@ export function useBlockAnchorState(graph: Graph, anchor: TAnchor): AnchorState }, [blockState, anchor]); return useSignal(signal); } + +export function useBlockAnchorPosition(state: AnchorState | undefined) { + const [pos, setPos] = useState<{ x: number, y: number }>(state.block ? state.block.getViewComponent().getAnchorPosition(state.state) : {x: 0, y: 0}); + + useLayoutEffect(() => { + if(!state) { + return; + } + return state.block.$geometry.subscribe(debounce(() => { + const position = state.block.getViewComponent().getAnchorPosition(state.state); + setPos(position); + }, 16)) + }, [state.block]); + + return pos; +} + diff --git a/src/react-component/hooks/useSignal.ts b/src/react-component/hooks/useSignal.ts index 921cabb..37c1e95 100644 --- a/src/react-component/hooks/useSignal.ts +++ b/src/react-component/hooks/useSignal.ts @@ -4,10 +4,8 @@ import type { Signal } from "@preact/signals-core"; export function useSignal(signal: Signal) { const [state, setState] = useState(signal.value); - const ref = useRef(setState); - ref.current = setState; useEffect(() => { - return signal.subscribe((v) => ref.current(v)); - }, [ref, signal]); + return signal.subscribe(setState); + }, [signal]); return state; } diff --git a/src/services/camera/Camera.ts b/src/services/camera/Camera.ts index ecacbdc..8d4da05 100644 --- a/src/services/camera/Camera.ts +++ b/src/services/camera/Camera.ts @@ -1,6 +1,5 @@ import { TGraphLayerContext } from "../../components/canvas/layers/graphLayer/GraphLayer"; -import { Component } from "../../lib/Component"; -import { withEvent } from "../../mixins/withEvents"; +import { EventedComponent } from "../../mixins/withEvents"; import { getXY, isMetaKeyEvent, isTrackpadWheelEvent, isWindows } from "../../utils/functions"; import { clamp } from "../../utils/functions/clamp"; import { dragListener } from "../../utils/functions/dragListener"; @@ -12,7 +11,7 @@ export type TCameraProps = { root?: HTMLElement; }; -export class Camera extends withEvent(Component) { +export class Camera extends EventedComponent { public declare props: TCameraProps; public declare context: TGraphLayerContext; diff --git a/src/services/newConnection/ConnectionService.ts b/src/services/newConnection/ConnectionService.ts index c44c864..9a5c71d 100644 --- a/src/services/newConnection/ConnectionService.ts +++ b/src/services/newConnection/ConnectionService.ts @@ -45,6 +45,15 @@ declare module "../../graphEvents" { targetAnchorId?: string; }> ) => void; + "connection-create-drop": ( + event: CustomEvent<{ + sourceBlockId: TBlockId; + sourceAnchorId: string; + targetBlockId?: TBlockId; + targetAnchorId?: string; + point: Point + }> + ) => void; } } @@ -128,15 +137,10 @@ export class ConnectionService extends Emitter { } public onMoveNewConnection(event: MouseEvent, point: Point) { - this.emit(EVENTS.NEW_CONNECTION_UPDATE, event); - const newTargetComponent = this.graph.getElementOverPoint(point, [Block, Anchor]); + + this.emit(EVENTS.NEW_CONNECTION_UPDATE, { target: newTargetComponent, event }); - if (!(newTargetComponent instanceof Block) || !(newTargetComponent instanceof Anchor)) { - return; - } - - /* Unset selection on move new selection target-point out of components */ if (!newTargetComponent || !newTargetComponent.connectedState) { this.targetComponent?.setSelection(false); this.targetComponent = undefined; @@ -169,15 +173,40 @@ export class ConnectionService extends Emitter { } } + protected getBlockId(component: BlockState | AnchorState) { + if (component instanceof AnchorState) { + return component.blockId; + } + return component.id; + } + + protected getAnchorId(component: BlockState | AnchorState) { + if (component instanceof AnchorState) { + return component.id; + } + return undefined; + } + public onEndNewConnection(point: Point) { + if (!this.sourceComponent) { + return; + } const targetComponent = this.graph.getElementOverPoint(point, [Block, Anchor]); this.emit(EVENTS.NEW_CONNECTION_END); if (!(targetComponent instanceof Block) && !(targetComponent instanceof Anchor)) { + this.graph.executеDefaultEventAction( + "connection-create-drop", + { + sourceBlockId: this.getBlockId(this.sourceComponent), + sourceAnchorId: this.getAnchorId(this.sourceComponent), + point, + }, + () => { } + ); return; } if ( - this.sourceComponent && targetComponent && targetComponent.connectedState && this.sourceComponent !== targetComponent.connectedState @@ -211,10 +240,21 @@ export class ConnectionService extends Emitter { } ); } - this.sourceComponent.setSelection(false); targetComponent.connectedState.setSelection(false); } + + this.graph.executеDefaultEventAction( + "connection-create-drop", + { + sourceBlockId: this.getBlockId(this.sourceComponent), + sourceAnchorId: this.getAnchorId(this.sourceComponent), + targetBlockId: this.getBlockId(targetComponent.connectedState), + targetAnchorId: this.getAnchorId(targetComponent.connectedState), + point, + }, + () => { } + ); } public unmount() { diff --git a/src/services/newConnection/NewConnection.ts b/src/services/newConnection/NewConnection.ts index 0c3cfa6..8069d5a 100644 --- a/src/services/newConnection/NewConnection.ts +++ b/src/services/newConnection/NewConnection.ts @@ -1,7 +1,10 @@ +import { Anchor } from "../../components/canvas/anchors"; +import { Block } from "../../components/canvas/blocks/Block"; import { OverLayer, TOverLayerContext } from "../../components/canvas/layers/overLayer/OverLayer"; import { Component } from "../../lib/Component"; import { getXY } from "../../utils/functions"; import { render } from "../../utils/renderers/render"; +import { renderSVG } from "../../utils/renderers/svgPath"; import { EVENTS } from "../../utils/types/events"; import { ConnectionService } from "./ConnectionService"; @@ -32,16 +35,41 @@ export class NewConnection extends Component { } protected render() { - return render(this.context.ctx, (ctx) => { - ctx.strokeStyle = this.context.colors.connection.selectedBackground; - ctx.globalAlpha = 1; - + render(this.context.ctx, (ctx) => { ctx.beginPath(); + ctx.strokeStyle = this.context.colors.connection.selectedBackground; + ctx.setLineDash([4, 4]); ctx.moveTo(this.state.sx, this.state.sy); ctx.lineTo(this.state.tx, this.state.ty); ctx.stroke(); ctx.closePath(); }); + render(this.context.ctx, (ctx) => { + ctx.beginPath(); + ctx.fillStyle = "rgba(254, 190, 92, 1)"; + ctx.roundRect(this.state.tx, this.state.ty - 12, 24, 24, 8); + ctx.fill(); + ctx.fillStyle = this.context.colors.canvas.belowLayerBackground; + if(!this.target) { + renderSVG({ + path: `M7 0.75C7.41421 0.75 7.75 1.08579 7.75 1.5V6.25H12.5C12.9142 6.25 13.25 6.58579 13.25 7C13.25 7.41421 12.9142 7.75 12.5 7.75H7.75V12.5C7.75 12.9142 7.41421 13.25 7 13.25C6.58579 13.25 6.25 12.9142 6.25 12.5V7.75H1.5C1.08579 7.75 0.75 7.41421 0.75 7C0.75 6.58579 1.08579 6.25 1.5 6.25H6.25V1.5C6.25 1.08579 6.58579 0.75 7 0.75Z`, + width: 14, + height: 14, + iniatialWidth: 14, + initialHeight: 14 + }, ctx, { x: this.state.tx, y: this.state.ty - 12, width: 24, height: 24 }); + } else { + renderSVG({ + path: `M15.53 1.53A.75.75 0 0 0 14.47.47l-1.29 1.29a4.24 4.24 0 0 0-5.423.483l-.58.58a.96.96 0 0 0 0 1.354l4.646 4.646a.96.96 0 0 0 1.354 0l.58-.58a4.24 4.24 0 0 0 .484-5.423zm-8.5 4.94a.75.75 0 0 1 0 1.06L5.78 8.78l1.44 1.44 1.25-1.25a.75.75 0 0 1 1.06 1.06l-1.25 1.25.543.543a.96.96 0 0 1 0 1.354l-.58.58a4.24 4.24 0 0 1-5.423.484l-1.29 1.29A.75.75 0 0 1 .47 14.47l1.29-1.29a4.24 4.24 0 0 1 .483-5.423l.58-.58a.96.96 0 0 1 1.354 0l.543.543 1.25-1.25a.75.75 0 0 1 1.06 0M3.5 8.62l-.197.197a2.743 2.743 0 0 0 3.879 3.879l.197-.197zm9.197-1.439-.197.197L8.621 3.5l.197-.197a2.743 2.743 0 0 1 3.879 3.879`, + width: 14, + height: 14, + iniatialWidth: 16, + initialHeight: 16 + }, ctx, { x: this.state.tx, y: this.state.ty - 12, width: 24, height: 24 }); + } + + ctx.closePath(); + }) } private startNewConnectionRender = (event: MouseEvent) => { @@ -53,7 +81,10 @@ export class NewConnection extends Component { }); }; - private updateNewConnectionRender = (event: MouseEvent) => { + protected target?: Block | Anchor; + + private updateNewConnectionRender = ({target, event}: { target?: Block | Anchor, event: MouseEvent }) => { + this.target = target; const xy = getXY(this.context.graphCanvas, event); this.setState({ tx: xy[0], diff --git a/src/services/selection/SelectionArea.ts b/src/services/selection/SelectionArea.ts index 74c4d98..5e4dc14 100644 --- a/src/services/selection/SelectionArea.ts +++ b/src/services/selection/SelectionArea.ts @@ -3,6 +3,7 @@ import { Component } from "../../lib/Component"; import { getXY } from "../../utils/functions"; import { render } from "../../utils/renderers/render"; import { EVENTS } from "../../utils/types/events"; +import { Point } from "../../utils/types/shapes"; import { SelectionAreaService } from "./SelectionAreaService"; export type SelectionAreaProps = { @@ -10,20 +11,20 @@ export type SelectionAreaProps = { }; export class SelectionArea extends Component { - public declare state: { sx: number; sy: number; width: number; height: number }; + public declare state: { width: number; height: number }; public declare context: TOverLayerContext; public declare props: SelectionAreaProps; + protected startPoint = new Point(0,0); + public constructor(props: SelectionAreaProps, context: OverLayer) { super(props, context); this.context = context.context; this.state = { - sx: 0, - sy: 0, width: 0, height: 0, }; @@ -34,13 +35,14 @@ export class SelectionArea extends Component { } protected render() { + if (!this.state.width && !this.state.height) { + return; + } return render(this.context.ctx, (ctx) => { ctx.fillStyle = this.context.colors.selection.background; ctx.strokeStyle = this.context.colors.selection.border; - ctx.globalAlpha = 1; - ctx.beginPath(); - ctx.rect(this.state.sx, this.state.sy, this.state.width, this.state.height); + ctx.roundRect(this.startPoint.x, this.startPoint.y, this.state.width, this.state.height, 1 * window.devicePixelRatio); ctx.closePath(); ctx.fill(); @@ -60,17 +62,15 @@ export class SelectionArea extends Component { private updateSelectionRender = (event: MouseEvent) => { const xy = getXY(this.context.graphCanvas, event); this.setState({ - width: xy[0] - this.state.sx, - height: xy[1] - this.state.sy, + width: xy[0] - this.startPoint.x, + height: xy[1] - this.startPoint.y, }); }; private startSelectionRender = (event: MouseEvent) => { - const xy = getXY(this.context.graphCanvas, event); - this.setState({ - sx: xy[0], - sy: xy[1], - }); + const [x,y] = getXY(this.context.graphCanvas, event); + this.startPoint.x = x; + this.startPoint.y = y; }; private endSelectionRender = () => { diff --git a/src/store/anchor/Anchor.ts b/src/store/anchor/Anchor.ts index bf21f5d..2ba69c5 100644 --- a/src/store/anchor/Anchor.ts +++ b/src/store/anchor/Anchor.ts @@ -19,12 +19,21 @@ export class AnchorState { return this.$state.value.blockId; } + public get state() { + return this.$state.value + } + public constructor( public readonly block: BlockState, anchor: TAnchor ) { this.$state.value = anchor; } + + public update(anchor: TAnchor) { + this.$state.value = anchor; + } + public setSelection(selected: boolean, silent?: boolean) { if (silent) { this.$selected.value = selected; diff --git a/src/store/block/Block.ts b/src/store/block/Block.ts index f2cb829..b4282ab 100644 --- a/src/store/block/Block.ts +++ b/src/store/block/Block.ts @@ -3,6 +3,7 @@ import { BlockListStore } from "./BlocksList"; import { Block, TBlock } from "../../components/canvas/blocks/Block"; import { AnchorState } from "../anchor/Anchor"; import { ESelectionStrategy } from "../../utils/types/types"; +import { TAnchor } from "../../components/canvas/anchors"; export type TBlockId = string | number | symbol; @@ -39,6 +40,16 @@ export class BlockState { public readonly $anchorStates: Signal = signal([]); + public $anchorIndexs = computed(() => { + const typeIndex = {}; + return new Map(this.$anchorStates.value?.sort((a, b) => ((a.state.index || 0) - (b.state.index || 0)) ).map((anchorState) => { + if (!typeIndex[anchorState.state.type]) { + typeIndex[anchorState.state.type] = 0; + } + return [anchorState.id, typeIndex[anchorState.state.type]++]; + }) || []); + }); + public $anchors = computed(() => { return this.$anchorStates.value?.map((anchorState) => anchorState.asTAnchor()) || []; }); @@ -49,8 +60,6 @@ export class BlockState { private blockView: Block; - public readonly dispose; - public constructor( public readonly store: BlockListStore, block: T @@ -110,8 +119,23 @@ export class BlockState { }; }); + public updateAnchors(anchors: TAnchor[]) { + const anchorsMap = new Map(this.$anchorStates.value.map((a) => [a.id, a])); + this.$anchorStates.value = anchors.map((anchor) => { + if (anchorsMap.has(anchor.id)) { + const anchorState = anchorsMap.get(anchor.id); + anchorState.update(anchor); + return anchorState; + } + return new AnchorState(this, anchor); + }) + } + public updateBlock(block: Partial): void { this.$state.value = Object.assign({}, this.$state.value, block); + if (block.anchors) { + this.updateAnchors(block.anchors); + } this.getViewComponent()?.updateHitBox(this.$geometry.value, true); } diff --git a/src/store/block/BlocksList.ts b/src/store/block/BlocksList.ts index 3a85b13..e8ccce8 100644 --- a/src/store/block/BlocksList.ts +++ b/src/store/block/BlocksList.ts @@ -327,7 +327,6 @@ export class BlockListStore { const selectedBlocks = this.$selectedBlocks.value; selectedBlocks.forEach((block) => { this.deleteAllBlockConnections(block.id); - block.dispose(); }); const newBlocks = this.$blocks.value.filter((block) => !selectedBlocks.includes(block)); this.applyBlocksState(newBlocks); diff --git a/src/store/settings.ts b/src/store/settings.ts index 4ecae55..d3005e7 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -8,7 +8,7 @@ export enum ECanChangeBlockGeometry { NONE = "none", } -export type TGraphSettingsConfig = {}> = { +export type TGraphSettingsConfig = { canDragCamera: boolean; canZoomCamera: boolean; canDuplicateBlocks: boolean; @@ -21,7 +21,7 @@ export type TGraphSettingsConfig = {} useBlocksAnchors: boolean; connectivityComponentOnClickRaise: boolean; showConnectionLabels: boolean; - blockComponents: Record>>; + blockComponents: Record>; }; const getInitState: TGraphSettingsConfig = { diff --git a/src/stories/Playground/ActionBlock/ActionBlock.css b/src/stories/Playground/ActionBlock/ActionBlock.css new file mode 100644 index 0000000..c54af27 --- /dev/null +++ b/src/stories/Playground/ActionBlock/ActionBlock.css @@ -0,0 +1,21 @@ +.action-block-wrapper { + border-radius: 8px; + border-width: 4px; + padding: var(--g-spacing-3); + + display: flex; + flex-direction: column; + gap: var(--g-spacing-1); +} + +.action-block-name { + font-weight: 500; +} + +.action-block-wrapper:hover { + cursor: pointer; + border-color: rgba(229, 229, 229, 0.4); + background-color: rgba(57, 47, 57, 1); +} + + diff --git a/src/stories/Playground/ActionBlock/ActionBlockHtml.tsx b/src/stories/Playground/ActionBlock/ActionBlockHtml.tsx new file mode 100644 index 0000000..aafc529 --- /dev/null +++ b/src/stories/Playground/ActionBlock/ActionBlockHtml.tsx @@ -0,0 +1,32 @@ +import { Database } from '@gravity-ui/icons'; +import { Button, Flex, Icon, Text } from '@gravity-ui/uikit'; +import React from "react"; +import { Graph } from "../../../graph"; +import { GraphBlock, GraphBlockAnchor } from "../../../react-component"; +import { TGravityActionBlock } from "../generateLayout"; +import "./ActionBlock.css"; + +export function ActionBlockHtml({ graph, block }: { graph: Graph, block: TGravityActionBlock }) { + return ( + + {block.anchors.map((anchor) => { + return ( + + ); + })} + + {block.name} + {block.meta.description} + + + + + + ) +} \ No newline at end of file diff --git a/src/stories/Playground/ActionBlock/index.tsx b/src/stories/Playground/ActionBlock/index.tsx new file mode 100644 index 0000000..1f32126 --- /dev/null +++ b/src/stories/Playground/ActionBlock/index.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { Anchor, CanvasBlock, EAnchorType, layoutText, TAnchor, TBlockId, TPoint } from "../../.."; +import { ActionBlockHtml } from "./ActionBlockHtml"; + +import { renderSVG } from "../../../utils/renderers/svgPath"; +import { TGravityActionBlock } from "../generateLayout"; +import './ActionBlock.css'; + +function getAnchorY(index) { + let y = 18 * index; + if (index >= 1) { + y += 8 * index; + } + return y + 18; +} + +export class ActionBlock extends CanvasBlock { + + public cursor = 'pointer'; + + protected hovered: boolean = false; + + protected subscribe(id: TBlockId) { + const subs = super.subscribe(id); + subs.push( + this.addEventListener('mouseenter', (e) => { + this.hovered = true; + this.performRender(); + } + ), + this.addEventListener('mouseleave', (e) => { + this.hovered = false; + this.performRender(); + }) + ) + return subs; + } + + protected renderName(ctx: CanvasRenderingContext2D) { + const scale = this.context.camera.getCameraScale(); + + if (scale > this.context.constants.block.SCALES[0]) { + ctx.fillStyle = this.context.colors.block.text; + ctx.textAlign = "center"; + this.renderText(this.state.name, ctx); + } + } + + public renderHTML() { + return + } + + protected renderAnchor(anchor: TAnchor, getPosition: (anchor: TAnchor) => TPoint) { + return Anchor.create({ + ...anchor, + zIndex: this.zIndex, + size: 18, + lineWidth: 2, + getPosition, + }, { + key: anchor.id + }); + } + + protected getAnchorsYOffter(type: EAnchorType) { + const anchors = this.connectedState.$state.value.anchors.filter((a) => a.type === type); + const { height } = this.getContentRect(); + return (height - getAnchorY(anchors.length - 1)) / 2; + } + + public getAnchorPosition(anchor: TAnchor): TPoint { + const a = this.getAnchorsYOffter(anchor.type as EAnchorType); + const index = this.connectedState.$anchorIndexs.value?.get(anchor.id) || 0; + const y = getAnchorY(index); + return { + x: anchor.type === EAnchorType.OUT ? this.state.width : 0, + y: a + y, + }; + } + + protected renderTextAtCenter(name: string, ctx: CanvasRenderingContext2D) { + const rect = this.getContentRect(); + const scale = this.context.camera.getCameraScale(); + ctx.fillStyle = this.context.colors.block.text; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + const { lines, measures } = layoutText(name, ctx, rect, { font: `500 ${9 / scale}px YS Text`, lineHeight: 9 / scale }) + const shiftY = rect.height / 2 - measures.height / 2; + for (let index = 0; index < lines.length; index++) { + const [line, x, y] = lines[index]; + const rY = (y + shiftY) | 0; + ctx.fillText(line, x, rY); + } + } + + public renderBody(ctx: CanvasRenderingContext2D) { + const scale = this.context.camera.getCameraScale(); + + ctx.lineWidth = Math.min(Math.round(2 / scale), 12); + ctx.fillStyle = this.hovered ? "rgba(57, 47, 57, 1)" : this.context.colors.block.background; + + ctx.beginPath(); + ctx.roundRect(this.state.x, this.state.y, this.state.width, this.state.height, 8); + ctx.fill(); + if (this.state.selected) { + ctx.strokeStyle = this.context.colors.block.selectedBorder; + } else { + ctx.strokeStyle = this.hovered ? 'rgba(229, 229, 229, 0.4)' : this.context.colors.block.border; + } + ctx.stroke(); + ctx.closePath(); + } + + public renderMinimalisticBlock(ctx: CanvasRenderingContext2D): void { + this.renderBody(ctx); + // do not show icon for large scale + if(this.context.camera.getCameraScale() < 0.1) { + return; + } + + ctx.fillStyle = "rgba(255, 190, 92, 1)"; + renderSVG({ + path: 'M5.75 2.5H10.25C10.7842 2.5 11.2532 2.77929 11.519 3.19983C10.6259 3.58121 10 4.46751 10 5.5C10 6.61941 10.7357 7.56698 11.75 7.88555V12C11.75 12.8284 11.0784 13.5 10.25 13.5H5.75C5.21576 13.5 4.74676 13.2207 4.48102 12.8002C5.3741 12.4188 6 11.5325 6 10.5C6 9.38059 5.26428 8.43302 4.25 8.11445V7.88555C5.26428 7.56698 6 6.61941 6 5.5C6 4.46751 5.3741 3.58121 4.48102 3.19982C4.74676 2.77929 5.21576 2.5 5.75 2.5ZM2.75 8.11445V7.88555C1.73572 7.56698 1 6.61941 1 5.5C1 4.32762 1.80699 3.34373 2.8958 3.0735C3.28617 1.87008 4.41648 1 5.75 1H10.25C11.5835 1 12.7138 1.87008 13.1042 3.07351C14.193 3.34373 15 4.32762 15 5.5C15 6.61941 14.2643 7.56698 13.25 7.88555V12C13.25 13.6569 11.9069 15 10.25 15H5.75C4.41647 15 3.28616 14.1299 2.8958 12.9265C1.80699 12.6563 1 11.6724 1 10.5C1 9.38059 1.73572 8.43302 2.75 8.11445ZM3.5 11.5C4.05228 11.5 4.5 11.0523 4.5 10.5C4.5 9.94771 4.05228 9.5 3.5 9.5C2.94772 9.5 2.5 9.94772 2.5 10.5C2.5 11.0523 2.94772 11.5 3.5 11.5ZM2.5 5.5C2.5 4.94772 2.94772 4.5 3.5 4.5C4.05228 4.5 4.5 4.94772 4.5 5.5C4.5 6.05228 4.05228 6.5 3.5 6.5C2.94772 6.5 2.5 6.05229 2.5 5.5ZM12.5 4.5C11.9477 4.5 11.5 4.94772 11.5 5.5C11.5 6.05229 11.9477 6.5 12.5 6.5C13.0523 6.5 13.5 6.05229 13.5 5.5C13.5 4.94772 13.0523 4.5 12.5 4.5Z', + width: 14 * 4, + height: 14 * 4, + iniatialWidth: 14, + initialHeight: 14, + }, ctx, this.getContentRect()); + } + + public renderSchematicView(ctx: CanvasRenderingContext2D) { + this.renderBody(ctx); + + const scale = this.context.camera.getCameraScale(); + const shouldRenderText = scale > this.context.constants.block.SCALES[0]; + + if (shouldRenderText) { + ctx.fillStyle = this.context.colors.block.text; + ctx.textAlign = "center"; + this.renderTextAtCenter(this.state.name, ctx); + } + } +} \ No newline at end of file diff --git a/src/stories/Playground/Editor/Editor.css b/src/stories/Playground/Editor/Editor.css new file mode 100644 index 0000000..6a6a8d2 --- /dev/null +++ b/src/stories/Playground/Editor/Editor.css @@ -0,0 +1,12 @@ +.editor-wrap { + width: 100%; +} + +.actions { + padding: var(--g-spacing-3); + border-top: 1px solid var(--g-color-base-float-accent-hover) +} + +.actions .hotkey { + --g-color-text-hint: var(--g-color-text-dark-hint); +} \ No newline at end of file diff --git a/src/stories/Playground/Editor/index.tsx b/src/stories/Playground/Editor/index.tsx new file mode 100644 index 0000000..6e4d867 --- /dev/null +++ b/src/stories/Playground/Editor/index.tsx @@ -0,0 +1,162 @@ + + +import { Button, Flex, Hotkey, Text } from "@gravity-ui/uikit"; +import { Editor, OnMount, OnValidate, loader } from "@monaco-editor/react"; +import { KeyCode, KeyMod } from 'monaco-editor/esm/vs/editor/editor.api'; +import React, { Ref, useImperativeHandle, useRef, useState } from "react"; +import type { TBlock } from "../../../components/canvas/blocks/Block"; +import { TBlockId } from "../../../store/block/Block"; +import type { TConnection } from "../../../store/connection/ConnectionState"; +import { GravityTheme, defineTheme } from "./theme"; + +import { useFn } from "../../../utils/hooks/useFn"; +import "./Editor.css"; +import { defineConigSchema } from "./schema"; +import { findBlockPositionsMonaco } from "./utils"; + + +loader.init().then((monaco) => { + defineTheme(monaco); + defineConigSchema(monaco); +}); + +export interface ConfigEditorController { + scrollTo: (blockId: TBlockId) => void; + updateBlocks: (block: TBlock[]) => void; + setContent: (p: {blocks: TBlock[], connections: TConnection[]}) => void; +} + +type ConfigEditorProps = { + onChange?: (config: { blocks: TBlock[], connections: TConnection[] }) => void + addBlock?: () => void +}; + +type ExtractTypeFromArray = T extends Array ? E : never; + +export const ConfigEditor = React.forwardRef(function ConfigEditor(props: ConfigEditorProps, ref: Ref) { + + const [errorMarker, setErrorMarker] = useState[0]>>(null); + + const monacoRef = useRef[0]>(null); + + const valueRef = useRef<{ blocks: TBlock[], connections: TConnection[] }>({ blocks: [], connections: []}) + + useImperativeHandle(ref, () => ({ + scrollTo: (blockId: string) => { + if (!monacoRef.current) { + return; + } + const model = monacoRef.current.getModel(); + const range = findBlockPositionsMonaco(model, blockId); + + if (range?.start.column) { + monacoRef.current?.revealLinesInCenter(range.start.lineNumber, range.end.lineNumber, 0); + } + + monacoRef.current.setSelection({ + startColumn: range.start.column, + startLineNumber: range.start.lineNumber, + endColumn: range.end.column, + endLineNumber: range.end.lineNumber + }); + }, + updateBlocks: (blocks: TBlock[]) => { + if (!monacoRef.current) { + return; + } + const model = monacoRef.current.getModel(); + const edits = blocks.map((block)=> { + const range = findBlockPositionsMonaco(model, block.id); + const text = JSON.stringify({ block: [block] }, null, 2); + return { + range: { + startColumn: range.start.column, + startLineNumber: range.start.lineNumber, + endColumn: range.end.column, + endLineNumber: range.end.lineNumber, + }, + text: text.slice(19, text.length - 6), + } + }) + + model.applyEdits(edits); + }, + setContent: ({ blocks, connections}) => { + valueRef.current = { + blocks, + connections + } + if (!monacoRef.current) { + return; + } + monacoRef.current?.setValue(JSON.stringify(valueRef.current, null, 2)); + }, + })); + + const applyChanges = useFn(() => { + try { + const data = JSON.parse(monacoRef.current.getModel().getValue()); + props?.onChange?.({ blocks: data.blocks, connections: data.conections }) + } catch(e) { + console.error(e); + } + }); + + return + + { + monacoRef.current = editor; + monacoRef.current?.setValue(JSON.stringify(valueRef.current, null, 2)); + editor.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, applyChanges); + }} + onValidate={(markers) => { + setErrorMarker(markers.filter((m) => m.severity === 8)[0] || null) + }} + language={'json'} + theme={GravityTheme} + options={{ + contextmenu: false, + lineNumbersMinChars: 4, + glyphMargin: false, + fontSize: 18, + lineHeight: 20, + colorDecorators: true, + minimap: {enabled: false}, + smoothScrolling: true, + // @ts-ignore + 'bracketPairColorization.editor': true, + }} + /> + + + + + {errorMarker && ( + + + { + monacoRef.current?.revealLinesInCenter(errorMarker.startLineNumber, errorMarker.endLineNumber, 0); + + + monacoRef.current.setSelection({ + startColumn: errorMarker.startColumn, + startLineNumber: errorMarker.startLineNumber, + endColumn: errorMarker.endColumn, + endLineNumber: errorMarker.endLineNumber + }); + }} + >{errorMarker.message} + + + )} + + +}) \ No newline at end of file diff --git a/src/stories/Playground/Editor/schema.ts b/src/stories/Playground/Editor/schema.ts new file mode 100644 index 0000000..2df7b84 --- /dev/null +++ b/src/stories/Playground/Editor/schema.ts @@ -0,0 +1,156 @@ +import { Monaco } from "@monaco-editor/react"; + +export function defineConigSchema(monaco: Monaco) { + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + schemaValidation: "error", + schemas: [ + { + uri: "http://gravity/graph-playground/schema.json", // id of the first schema + fileMatch: ["*"], // associate with our model + schema: { + "type": "object", + "properties": { + "blocks": { + "type": "array", + "items": { + "$ref": "#/definitions/TBlock" + }, + "description": "List of blocks (TBlock[])" + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/definitions/TConnection" + }, + "description": "List of connections (TConnection[])" + } + }, + "required": ["blocks", "connections"], + "definitions": { + "TBlock": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Block identifier (TBlockId)" + }, + "is": { + "type": "string", + "description": "String representation of the block type" + }, + "x": { + "type": "number", + "description": "X coordinate" + }, + "y": { + "type": "number", + "description": "Y coordinate" + }, + "width": { + "type": "number", + "description": "Block width" + }, + "height": { + "type": "number", + "description": "Block height" + }, + "selected": { + "type": "boolean", + "description": "Flag indicating if the block is selected" + }, + "name": { + "type": "string", + "description": "Block name" + }, + "anchors": { + "type": "array", + "items": { + "$ref": "#/definitions/TAnchor" + }, + "description": "List of anchors (TAnchor[])" + }, + "meta": { + "type": "object", + "description": "Meta information (optional)", + } + }, + "required": ["id", "is", "x", "y", "width", "height", "selected", "name", "anchors"] + }, + "TConnection": { + "type": "object", + "properties": { + "sourceBlockId": { + "type": "string", + "description": "Identifier of the source block" + }, + "targetBlockId": { + "type": "string", + "description": "Identifier of the target block" + }, + "sourceAnchorId": { + "type": "string", + "description": "Identifier of the source anchor (optional)" + }, + "targetAnchorId": { + "type": "string", + "description": "Identifier of the target anchor (optional)" + }, + "label": { + "type": "string", + "description": "Connection label (optional)" + }, + "styles": { + "type": "object", + "properties": { + "dashes": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Array of dash lengths for dashed lines (optional)" + } + }, + "additionalProperties": true, + "description": "Connection styles (optional)" + }, + "dashed": { + "type": "boolean", + "description": "Flag indicating if the line is dashed (optional)" + }, + "selected": { + "type": "boolean", + "description": "Flag indicating if the connection is selected (optional)" + } + }, + "required": ["sourceBlockId", "targetBlockId"] + }, + "TAnchor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Anchor identifier" + }, + "blockId": { + "type": "string", + "description": "Identifier of the block this anchor belongs to" + }, + "type": { + "type": "string", + "enum": ["IN", "OUT"], + "description": "Anchor type, either IN or OUT" + }, + "index": { + "type": "number", + "description": "Anchor index" + } + }, + "required": ["id", "blockId", "type"] + } + } + }, + }, + ], + }); +} \ No newline at end of file diff --git a/src/stories/Playground/Editor/theme.ts b/src/stories/Playground/Editor/theme.ts new file mode 100644 index 0000000..aff5081 --- /dev/null +++ b/src/stories/Playground/Editor/theme.ts @@ -0,0 +1,24 @@ +import type { Monaco } from "@monaco-editor/react"; + +export const GravityTheme = 'gravity'; + +export function defineTheme(monaco: Monaco) { + monaco.editor.defineTheme(GravityTheme, { + base: "vs-dark", + inherit: true, + rules: [ + { + token: "string.key.json", + foreground: "#febe5c", + }, + ], + colors: { + "editor.foreground": "#ffdb4d4d", + "editor.background": "#251b25", + "editor.lineHighlightBackground": "#ffdb4d4d", + "editorLineNumber.foreground": "#bd5c0a", + "editor.selectionBackground": "#ffdb4d4d", + "editor.inactiveSelectionBackground": "#ffdb4d4d", + }, + }); +} \ No newline at end of file diff --git a/src/stories/Playground/Editor/utils.ts b/src/stories/Playground/Editor/utils.ts new file mode 100644 index 0000000..30fd663 --- /dev/null +++ b/src/stories/Playground/Editor/utils.ts @@ -0,0 +1,29 @@ +export function findBlockPositionsMonaco(model, blockId) { + const configString = model.getValue(); + const blockSearchStr = `"id": "${blockId}"`; + const startIndex = configString.indexOf(blockSearchStr); + + const blockStart = configString.lastIndexOf('{', startIndex); + let blockEnd = configString.indexOf('}', startIndex); + + let braceCount = 1; + let currentPos = blockEnd + 1; + while (braceCount > 0 && currentPos < configString.length) { + if (configString[currentPos] === '{') { + braceCount++; + } else if (configString[currentPos] === '}') { + braceCount--; + } + currentPos++; + } + blockEnd = currentPos; + + // Получаем позиции начала и конца через Monaco Editor API + const startPosition = model.getPositionAt(blockStart); + const endPosition = model.getPositionAt(blockEnd); + + return { + start: startPosition, + end: endPosition, + }; +} diff --git a/src/stories/Playground/GraphPlayground.tsx b/src/stories/Playground/GraphPlayground.tsx new file mode 100644 index 0000000..767c932 --- /dev/null +++ b/src/stories/Playground/GraphPlayground.tsx @@ -0,0 +1,286 @@ +import { Flex, RadioButton, RadioButtonOption, RadioButtonProps, Text, ThemeProvider } from "@gravity-ui/uikit"; +import "@gravity-ui/uikit/styles/styles.css"; +import React, { useEffect, useLayoutEffect, useRef } from "react"; +import { StoryFn } from "storybook/internal/types"; +import { Graph, GraphState, TGraphConfig } from "../../graph"; +import { GraphBlock, GraphCanvas, HookGraphParams, useGraph, useGraphEvent } from "../../react-component"; +import { ECanChangeBlockGeometry } from "../../store/settings"; +import { useFn } from "../../utils/hooks/useFn"; + +import { TBlock } from "../../components/canvas/blocks/Block"; +import { random } from "../../components/canvas/blocks/generate"; +import { EAnchorType } from "../configurations/definitions"; +import { ActionBlock } from "./ActionBlock"; +import { ConfigEditor, ConfigEditorController } from "./Editor"; +import './Playground.css'; +import { GraphSettings } from "./Settings"; +import { TextBlock } from "./TextBlock"; +import { Toolbox } from "./Toolbox"; +import { GravityActionBlockIS, GravityTextBlockIS, TGravityActionBlock, TGravityTextBlock, createActionBlock, createTextBlock, generatePlaygroundActionBlocks } from "./generateLayout"; + +const generated = generatePlaygroundActionBlocks(0, 5); +const textBlocks = [ + createTextBlock(-144, 80, 448, 0, 'To create new block, drag and drop new connection from edge'), + createTextBlock(-64, 160, 240, 1, 'Use scroll to zoom in or out') +]; + +const config: HookGraphParams = { + viewConfiguration: { + colors: { + selection: { + background: "rgba(255, 190, 92, 0.1)", + border: "rgba(255, 190, 92, 1)", + }, + connection: { + background: "rgba(255, 255, 255, 0.5)", + selectedBackground: "rgba(234, 201, 74, 1)", + }, + block: { + background: "rgba(37, 27, 37, 1)", + border: "rgba(229, 229, 229, 0.2)", + selectedBorder: "rgba(255, 190, 92, 1)", + text: "rgba(255, 255, 255, 1)", + }, + anchor: { + background: "rgba(255, 190, 92, 1)" + }, + canvas: { + layerBackground: "rgba(22, 13, 27, 1)", + belowLayerBackground: "rgba(22, 13, 27, 1)", + dots: "rgba(255, 255, 255, 0.2)", + border: "rgba(255, 255, 255, 0.3)", + } + }, + constants: { + block: { + SCALES: [0.1, 0.2, 0.5] + } + } + }, + settings: { + canDragCamera: true, + canZoomCamera: true, + canDuplicateBlocks: false, + canChangeBlockGeometry: ECanChangeBlockGeometry.ALL, + canCreateNewConnections: false, + showConnectionArrows: false, + scaleFontSize: 1, + useBezierConnections: true, + useBlocksAnchors: true, + showConnectionLabels: false, + blockComponents: { + [GravityActionBlockIS]: ActionBlock, + [GravityTextBlockIS]: TextBlock, + } + } +}; + +const graphSizeOptions: RadioButtonOption[] = [ + {value: '1', content: '1'}, + {value: '100', content: '100'}, + {value: '1000', content: '1 000'}, + {value: '10000', content: '10 000'}, +]; + +export function GraphPLayground() { + const { graph, setEntities, updateEntities, start } = useGraph(config); + const editorRef = useRef(null); + + const updateVisibleConfig = useFn(() => { + const config = graph.rootStore.getAsConfig(); + editorRef?.current.setContent({ + blocks: config.blocks || [], + connections: config.connections || [] + }); + }); + + useGraphEvent(graph, 'block-change', ({block}) => { + editorRef?.current.updateBlocks([block]); + editorRef?.current.scrollTo(block.id); + }); + + useGraphEvent(graph, "blocks-selection-change", ({ changes }) => { + editorRef?.current.updateBlocks([ + ...changes.add.map((id) => ({ + ...graph.rootStore.blocksList.getBlock(id), + selected: true, + })), + ...changes.removed.map((id) => ({ + ...graph.rootStore.blocksList.getBlock(id), + selected: false, + })) + ]); + }); + + useGraphEvent(graph, 'connection-created', ({sourceBlockId, sourceAnchorId, targetBlockId, targetAnchorId}, event) => { + event.preventDefault(); + const pullSourceAnchor = graph.rootStore.blocksList.getBlockState(sourceBlockId).getAnchorById(sourceAnchorId); + if (pullSourceAnchor.state.type === EAnchorType.IN) { + graph.api.addConnection({ + sourceBlockId: targetBlockId, + sourceAnchorId: targetAnchorId, + targetBlockId: sourceBlockId, + targetAnchorId: sourceAnchorId, + }) + } else { + graph.api.addConnection({ + sourceBlockId: sourceBlockId, + sourceAnchorId: sourceAnchorId, + targetBlockId: targetBlockId, + targetAnchorId: targetAnchorId, + }) + } + updateVisibleConfig(); + }); + + useGraphEvent(graph, 'connection-create-drop', ({sourceBlockId, sourceAnchorId, targetBlockId, point}) => { + if (targetBlockId) { + return; + } + let block: TBlock; + const pullSourceAnchor = graph.rootStore.blocksList.getBlockState(sourceBlockId).getAnchorById(sourceAnchorId); + if (pullSourceAnchor.state.type === EAnchorType.IN) { + block = createActionBlock(point.x - 126, point.y - 63, graph.rootStore.blocksList.$blocksMap.value.size + 1); + graph.api.addBlock(block); + graph.api.addConnection({ + sourceBlockId: block.id, + sourceAnchorId: block.anchors[1].id, + targetBlockId: sourceBlockId, + targetAnchorId: sourceAnchorId, + }) + } else { + block = createActionBlock(point.x, point.y - 63, graph.rootStore.blocksList.$blocksMap.value.size + 1); + graph.api.addBlock(block); + graph.api.addConnection({ + sourceBlockId: sourceBlockId, + sourceAnchorId: sourceAnchorId, + targetBlockId: block.id, + targetAnchorId: block.anchors[0].id, + }) + } + graph.zoomTo([block.id], {transition: 250 }); + updateVisibleConfig(); + editorRef?.current.scrollTo(block.id); + }); + + useLayoutEffect(() => { + setEntities({ blocks: [ + ...textBlocks, + ...generated.blocks + ], connections: generated.connections }); + updateVisibleConfig(); + }, [setEntities]); + + useGraphEvent(graph, "state-change", ({ state }) => { + if (state === GraphState.ATTACHED) { + start(); + graph.zoomTo("center", { padding: 300 }); + } + }); + + const addNewBlock = useFn(() => { + const rect = graph.rootStore.blocksList.getUsableRect(); + const x = random(rect.x, rect.x + rect.width + 100); + const y = random(rect.y, rect.y + rect.height + 100); + const block = createActionBlock(x, y, graph.rootStore.blocksList.$blocksMap.value.size + 1); + graph.api.addBlock(block); + graph.zoomTo([block.id], { transition: 250 }); + updateVisibleConfig(); + editorRef?.current.scrollTo(block.id); + }); + + const renderBlockFn = useFn((graph: Graph, block: TBlock) => { + const view = graph.rootStore.blocksList.getBlockState(block.id)?.getViewComponent() + if (view instanceof ActionBlock) { + return view.renderHTML(); + } + if (view instanceof TextBlock) { + return view.renderHTML(); + } + return Unknown block <>{block.id} + }); + + useGraphEvent(graph, 'blocks-selection-change', ({list}) => { + if (list.length === 1) { + editorRef?.current.scrollTo(list[0]); + } + }); + + useEffect(() => { + const fn = (e: KeyboardEvent) => { + if (e.code === 'Backspace') { + graph.api.deleteSelected(); + updateVisibleConfig(); + } + }; + document.body.addEventListener('keydown', fn); + return () => document.body.removeEventListener('keydown', fn); + }); + + const updateGraphSize = useFn, void>((value) => { + let config: TGraphConfig; + switch(value) { + case graphSizeOptions[0].value: { + config = generatePlaygroundActionBlocks(0, 5); + break; + } + case graphSizeOptions[1].value: { + config = generatePlaygroundActionBlocks(10, 100); + break; + } + case graphSizeOptions[2].value: { + config = generatePlaygroundActionBlocks(23, 150); + break; + } + case graphSizeOptions[3].value: { + graph.updateSettings({ + useBezierConnections: false + }); + config = generatePlaygroundActionBlocks(50, 150); + break; + } + } + setEntities(config); + graph.zoomTo('center', {transition: 500}); + updateVisibleConfig(); + + }); + + return ( + + + + + Blocks + + + + + + + + + + + + JSON Editor + + { + updateEntities({blocks, connections}) + }} + addBlock={addNewBlock} /> + + + + + ); +} + +export const Default: StoryFn = () => ; \ No newline at end of file diff --git a/src/stories/Playground/Playground.css b/src/stories/Playground/Playground.css new file mode 100644 index 0000000..1ea505d --- /dev/null +++ b/src/stories/Playground/Playground.css @@ -0,0 +1,108 @@ +.wrapper { + height: 100%; + background-color: rgba(37, 27, 37, 1); + padding: 16px; +} + +.graph { + min-width: 50% +} + +.graph-editor { + background-color: rgba(22, 13, 27, 1); +} + +.view { + border: 1px solid transparent; + border-radius: 24px; + overflow: hidden; + position: relative; +} + +.view.config-editor { + border-radius: 10px; + border-color: var(--yc-color-base-float-accent-hover); +} + +.settings-popup { + padding: 16px; +} + +.graph-tools { + height: 100%; + pointer-events: none; + position: absolute; + left: 20px; + z-index: 10; +} + +.graph-tools-zoom { + pointer-events: all; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 0; + z-index: 10; + transform: translate(0, -50%); +} + +.graph-size-settings { + --g-color-base-brand: rgba(255, 190, 92, 1); + --g-color-base-background: var(--g-color-base-brand); + --g-color-text-primary: var(--g-color-text-dark-primary); +} + +.graph-size-settings .yc-radio-button__option:hover .yc-radio-button__option-text { + --g-color-text-primary: var(--g-color-text-light-primary); +} + +.graph-size-settings .yc-radio-button__option_checked.yc-radio-button__option:hover .yc-radio-button__option-text { + --g-color-text-primary: var(--g-color-text-dark-primary); +} + +.graph-tools-settings { + pointer-events: all; + box-sizing: border-box; + position: absolute; + bottom: 20px; + left: 0; + z-index: 10; +} + +.button-group .yc-button { + border-radius: 0 !important; +} +.button-group .yc-button::before { + border-radius: 0 !important; +} +.button-group .yc-button::after { + border-radius: 0 !important; +} +.button-group .yc-button:not(:last-child) { + border-bottom: 1px solid var(--yc-color-base-misc-light); +} +.button-group .yc-button:first-child { + border-top-right-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; + border-top-left-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; +} +.button-group .yc-button:first-child::before { + border-top-right-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; + border-top-left-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; +} +.button-group .yc-button:first-child::after { + border-top-right-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; + border-top-left-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; +} +.button-group .yc-button:last-child { + border-bottom-right-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; + border-bottom-left-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; +} +.button-group .yc-button:last-child::before { + border-bottom-right-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; + border-bottom-left-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; +} +.button-group .yc-button:last-child::after { + border-bottom-right-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; + border-bottom-left-radius: var(--yc-button-border-radius, var(--_--border-radius)) !important; +} + diff --git a/src/stories/Playground/Playground.stories.tsx b/src/stories/Playground/Playground.stories.tsx new file mode 100644 index 0000000..b548f60 --- /dev/null +++ b/src/stories/Playground/Playground.stories.tsx @@ -0,0 +1,13 @@ +import { Meta, StoryFn } from "@storybook/react/*"; +import { GraphPLayground } from "./GraphPlayground"; +import React from "react"; + +const meta: Meta = { + title: "Playground/playgground", + component: GraphPLayground, +}; + +export default meta; + +export const Default: StoryFn = () => ; + diff --git a/src/stories/Playground/Settings.tsx b/src/stories/Playground/Settings.tsx new file mode 100644 index 0000000..7b1c8b7 --- /dev/null +++ b/src/stories/Playground/Settings.tsx @@ -0,0 +1,66 @@ +import { Gear } from '@gravity-ui/icons'; +import { Button, Flex, Icon, Popup, RadioButton, RadioButtonOption, Text } from "@gravity-ui/uikit"; +import React, { useRef, useState } from "react"; +import { Graph } from '../../graph'; +import { useRerender } from './hooks'; + +const ConnectionVariants: RadioButtonOption[] = [ + { value: 'bezier', content: 'Bezier' }, + { value: 'line', content: 'Line' }, +]; + +const ConnectionArrowsVariants: RadioButtonOption[] = [ + { value: 'bezier', content: 'Show' }, + { value: 'line', content: 'Hide' }, +]; + +export function GraphSettings({ className, graph }: {className: string, graph: Graph}) { + + const rerender = useRerender(); + const settingBtnRef = useRef(); + const [settingsOpened, setSettingsOpened] = useState(false); + return <> + + setSettingsOpened(false)} placement={["right-end"]}> + + Graph settings + + Connection type + { + graph.updateSettings({ + useBezierConnections: value === ConnectionVariants[0].value + }); + rerender(); + }} + value={ConnectionVariants[graph.rootStore.settings.getConfigFlag('useBezierConnections') ? 0 : 1].value} + options={ConnectionVariants} + /> + + + Show arrows + { + graph.updateSettings({ + showConnectionArrows: value === ConnectionArrowsVariants[0].value + }); + rerender(); + }} + value={ConnectionArrowsVariants[graph.rootStore.settings.getConfigFlag('showConnectionArrows') ? 0 : 1].value} + options={ConnectionArrowsVariants} + /> + + + + +} \ No newline at end of file diff --git a/src/stories/Playground/TextBlock/TextBlock.css b/src/stories/Playground/TextBlock/TextBlock.css new file mode 100644 index 0000000..7c1bb18 --- /dev/null +++ b/src/stories/Playground/TextBlock/TextBlock.css @@ -0,0 +1,36 @@ +.text-block-wrapper { + border-radius: 8px; + border-width: 2px; + border-style: dashed; + + padding: 0 var(--g-spacing-3); + + display: flex; + flex-direction: row; + align-items: center; + + color: var(--g-color-line-warning); + border-color: var(--g-color-line-warning); + background-color: rgba(46, 31, 34, 1); +} + +.text-block-wrapper .icon { + flex-shrink: 0; +} + +.text-block-text { + flex-grow: 1; + font-weight: 500; + text-overflow: ellipsis; +} + +.text-block-wrapper:hover { + cursor: pointer; + border-color: rgba(255, 190, 92, 1); +} + +.text-block-wrapper.selected { + color: rgba(255, 190, 92, 1); + border-color: rgba(255, 190, 92, 1); + border-style: solid; +} diff --git a/src/stories/Playground/TextBlock/TextBlockHtml.tsx b/src/stories/Playground/TextBlock/TextBlockHtml.tsx new file mode 100644 index 0000000..1398377 --- /dev/null +++ b/src/stories/Playground/TextBlock/TextBlockHtml.tsx @@ -0,0 +1,19 @@ +import { CircleInfo } from '@gravity-ui/icons'; +import { Flex, Icon, Text } from '@gravity-ui/uikit'; +import React from "react"; +import { Graph } from "../../../graph"; +import { GraphBlock } from "../../../react-component"; +import { TGravityTextBlock } from "../generateLayout"; +import "./TextBlock.css"; + +export function TextBlockHtml({ graph, block }: { graph: Graph, block: TGravityTextBlock }) { + return ( + + + + {block.meta.text} + {/* */} + + + ) +} \ No newline at end of file diff --git a/src/stories/Playground/TextBlock/index.tsx b/src/stories/Playground/TextBlock/index.tsx new file mode 100644 index 0000000..46f3373 --- /dev/null +++ b/src/stories/Playground/TextBlock/index.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { CanvasBlock, layoutText, TBlockId } from "../../.."; + +import { TGravityTextBlock } from "../generateLayout"; +import './TextBlock.css'; +import { TextBlockHtml } from "./TextBlockHtml"; + +function getAnchorY(index) { + let y = 18 * index; + if (index >= 1) { + y += 8 * index; + } + return y + 18; +} + +export class TextBlock extends CanvasBlock { + + public cursor = 'pointer'; + + protected hovered: boolean = false; + + protected subscribe(id: TBlockId) { + const subs = super.subscribe(id); + subs.push( + this.addEventListener('mouseenter', (e) => { + this.hovered = true; + this.performRender(); + } + ), + this.addEventListener('mouseleave', (e) => { + this.hovered = false; + this.performRender(); + }) + ) + return subs; + } + + protected renderName(ctx: CanvasRenderingContext2D) { + ctx.fillStyle = "rgba(189, 142, 75, 1)"; + ctx.textAlign = "center"; + this.renderText(this.state.meta.text, ctx); + } + + public renderHTML() { + return + } + + protected renderTextAtCenter(name: string, ctx: CanvasRenderingContext2D) { + const rect = this.getContentRect(); + const scale = this.context.camera.getCameraScale(); + ctx.fillStyle = "rgba(189, 142, 75, 1)"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + const { lines, measures } = layoutText(name, ctx, rect, { font: `500 ${13 / scale}px YS Text`, lineHeight: 9 / scale }) + const shiftY = rect.height / 2 - measures.height / 2; + for (let index = 0; index < lines.length; index++) { + const [line, x, y] = lines[index]; + const rY = (y + shiftY) | 0; + ctx.fillText(line, x, rY); + } + } + + public renderBody(ctx: CanvasRenderingContext2D) { + const scale = this.context.camera.getCameraScale(); + + ctx.save(); + + ctx.lineWidth = Math.min(Math.round(2 / scale), 12); + ctx.fillStyle = 'rgba(189, 142, 75, 0.1)'; + + ctx.beginPath(); + ctx.roundRect(this.state.x, this.state.y, this.state.width, this.state.height, 8); + ctx.fill(); + + if (this.state.selected) { + ctx.lineWidth = Math.min(Math.round(2 / scale), 12); + } else { + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + } + + ctx.strokeStyle = 'rgba(189, 142, 75, 1)' + ctx.stroke(); + ctx.closePath(); + + ctx.restore(); + } + + public renderMinimalisticBlock(ctx: CanvasRenderingContext2D): void { + this.renderBody(ctx); + } + + public renderSchematicView(ctx: CanvasRenderingContext2D) { + this.renderBody(ctx); + + const scale = this.context.camera.getCameraScale(); + const shouldRenderText = scale > this.context.constants.block.SCALES[0]; + + if (shouldRenderText) { + this.renderName(ctx); + } + } +} \ No newline at end of file diff --git a/src/stories/Playground/Toolbox.tsx b/src/stories/Playground/Toolbox.tsx new file mode 100644 index 0000000..4ac35c0 --- /dev/null +++ b/src/stories/Playground/Toolbox.tsx @@ -0,0 +1,42 @@ +import { MagnifierMinus, MagnifierPlus, SquareDashed } from '@gravity-ui/icons'; +import { Button, Flex, Icon, Tooltip } from "@gravity-ui/uikit"; +import React, { useState } from "react"; +import { Graph } from "../../graph"; +import { useGraphEvent } from "../../react-component"; + +export function Toolbox({className, graph}: {className: string, graph: Graph}) { + const [scale, setScale] = useState(1); + + useGraphEvent(graph, 'camera-change', ({scale}) => { + setScale(scale) + }) + return + + + + + + + + + + +} \ No newline at end of file diff --git a/src/stories/Playground/generateLayout.tsx b/src/stories/Playground/generateLayout.tsx new file mode 100644 index 0000000..53050b2 --- /dev/null +++ b/src/stories/Playground/generateLayout.tsx @@ -0,0 +1,125 @@ +import { TBlock } from "../../components/canvas/blocks/Block"; +import { TGraphConfig } from "../../graph"; +import { EAnchorType } from "../../store/anchor/Anchor"; + +export const GravityActionBlockIS = 'block-action' +export type TGravityActionBlock = TBlock<{ description: string }> & { is: typeof GravityActionBlockIS }; + +export const GravityTextBlockIS = 'block-text' +export type TGravityTextBlock = TBlock<{ text: string }> & { is: typeof GravityTextBlockIS }; + +function getActionBlockId(num: number): string { + return `action_${num}`; +} + +export function createActionBlock(x: number, y: number, index: number): TGravityActionBlock { + const blockId = getActionBlockId(index); + + return { + is: GravityActionBlockIS, + id: blockId, + x, + y, + width: 63 * 2, + height: 63 * 2, + selected: false, + name: `Block #${index}`, + anchors: [ + { + id: `${blockId}_in`, + blockId, + type: EAnchorType.IN, + }, + { + id: `${blockId}_out`, + blockId, + type: EAnchorType.OUT, + } + ], + meta: { + description: "Description", + }, + }; +} + +export function createTextBlock(x: number, y: number, width: number, index: number, text): TGravityTextBlock { + const blockId = `text_${index}`; + + return { + is: GravityTextBlockIS, + id: blockId, + x, + y, + width, + height: 48, + selected: false, + name: `Text Block`, + anchors: [], + meta: { + text, + }, + }; +} + +function getRandomArbitrary(min, max) { + return (Math.random() * (max - min) + min) | 0; +} + +export function generatePlaygroundActionBlocks( + layersCount: number, + connectionsPerLayer: number, +) { + const config: TGraphConfig = { + blocks: [], + connections: [], + }; + + const gapX = 500; + const gapY = 200; + + let prevLayerBlocks: TBlock[] = []; + let index = 0; + for (let i = 0; i <= layersCount; i++) { + let count = i ** 2; + if (i >= layersCount / 2) { + count = (layersCount - i) ** 2; + } + const startY = (500 - gapY * count) / 2; + const layerX = gapX * i * 2.5; + const currentLayerBlocks: TBlock[] = []; + for (let j = 0; j <= count; j++) { + const y = startY + gapY * j; + + const block = createActionBlock(layerX, y, ++index); + + config.blocks.push(block); + currentLayerBlocks.push(block); + } + if (i > 1) { + for (let c = 0; c <= connectionsPerLayer; c++) { + const indexSource = getRandomArbitrary( + config.blocks.length - currentLayerBlocks.length - prevLayerBlocks.length - 1, + config.blocks.length - currentLayerBlocks.length - 1 + ); + const indexTarget = getRandomArbitrary( + config.blocks.length - currentLayerBlocks.length - 1, + config.blocks.length - 1 + ); + if (indexSource !== indexTarget) { + const sourceBlockId = getActionBlockId(indexSource); + const targetBlockId = getActionBlockId(indexTarget); + config.connections.push({ + sourceBlockId: sourceBlockId, + sourceAnchorId: `${sourceBlockId}_anchor_out`, + targetBlockId: targetBlockId, + targetAnchorId: `${targetBlockId}_anchor_in`, + }); + } + + } + prevLayerBlocks = [...currentLayerBlocks]; + } + } + + return config; +} diff --git a/src/stories/Playground/hooks.ts b/src/stories/Playground/hooks.ts new file mode 100644 index 0000000..14f1d24 --- /dev/null +++ b/src/stories/Playground/hooks.ts @@ -0,0 +1,9 @@ +import { useState } from "react"; +import { useFn } from "../../utils/hooks/useFn"; + +export function useRerender() { + const [tick, setTick] = useState(Date.now()); + return useFn(() => { + setTick(Date.now()); + }) +} \ No newline at end of file diff --git a/src/utils/renderers/svgPath.ts b/src/utils/renderers/svgPath.ts new file mode 100644 index 0000000..70c3eb7 --- /dev/null +++ b/src/utils/renderers/svgPath.ts @@ -0,0 +1,25 @@ +import { render } from "./render"; + +export function renderSVG( + icon: { + path: string, + width: number, + height: number, + iniatialWidth: number, + initialHeight: number + }, + ctx: CanvasRenderingContext2D, + rect: {x: number, y: number, width: number, height: number}, + +) { + render(ctx, (ctx) => { + const iconPath = new Path2D(icon.path); + const coefX = icon.width / icon.iniatialWidth; + const coefY = icon.height / icon.initialHeight; + // MoveTo position + ctx.translate(rect.x + (rect.width / 2) - (icon.width / 2), rect.y + (rect.height / 2) - (icon.height / 2)); + ctx.scale(coefX, coefY); + ctx.fill(iconPath, 'evenodd'); + }) + +} diff --git a/src/utils/renderers/text.ts b/src/utils/renderers/text.ts index a79db9d..2c4a3d1 100644 --- a/src/utils/renderers/text.ts +++ b/src/utils/renderers/text.ts @@ -18,8 +18,8 @@ export function cachedMeasureText(text: string, params: TMeasureTextOptions) { } return cache.get(key); } -export type TTExtRect = Omit & Partial>; -export function renderText(text: string, ctx: CanvasRenderingContext2D, rect: TTExtRect, params: TMeasureTextOptions) { + +export function layoutText(text: string, ctx: CanvasRenderingContext2D, rect: TTExtRect, params: TMeasureTextOptions) { let x = rect.x; switch (ctx.textAlign) { @@ -47,14 +47,28 @@ export function renderText(text: string, ctx: CanvasRenderingContext2D, rect: TT maxHeight: rect.height, ...params, }); - + const lines = []; for (const line of measures.linesWords) { - ctx.fillText(line, x, y); + lines.push([line, x, y]); y += lineHeight; if (rect.height && y > rect.y + rect.height - lineHeight) { break; } } + return { + measures, + lines, + lineHeight, + } +} + +export type TTExtRect = Omit & Partial>; +export function renderText(text: string, ctx: CanvasRenderingContext2D, rect: TTExtRect, params: TMeasureTextOptions) { + const { lines, measures } = layoutText(text, ctx, rect, params); + + for (const [line, x, y] of lines) { + ctx.fillText(line, x, y); + } return measures; }