diff --git a/package-lock.json b/package-lock.json index 488dc32e4b43..3b59d041ff90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@gravity-ui/chartkit": "^5.10.1", "@gravity-ui/components": "^3.10.1", "@gravity-ui/date-components": "^2.10.1", + "@gravity-ui/graph": "^0.0.2", "@gravity-ui/icons": "^2.11.0", "@gravity-ui/markdown-editor": "^13.18.0", "@gravity-ui/navigation": "^2.24.1", @@ -19,6 +20,7 @@ "@gravity-ui/uikit": "^6.30.1", "@mdx-js/mdx": "^2.3.0", "@mdx-js/react": "^2.3.0", + "@monaco-editor/react": "^4.6.0", "@octokit/rest": "^20.1.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", @@ -3460,6 +3462,26 @@ "eslint": "^8.0.0" } }, + "node_modules/@gravity-ui/graph": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@gravity-ui/graph/-/graph-0.0.2.tgz", + "integrity": "sha512-sP0pT2+hjiaMD8fQXQixb9el3Li1UXISwuKEhzz7RheejMOqdQ1gebuvRNr2UjheD4akU5vn+de8YhQxafn/uA==", + "dependencies": { + "@monaco-editor/react": "^4.6.0", + "@preact/signals-core": "^1.5.1", + "intersects": "^2.7.2", + "lodash-es": "^4.17.21", + "rbush": "^3.0.1" + }, + "engines": { + "pnpm": "Please use npm instead of pnpm to install dependencies", + "yarn": "Please use npm instead of yarn to install dependencies" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@gravity-ui/i18n": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@gravity-ui/i18n/-/i18n-1.6.0.tgz", @@ -3965,6 +3987,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/@next/env": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz", @@ -4292,6 +4338,15 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@preact/signals-core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.8.0.tgz", + "integrity": "sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@react-spring/animated": { "version": "9.7.3", "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.3.tgz", @@ -10361,6 +10416,11 @@ "node": ">=12" } }, + "node_modules/intersects": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/intersects/-/intersects-2.7.2.tgz", + "integrity": "sha512-/LtLDq40iFtvnjhouev9p2R+jP+raVONPiD1t8Mcj879pkrLiav99BTRPBkfMPwSYr5vTNws3USGoW+8usS45A==" + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -11434,6 +11494,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -14102,6 +14167,11 @@ "node": ">=8" } }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==" + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -14145,6 +14215,14 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/rbush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", + "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", + "dependencies": { + "quickselect": "^2.0.0" + } + }, "node_modules/rc-slider": { "version": "10.6.2", "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-10.6.2.tgz", @@ -15541,6 +15619,11 @@ "stacktrace-gps": "^3.0.4" } }, + "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/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", diff --git a/package.json b/package.json index 48c90c2539c9..ba3ac2ec1814 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@gravity-ui/chartkit": "^5.10.1", "@gravity-ui/components": "^3.10.1", "@gravity-ui/date-components": "^2.10.1", + "@gravity-ui/graph": "^0.0.2", "@gravity-ui/icons": "^2.11.0", "@gravity-ui/markdown-editor": "^13.18.0", "@gravity-ui/navigation": "^2.24.1", @@ -15,6 +16,7 @@ "@gravity-ui/uikit": "^6.30.1", "@mdx-js/mdx": "^2.3.0", "@mdx-js/react": "^2.3.0", + "@monaco-editor/react": "^4.6.0", "@octokit/rest": "^20.1.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", diff --git a/public/locales/en/libraries-info.json b/public/locales/en/libraries-info.json index d83a17d9c8ee..4b335022deee 100644 --- a/public/locales/en/libraries-info.json +++ b/public/locales/en/libraries-info.json @@ -30,7 +30,7 @@ "description_babel-preset": "Babel configuration preset for Gravity UI projects.", "description_browserslist-config": "Browserslist confugiration preset used in our services.", "description_markdown-editor": "A powerful tool for working with Markdown, which combines WYSIWYG and Markup modes.", - "description_graph": "Graph description", + "description_graph": "High-performance graph renderer with dynamic scale-aware detailization", "description_data-source": "A wrapper around data fetching.", "description_webpack-i18n-assets-plugin": "A plugin for Webpack that replaces calls to localization functions (i18n) with target texts." } diff --git a/src/components/GraphPlayground/GraphPlayground.scss b/src/components/GraphPlayground/GraphPlayground.scss index 33a708bb0bf4..6c8df134f6fa 100644 --- a/src/components/GraphPlayground/GraphPlayground.scss +++ b/src/components/GraphPlayground/GraphPlayground.scss @@ -50,11 +50,18 @@ $block: '.#{variables.$ns}graph'; background: rgba(37, 27, 37, 1); border-radius: 24px; flex: 1; - min-height: 648px; + flex-direction: column; + min-height: 80vh; + max-height: 80vh; gap: 24px; position: relative; } + &__graph-viewer { + flex: 1; + min-height: 100%; + } + &__json-switcher { position: absolute; top: 24px; diff --git a/src/components/GraphPlayground/GraphPlayground.tsx b/src/components/GraphPlayground/GraphPlayground.tsx index 9e9d36a55ecf..6cd5b0cee447 100644 --- a/src/components/GraphPlayground/GraphPlayground.tsx +++ b/src/components/GraphPlayground/GraphPlayground.tsx @@ -1,20 +1,17 @@ -import {LayoutColumns, LayoutSideContentRight} from '@gravity-ui/icons'; import {Col, Grid, Row} from '@gravity-ui/page-constructor'; -import {Button, Icon, Text} from '@gravity-ui/uikit'; +import {Button, ThemeProvider} from '@gravity-ui/uikit'; import {useTranslation} from 'next-i18next'; -import {useState} from 'react'; import {block, getLocaleLink} from '../../utils'; import './GraphPlayground.scss'; +import {GraphPlayground} from './Playground/GraphPlayground'; const b = block('graph'); export const GraphPlayround = () => { const {t, i18n} = useTranslation('graph'); - const [JSONVisible, setJSONVisible] = useState(true); - return ( @@ -33,23 +30,9 @@ export const GraphPlayround = () => { - - - Graph viewer -
- - {JSONVisible && ( - - JSON Editor -
- - )} + + +
); diff --git a/src/components/GraphPlayground/Playground/ActionBlock/ActionBlock.scss b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlock.scss new file mode 100644 index 000000000000..f2fbf763c251 --- /dev/null +++ b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlock.scss @@ -0,0 +1,26 @@ +@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables; +@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins; +@use '../../../../variables.scss'; + +$block: '.#{variables.$ns}block'; + +#{$block} { + border-radius: 8px; + border-width: 2px; + padding: var(--g-spacing-3); + + display: flex; + flex-direction: column; + gap: var(--g-spacing-1); + + &:hover { + &:not(.selected) { + border-color: rgba(229, 229, 229, 0.4); + } + background-color: rgba(57, 47, 57, 1); + } + + &__name { + font-weight: 500; + } +} diff --git a/src/components/GraphPlayground/Playground/ActionBlock/ActionBlockHtml.tsx b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlockHtml.tsx new file mode 100644 index 000000000000..082cbb485515 --- /dev/null +++ b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlockHtml.tsx @@ -0,0 +1,41 @@ +import {Graph, GraphBlock, GraphBlockAnchor} from '@gravity-ui/graph'; +import {Database} from '@gravity-ui/icons'; +import {Button, Flex, Icon, Text} from '@gravity-ui/uikit'; +import React from 'react'; + +import {block} from '../../../../utils'; +import {TGravityActionBlock} from '../generateLayout'; + +import './ActionBlock.scss'; + +const b = block('block'); + +export function ActionBlockHtml({graph, block}: {graph: Graph; block: TGravityActionBlock}) { + return ( + + {block.anchors.map((anchor) => { + return ( + + ); + })} + + + {block.name} + + + {block.meta?.description} + + + + + + + ); +} diff --git a/src/components/GraphPlayground/Playground/ActionBlock/index.tsx b/src/components/GraphPlayground/Playground/ActionBlock/index.tsx new file mode 100644 index 000000000000..dc8199ee33e9 --- /dev/null +++ b/src/components/GraphPlayground/Playground/ActionBlock/index.tsx @@ -0,0 +1,168 @@ +import {CanvasBlock, EAnchorType, TAnchor, TBlockId, TPoint, layoutText} from '@gravity-ui/graph'; +import {EventedComponent} from '@gravity-ui/graph/build/mixins/withEvents'; +import React from 'react'; + +import {TGravityActionBlock} from '../generateLayout'; + +import {ActionBlockHtml} from './ActionBlockHtml'; + +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}, +) { + ctx.save(); + 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'); + ctx.restore(); +} + +function getAnchorY(index: number) { + let y = 18 * index; + if (index >= 1) { + y += 8 * index; + } + return y + 18; +} + +export class ActionBlock extends CanvasBlock { + cursor = 'pointer'; + + protected hovered = false; + + renderHTML() { + return ( + + ); + } + + 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, + }; + } + + 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(), + ); + } + + 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); + } + } + + protected subscribe(id: TBlockId) { + const subs = super.subscribe(id); + subs.push( + // FIXME: Types is broken, parent methods do not passed to child + (this as unknown as EventedComponent).addEventListener('mouseenter', () => { + this.hovered = true; + (this as unknown as EventedComponent).performRender(); + }), + (this as unknown as EventedComponent).addEventListener('mouseleave', () => { + this.hovered = false; + (this as unknown as EventedComponent).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); + } + } + + 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; + } + + 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 = Math.floor(y + shiftY); + ctx.fillText(line, x, rY); + } + } + + protected renderBody(ctx: CanvasRenderingContext2D) { + const scale = this.context.camera.getCameraScale(); + 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.lineWidth = Math.min(Math.round(2 / scale), 12); + ctx.strokeStyle = this.context.colors.block?.selectedBorder || ''; + } else { + ctx.lineWidth = Math.min(Math.round(1 / scale), 12); + ctx.strokeStyle = this.hovered + ? 'rgba(229, 229, 229, 0.4)' + : this.context.colors.block?.border || ''; + } + ctx.stroke(); + ctx.closePath(); + } +} diff --git a/src/components/GraphPlayground/Playground/Editor/Editor.scss b/src/components/GraphPlayground/Playground/Editor/Editor.scss new file mode 100644 index 000000000000..3d9f3693dcc7 --- /dev/null +++ b/src/components/GraphPlayground/Playground/Editor/Editor.scss @@ -0,0 +1,20 @@ +@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables; +@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins; +@use '../../../../variables.scss'; + +$block: '.#{variables.$ns}editor'; + +#{$block} { + width: 100%; + + &__actions { + padding: var(--g-spacing-3); + border-top: 1px solid var(--g-color-base-float-accent-hover); + + #{$block} { + &__hotkey { + --g-color-text-hint: var(--g-color-text-dark-hint); + } + } + } +} diff --git a/src/components/GraphPlayground/Playground/Editor/index.tsx b/src/components/GraphPlayground/Playground/Editor/index.tsx new file mode 100644 index 000000000000..e9e496b15ccf --- /dev/null +++ b/src/components/GraphPlayground/Playground/Editor/index.tsx @@ -0,0 +1,186 @@ +import {TBlock, TBlockId, TConnection} from '@gravity-ui/graph'; +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, useCallback, useImperativeHandle, useRef, useState} from 'react'; + +import {block} from '../../../../utils'; + +import './Editor.scss'; +import {defineConigSchema} from './schema'; +import {GravityTheme, defineTheme} from './theme'; +import {findBlockPositionsMonaco} from './utils'; + +loader.init().then((monaco) => { + defineTheme(monaco); + defineConigSchema(monaco); +}); + +const b = block('editor'); + +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]>>(); + + const monacoRef = useRef[0]>(); + + const valueRef = useRef<{blocks: TBlock[]; connections: TConnection[]}>({ + blocks: [], + connections: [], + }); + + useImperativeHandle(ref, () => ({ + scrollTo: (blockId) => { + 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[]) => { + const model = monacoRef.current?.getModel(); + if (!model) { + return; + } + 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 = useCallback(() => { + const model = monacoRef.current?.getModel(); + if (!model) { + return; + } + try { + const data = JSON.parse(model.getValue()); + props?.onChange?.({blocks: data.blocks, connections: data.conections}); + } catch (e) { + console.error(e); + } + }, [monacoRef]); + + return ( + + + { + monacoRef.current = editor; + monacoRef.current?.setValue(JSON.stringify(valueRef.current, null, 2)); + // eslint-disable-next-line no-bitwise + 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} + + + + )} + + + ); +}); diff --git a/src/components/GraphPlayground/Playground/Editor/schema.ts b/src/components/GraphPlayground/Playground/Editor/schema.ts new file mode 100644 index 000000000000..0836eafab5d8 --- /dev/null +++ b/src/components/GraphPlayground/Playground/Editor/schema.ts @@ -0,0 +1,168 @@ +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'], + }, + }, + }, + }, + ], + }); +} diff --git a/src/components/GraphPlayground/Playground/Editor/theme.ts b/src/components/GraphPlayground/Playground/Editor/theme.ts new file mode 100644 index 000000000000..88af951a49ac --- /dev/null +++ b/src/components/GraphPlayground/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', + }, + }); +} diff --git a/src/components/GraphPlayground/Playground/Editor/utils.ts b/src/components/GraphPlayground/Playground/Editor/utils.ts new file mode 100644 index 000000000000..575f9cd3ad5d --- /dev/null +++ b/src/components/GraphPlayground/Playground/Editor/utils.ts @@ -0,0 +1,31 @@ +import {TBlockId} from '@gravity-ui/graph'; +import {editor} from 'monaco-editor'; + +export function findBlockPositionsMonaco(model: editor.ITextModel, blockId: TBlockId) { + const configString = model.getValue(); + const blockSearchStr = `"id": "${String(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; + + const startPosition = model.getPositionAt(blockStart); + const endPosition = model.getPositionAt(blockEnd); + + return { + start: startPosition, + end: endPosition, + }; +} diff --git a/src/components/GraphPlayground/Playground/GraphPlayground.tsx b/src/components/GraphPlayground/Playground/GraphPlayground.tsx new file mode 100644 index 000000000000..d82a8c0b330b --- /dev/null +++ b/src/components/GraphPlayground/Playground/GraphPlayground.tsx @@ -0,0 +1,367 @@ +import { + EAnchorType, + ECanChangeBlockGeometry, + Graph, + GraphBlock, + GraphCanvas, + GraphState, + HookGraphParams, + TBlock, + useGraph, + useGraphEvent, +} from '@gravity-ui/graph'; +import {LayoutColumns, LayoutSideContentRight} from '@gravity-ui/icons'; +import {Button, Flex, Icon, RadioButton, RadioButtonOption, Text} from '@gravity-ui/uikit'; +import random from 'lodash/random'; +import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; + +import {block} from '../../../utils'; + +import {ActionBlock} from './ActionBlock'; +import {ConfigEditor, ConfigEditorController} from './Editor'; +import './Playground.scss'; +import {GraphSettings} from './Settings'; +import {TextBlock} from './TextBlock'; +import {Toolbox} from './Toolbox'; +import { + GravityActionBlockIS, + GravityTextBlockIS, + createActionBlock, + createTextBlock, + generatePlaygroundActionBlocks, +} from './generateLayout'; + +const b = block('graph-playground'); +const radioB = block('graph-playground-radio-buton'); + +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'}, +]; + +function useFn, RT>(handler: (...args: ARG) => RT) { + const handlerRef = useRef(handler); + + handlerRef.current = handler; + + return useCallback((...args: ARG) => { + return handlerRef.current(...args); + }, []); +} + +export function GraphPlayground({className}: {className: string}) { + const {graph, setEntities, updateEntities, start} = useGraph(config); + const editorRef = useRef(null); + + const [editorOpened, setEditorOpened] = useState(true); + + 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(); + if (!sourceAnchorId) { + return; + } + 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 = async (value: string) => { + let config; + 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); + setEditorOpened(false); + break; + } + case graphSizeOptions[3].value: { + graph.updateSettings({ + useBezierConnections: false, + }); + setEditorOpened(false); + config = generatePlaygroundActionBlocks(50, 150); + break; + } + } + if (config) { + // await update editiorOpened + await new Promise((resolve) => setTimeout(resolve, 200)); + setEntities({blocks: config?.blocks, connections: config?.connections}); + graph.zoomTo('center', {transition: 500}); + updateVisibleConfig(); + } + }; + + return ( + + + + + + Blocks + + + + + + + + + + + + + + + JSON Editor + + + { + updateEntities({blocks, connections}); + }} + addBlock={addNewBlock} + /> + + + + ); +} diff --git a/src/components/GraphPlayground/Playground/Playground.scss b/src/components/GraphPlayground/Playground/Playground.scss new file mode 100644 index 000000000000..87f3b05aab85 --- /dev/null +++ b/src/components/GraphPlayground/Playground/Playground.scss @@ -0,0 +1,148 @@ +@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables; +@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins; +@use '../../../variables.scss'; + +$pg: '.#{variables.$ns}graph-playground'; +$radio: '.#{variables.$ns}graph-playground-radio-buton'; + +#{$radio} { + --_--border-radius: 8px; + --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); + + .g-radio-button__option { + &:hover .g-radio-button__option-text { + --g-color-text-primary: var(--g-color-text-light-primary); + } + + &_checked { + &:hover .g-radio-button__option-text { + --g-color-text-primary: var(--g-color-text-dark-primary); + } + } + } +} + +#{$pg} { + height: 100%; + position: relative; + + &__layout-button { + position: absolute; + top: 0; + right: 0; + } + + &__content { + &_graph { + min-width: 50%; + } + + #{$pg} { + &__title { + line-height: var(--g-text-display-1-line-height); + } + } + + &_hidden { + width: 0; + /* stylelint-disable declaration-no-important */ + flex: 0 !important; + visibility: hidden; + } + } + + &__view { + border: 1px solid transparent; + border-radius: 24px; + overflow: hidden; + position: relative; + + &_config-editor { + border-radius: 10px; + border-color: rgba(255, 255, 255, 0.2); + } + + &_graph-editor { + background-color: var(--g-color-base-background); + } + } + + &__graph-tools { + height: 100%; + pointer-events: none; + position: absolute; + left: 20px; + z-index: 10; + + #{$pg} { + &__zoom { + pointer-events: all; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 0; + z-index: 10; + transform: translate(0, -50%); + } + + &__graph-settings { + pointer-events: all; + box-sizing: border-box; + position: absolute; + bottom: 20px; + left: 0; + z-index: 10; + } + } + } + + .g-button, + .g-radio-button { + --_--border-radius: 8px; + } + + .button-group { + .g-button { + border-radius: 0; + + &::before, + &::after { + border-radius: 0; + } + + &:not(:last-child) { + border-bottom: 1px solid var(--yc-color-base-misc-light); + } + + &:first-child { + border-top-right-radius: var(--g-button-border-radius, var(--_--border-radius)); + border-top-left-radius: var(--g-button-border-radius, var(--_--border-radius)); + + &::before, + &::after { + border-top-right-radius: var(--g-button-border-radius, var(--_--border-radius)); + border-top-left-radius: var(--g-button-border-radius, var(--_--border-radius)); + } + } + + &:last-child { + border-bottom-right-radius: var(--g-button-border-radius, var(--_--border-radius)); + border-bottom-left-radius: var(--g-button-border-radius, var(--_--border-radius)); + + &::before, + &::after { + border-bottom-right-radius: var( + --g-button-border-radius, + var(--_--border-radius) + ); + border-bottom-left-radius: var( + --g-button-border-radius, + var(--_--border-radius) + ); + } + } + } + } +} diff --git a/src/components/GraphPlayground/Playground/Settings.scss b/src/components/GraphPlayground/Playground/Settings.scss new file mode 100644 index 000000000000..66110e972bab --- /dev/null +++ b/src/components/GraphPlayground/Playground/Settings.scss @@ -0,0 +1,11 @@ +@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables; +@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins; +@use '../../../variables.scss'; + +$settings: '.#{variables.$ns}graph-settings'; + +#{$settings} { + &__popup { + padding: 16px; + } +} diff --git a/src/components/GraphPlayground/Playground/Settings.tsx b/src/components/GraphPlayground/Playground/Settings.tsx new file mode 100644 index 000000000000..5c02d5e5e5e5 --- /dev/null +++ b/src/components/GraphPlayground/Playground/Settings.tsx @@ -0,0 +1,101 @@ +import {Graph} from '@gravity-ui/graph'; +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 {block} from '../../../utils'; + +import './Settings.scss'; +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'}, +]; + +const b = block('graph-settings'); + +export function GraphSettings({ + className, + radionButtonClass, + graph, +}: { + className: string; + radionButtonClass: string; + graph: Graph; +}) { + const rerender = useRerender(); + const settingBtnRef = useRef(null); + 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} + /> + + + + + ); +} diff --git a/src/components/GraphPlayground/Playground/TextBlock/TextBlock.scss b/src/components/GraphPlayground/Playground/TextBlock/TextBlock.scss new file mode 100644 index 000000000000..b993236bd2f3 --- /dev/null +++ b/src/components/GraphPlayground/Playground/TextBlock/TextBlock.scss @@ -0,0 +1,42 @@ +@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables; +@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins; +@use '../../../../variables.scss'; + +$block: '.#{variables.$ns}text-block'; + +#{$block} { + 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); + + &:hover { + cursor: pointer; + border-color: rgba(255, 190, 92, 1); + } + + &.selected { + color: rgba(255, 190, 92, 1); + border-color: rgba(255, 190, 92, 1); + border-style: solid; + } + + &__icon { + flex-shrink: 0; + } + + &__text { + flex-grow: 1; + font-weight: 500; + text-overflow: ellipsis; + } +} diff --git a/src/components/GraphPlayground/Playground/TextBlock/TextBlockHtml.tsx b/src/components/GraphPlayground/Playground/TextBlock/TextBlockHtml.tsx new file mode 100644 index 000000000000..06ef062fc9d1 --- /dev/null +++ b/src/components/GraphPlayground/Playground/TextBlock/TextBlockHtml.tsx @@ -0,0 +1,25 @@ +import {Graph, GraphBlock} from '@gravity-ui/graph'; +import {CircleInfo} from '@gravity-ui/icons'; +import {Flex, Icon, Text} from '@gravity-ui/uikit'; +import React from 'react'; + +import {block} from '../../../../utils'; +import {TGravityTextBlock} from '../generateLayout'; + +import './TextBlock.scss'; + +const b = block('text-block'); + +export function TextBlockHtml({graph, block}: {graph: Graph; block: TGravityTextBlock}) { + return ( + + + + + {block.meta?.text} + + {/* */} + + + ); +} diff --git a/src/components/GraphPlayground/Playground/TextBlock/index.tsx b/src/components/GraphPlayground/Playground/TextBlock/index.tsx new file mode 100644 index 000000000000..17af64b026db --- /dev/null +++ b/src/components/GraphPlayground/Playground/TextBlock/index.tsx @@ -0,0 +1,99 @@ +import {CanvasBlock, TBlockId, layoutText} from '@gravity-ui/graph'; +import {EventedComponent} from '@gravity-ui/graph/build/mixins/withEvents'; +import React from 'react'; + +import {TGravityTextBlock} from '../generateLayout'; + +import {TextBlockHtml} from './TextBlockHtml'; + +export class TextBlock extends CanvasBlock { + cursor = 'pointer'; + + protected hovered = false; + + renderHTML() { + return ( + + ); + } + + renderMinimalisticBlock(ctx: CanvasRenderingContext2D): void { + this.renderBody(ctx); + } + + 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); + } + } + + protected subscribe(id: TBlockId) { + const subs = super.subscribe(id); + subs.push( + (this as unknown as EventedComponent).addEventListener('mouseenter', () => { + this.hovered = true; + (this as unknown as EventedComponent).performRender(); + }), + (this as unknown as EventedComponent).addEventListener('mouseleave', () => { + this.hovered = false; + (this as unknown as EventedComponent).performRender(); + }), + ); + return subs; + } + + protected renderName(ctx: CanvasRenderingContext2D) { + ctx.fillStyle = 'rgba(189, 142, 75, 1)'; + ctx.textAlign = 'center'; + this.renderText(this.state.meta?.text || '', ctx); + } + + 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 = Math.round(y + shiftY); + ctx.fillText(line, x, rY); + } + } + + protected 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(); + } +} diff --git a/src/components/GraphPlayground/Playground/Toolbox.tsx b/src/components/GraphPlayground/Playground/Toolbox.tsx new file mode 100644 index 000000000000..9cf2df4ec082 --- /dev/null +++ b/src/components/GraphPlayground/Playground/Toolbox.tsx @@ -0,0 +1,52 @@ +import {Graph, useGraphEvent} from '@gravity-ui/graph'; +import {MagnifierMinus, MagnifierPlus, SquareDashed} from '@gravity-ui/icons'; +import {Button, Flex, Icon, Tooltip} from '@gravity-ui/uikit'; +import React, {useState} from 'react'; + +export function Toolbox({className, graph}: {className: string; graph: Graph}) { + const [scale, setScale] = useState(1); + + useGraphEvent(graph, 'camera-change', ({scale}) => { + setScale(scale); + }); + + return ( + + + + + + + + + + + + ); +} diff --git a/src/components/GraphPlayground/Playground/generateLayout.tsx b/src/components/GraphPlayground/Playground/generateLayout.tsx new file mode 100644 index 000000000000..88ce05dc5e9e --- /dev/null +++ b/src/components/GraphPlayground/Playground/generateLayout.tsx @@ -0,0 +1,129 @@ +// import { TBlock } from "../../components/canvas/blocks/Block"; +// import { TGraphConfig } from "../../graph"; +// import { EAnchorType } from "../../store/anchor/Anchor"; + +import {EAnchorType, type TBlock, TConnection} from '@gravity-ui/graph'; + +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: string, +): 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: number, max: number) { + return Math.round(Math.random() * (max - min) + min); +} + +export function generatePlaygroundActionBlocks(layersCount: number, connectionsPerLayer: number) { + const config: {blocks: TBlock[]; connections: TConnection[]} = { + 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/components/GraphPlayground/Playground/hooks.ts b/src/components/GraphPlayground/Playground/hooks.ts new file mode 100644 index 000000000000..d0529efe1178 --- /dev/null +++ b/src/components/GraphPlayground/Playground/hooks.ts @@ -0,0 +1,8 @@ +import {useCallback, useState} from 'react'; + +export function useRerender() { + const [_, setTick] = useState(Date.now()); + return useCallback(() => { + setTick(Date.now()); + }, []); +} diff --git a/src/components/GraphPlayground/consts.ts b/src/components/GraphPlayground/consts.ts new file mode 100644 index 000000000000..d8804b55f068 --- /dev/null +++ b/src/components/GraphPlayground/consts.ts @@ -0,0 +1,8 @@ +import {type RadioButtonOption} from '@gravity-ui/uikit'; + +export const GraphSizeOptions: RadioButtonOption[] = [ + {value: '1', content: '1'}, + {value: '100', content: '100'}, + {value: '1000', content: '1 000'}, + {value: '10000', content: '10 000'}, +]; diff --git a/src/libs.mjs b/src/libs.mjs index 0366de5defa7..4b2828e3d0fc 100644 --- a/src/libs.mjs +++ b/src/libs.mjs @@ -30,14 +30,13 @@ export const libs = [ id: 'graph', githubId: 'gravity-ui/graph', npmId: '@gravity-ui/graph', - title: 'Graph viewer', + title: 'Graph', primary: false, landing: false, tags: ['ui'], storybookUrl: 'https://preview.gravity-ui.com/graph/', readmeUrl: 'https://raw.githubusercontent.com/gravity-ui/graph/main/README.md', - changelogUrl: - 'https://raw.githubusercontent.com/gravity-ui/graph/main/CHANGELOG.md', + changelogUrl: 'https://raw.githubusercontent.com/gravity-ui/graph/main/CHANGELOG.md', mainBranch: 'main', }, {