From 8376c6bda1dc35904f1b6eb2cce2698b5809dc1c Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Wed, 24 Jul 2024 18:11:39 +0300 Subject: [PATCH] Improve OSS UI --- README.md | 1 + rsconcept/frontend/package-lock.json | 7 ++ rsconcept/frontend/package.json | 1 + rsconcept/frontend/src/components/Icons.tsx | 1 + .../OssPage/EditorOssGraph/EditorOssGraph.tsx | 7 +- .../OssPage/EditorOssGraph/InputNode.tsx | 48 +++++------- .../OssPage/EditorOssGraph/OperationNode.tsx | 59 +++++---------- .../pages/OssPage/EditorOssGraph/OssFlow.tsx | 74 +++++++++++++++++-- .../EditorOssGraph/ToolbarOssGraph.tsx | 26 ++++++- .../src/pages/OssPage/OssEditContext.tsx | 18 ++++- rsconcept/frontend/src/utils/constants.ts | 4 + rsconcept/frontend/src/utils/labels.ts | 3 +- 12 files changed, 171 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 9e895f7b..60db14af 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ This readme file is used mostly to document project dependencies - use-debounce - framer-motion - reagraph + - html-to-image - @tanstack/react-table - @uiw/react-codemirror - @uiw/codemirror-themes diff --git a/rsconcept/frontend/package-lock.json b/rsconcept/frontend/package-lock.json index d3fff291..a59ccb9c 100644 --- a/rsconcept/frontend/package-lock.json +++ b/rsconcept/frontend/package-lock.json @@ -15,6 +15,7 @@ "axios": "^1.7.2", "clsx": "^2.1.1", "framer-motion": "^11.3.8", + "html-to-image": "^1.11.11", "js-file-download": "^0.4.12", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -6808,6 +6809,12 @@ "dev": true, "license": "MIT" }, + "node_modules/html-to-image": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.11.tgz", + "integrity": "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==", + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", diff --git a/rsconcept/frontend/package.json b/rsconcept/frontend/package.json index 17b3a68c..695d8326 100644 --- a/rsconcept/frontend/package.json +++ b/rsconcept/frontend/package.json @@ -19,6 +19,7 @@ "axios": "^1.7.2", "clsx": "^2.1.1", "framer-motion": "^11.3.8", + "html-to-image": "^1.11.11", "js-file-download": "^0.4.12", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/rsconcept/frontend/src/components/Icons.tsx b/rsconcept/frontend/src/components/Icons.tsx index d2ff8d31..9b81c1ef 100644 --- a/rsconcept/frontend/src/components/Icons.tsx +++ b/rsconcept/frontend/src/components/Icons.tsx @@ -38,6 +38,7 @@ export { LuFolderClosed as IconFolderClosed } from 'react-icons/lu'; export { LuFolderDot as IconFolderEmpty } from 'react-icons/lu'; export { LuLightbulb as IconHelp } from 'react-icons/lu'; export { LuLightbulbOff as IconHelpOff } from 'react-icons/lu'; +export { TbGridDots as IconGrid } from 'react-icons/tb'; export { RiPushpinFill as IconPin } from 'react-icons/ri'; export { RiUnpinLine as IconUnpin } from 'react-icons/ri'; export { BiCaretDown as IconSortDesc } from 'react-icons/bi'; diff --git a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/EditorOssGraph.tsx b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/EditorOssGraph.tsx index 16f595be..c8dcad0c 100644 --- a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/EditorOssGraph.tsx +++ b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/EditorOssGraph.tsx @@ -2,6 +2,9 @@ import { ReactFlowProvider } from 'reactflow'; +import useLocalStorage from '@/hooks/useLocalStorage'; +import { storage } from '@/utils/constants'; + import OssFlow from './OssFlow'; interface EditorOssGraphProps { @@ -10,9 +13,11 @@ interface EditorOssGraphProps { } function EditorOssGraph({ isModified, setIsModified }: EditorOssGraphProps) { + const [showGrid, setShowGrid] = useLocalStorage(storage.ossShowGrid, false); + return ( - + ); } diff --git a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/InputNode.tsx b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/InputNode.tsx index 4317aa05..4ed211b8 100644 --- a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/InputNode.tsx +++ b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/InputNode.tsx @@ -1,7 +1,9 @@ import { Handle, Position } from 'reactflow'; -import { IconDestroy, IconEdit2 } from '@/components/Icons'; +import { IconRSForm } from '@/components/Icons'; import MiniButton from '@/components/ui/MiniButton.tsx'; +import Overlay from '@/components/ui/Overlay'; +import { useOSS } from '@/context/OssContext'; import { useOssEdit } from '../OssEditContext'; @@ -14,40 +16,30 @@ interface InputNodeProps { function InputNode({ id, data }: InputNodeProps) { const controller = useOssEdit(); - console.log(controller.isMutable); + const model = useOSS(); - const handleDelete = () => { - console.log('delete node ' + id); - }; + const hasFile = !!model.schema?.operationByID.get(Number(id))?.result; - const handleEditOperation = () => { - console.log('edit operation ' + id); - //controller.selectNode(id); - //controller.showSynthesis(); + const handleOpenSchema = () => { + controller.openOperationSchema(Number(id)); }; return ( <> -
-
{data.label}
-
- } - noPadding - title='Редактировать' - onClick={() => { - handleEditOperation(); - }} - /> - } - title='Удалить операцию' - onClick={handleDelete} - /> -
-
+ + + } + noHover + title='Связанная КС' + onClick={() => { + handleOpenSchema(); + }} + disabled={!hasFile} + /> + +
{data.label}
); } diff --git a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OperationNode.tsx b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OperationNode.tsx index 26e7dd73..a4ada7e6 100644 --- a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OperationNode.tsx +++ b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OperationNode.tsx @@ -1,8 +1,9 @@ -import { VscDebugStart } from 'react-icons/vsc'; import { Handle, Position } from 'reactflow'; -import { IconDestroy, IconEdit2 } from '@/components/Icons'; +import { IconRSForm } from '@/components/Icons'; import MiniButton from '@/components/ui/MiniButton.tsx'; +import Overlay from '@/components/ui/Overlay'; +import { useOSS } from '@/context/OssContext'; import { useOssEdit } from '../OssEditContext'; interface OperationNodeProps { @@ -14,50 +15,30 @@ interface OperationNodeProps { function OperationNode({ id, data }: OperationNodeProps) { const controller = useOssEdit(); - console.log(controller.isMutable); + const model = useOSS(); - const handleDelete = () => { - console.log('delete node ' + id); - // onDelete(id); - }; + const hasFile = !!model.schema?.operationByID.get(Number(id))?.result; - const handleEditOperation = () => { - console.log('edit operation ' + id); - //controller.selectNode(id); - //controller.showSynthesis(); - }; - - const handleRunOperation = () => { - console.log('run operation'); - // controller.singleSynthesis(id); + const handleOpenSchema = () => { + controller.openOperationSchema(Number(id)); }; return ( <> -
-
{data.label}
-
- } - title='Редактировать' - onClick={() => { - handleEditOperation(); - }} - /> - } - title='Синтез' - onClick={() => handleRunOperation()} - /> - } - title='Удалить операцию' - onClick={handleDelete} - /> -
-
+ + + } + noHover + title='Связанная КС' + onClick={() => { + handleOpenSchema(); + }} + disabled={!hasFile} + /> + +
{data.label}
diff --git a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx index a57cc133..45364ec2 100644 --- a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx +++ b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx @@ -1,7 +1,12 @@ 'use client'; +import { toSvg } from 'html-to-image'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { toast } from 'react-toastify'; import { + Background, + getNodesBounds, + getViewportForBounds, Node, NodeChange, NodeTypes, @@ -18,6 +23,7 @@ import AnimateFade from '@/components/wrap/AnimateFade'; import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useOSS } from '@/context/OssContext'; import { PARAMETER } from '@/utils/constants'; +import { errors } from '@/utils/labels'; import { useOssEdit } from '../OssEditContext'; import InputNode from './InputNode'; @@ -27,10 +33,12 @@ import ToolbarOssGraph from './ToolbarOssGraph'; interface OssFlowProps { isModified: boolean; setIsModified: React.Dispatch>; + showGrid: boolean; + setShowGrid: React.Dispatch>; } -function OssFlow({ isModified, setIsModified }: OssFlowProps) { - const { calculateHeight } = useConceptOptions(); +function OssFlow({ isModified, setIsModified, showGrid, setShowGrid }: OssFlowProps) { + const { calculateHeight, colors } = useConceptOptions(); const model = useOSS(); const controller = useOssEdit(); const flow = useReactFlow(); @@ -119,6 +127,47 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) { controller.deleteOperation(controller.selected[0], getPositions()); }, [controller, getPositions]); + const handleFitView = useCallback(() => { + flow.fitView({ duration: PARAMETER.zoomDuration }); + }, [flow]); + + const handleResetPositions = useCallback(() => { + setToggleReset(prev => !prev); + }, []); + + const handleSaveImage = useCallback(() => { + const canvas: HTMLElement | null = document.querySelector('.react-flow__viewport'); + if (canvas === null) { + toast.error(errors.imageFailed); + return; + } + + const imageWidth = PARAMETER.ossImageWidth; + const imageHeight = PARAMETER.ossImageHeight; + const nodesBounds = getNodesBounds(nodes); + const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.5, 2); + toSvg(canvas, { + backgroundColor: colors.bgDefault, + width: imageWidth, + height: imageHeight, + style: { + width: String(imageWidth), + height: String(imageHeight), + transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})` + } + }) + .then(dataURL => { + const a = document.createElement('a'); + a.setAttribute('download', 'reactflow.svg'); + a.setAttribute('href', dataURL); + a.click(); + }) + .catch(error => { + console.error(error); + toast.error(errors.imageFailed); + }); + }, [colors, nodes]); + function handleKeyDown(event: React.KeyboardEvent) { // Hotkeys implementation if (controller.isProcessing) { @@ -127,6 +176,12 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) { if (!controller.isMutable) { return; } + if ((event.ctrlKey || event.metaKey) && event.key === 's') { + event.preventDefault(); + event.stopPropagation(); + handleSavePositions(); + return; + } if (event.key === 'Delete') { event.preventDefault(); event.stopPropagation(); @@ -160,9 +215,13 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) { maxZoom={2} minZoom={0.75} nodesConnectable={false} - /> + snapToGrid={true} + snapGrid={[10, 10]} + > + {showGrid ? : null} + ), - [nodes, edges, proOptions, handleNodesChange, onEdgesChange, OssNodeTypes] + [nodes, edges, proOptions, handleNodesChange, onEdgesChange, OssNodeTypes, showGrid] ); return ( @@ -170,11 +229,14 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) { flow.fitView({ duration: PARAMETER.zoomDuration })} + showGrid={showGrid} + onFitView={handleFitView} onCreate={handleCreateOperation} onDelete={handleDeleteOperation} - onResetPositions={() => setToggleReset(prev => !prev)} + onResetPositions={handleResetPositions} onSavePositions={handleSavePositions} + onSaveImage={handleSaveImage} + toggleShowGrid={() => setShowGrid(prev => !prev)} />
diff --git a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/ToolbarOssGraph.tsx b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/ToolbarOssGraph.tsx index 30aa14c0..fa69f52f 100644 --- a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/ToolbarOssGraph.tsx +++ b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/ToolbarOssGraph.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; -import { IconDestroy, IconFitImage, IconNewItem, IconReset, IconSave } from '@/components/Icons'; +import { IconDestroy, IconFitImage, IconGrid, IconImage, IconNewItem, IconReset, IconSave } from '@/components/Icons'; import BadgeHelp from '@/components/info/BadgeHelp'; import MiniButton from '@/components/ui/MiniButton'; import { HelpTopic } from '@/models/miscellaneous'; @@ -11,20 +11,26 @@ import { useOssEdit } from '../OssEditContext'; interface ToolbarOssGraphProps { isModified: boolean; + showGrid: boolean; onCreate: () => void; onDelete: () => void; onFitView: () => void; + onSaveImage: () => void; onSavePositions: () => void; onResetPositions: () => void; + toggleShowGrid: () => void; } function ToolbarOssGraph({ isModified, + showGrid, onCreate, onDelete, onFitView, + onSaveImage, onSavePositions, - onResetPositions + onResetPositions, + toggleShowGrid }: ToolbarOssGraphProps) { const controller = useOssEdit(); @@ -51,6 +57,17 @@ function ToolbarOssGraph({ title='Сбросить вид' onClick={onFitView} /> + + ) : ( + + ) + } + onClick={toggleShowGrid} + /> {controller.isMutable ? ( ) : null} + } + title='Сохранить изображение' + onClick={onSaveImage} + /> void; + openOperationSchema: (target: OperationID) => void; + savePositions: (positions: IOperationPosition[], callback?: () => void) => void; promptCreateOperation: (x: number, y: number, positions: IOperationPosition[]) => void; deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void; @@ -56,7 +60,7 @@ interface OssEditStateProps { } export const OssEditState = ({ selected, setSelected, children }: OssEditStateProps) => { - // const router = useConceptNavigation(); + const router = useConceptNavigation(); const { user } = useAuth(); const { adminMode } = useConceptOptions(); const { accessLevel, setAccessLevel } = useAccessMode(); @@ -151,6 +155,17 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr [model] ); + const openOperationSchema = useCallback( + (target: OperationID) => { + const node = model.schema?.operationByID.get(target); + if (!node || !node.result) { + return; + } + router.push(urls.schema(node.result)); + }, + [router, model] + ); + const savePositions = useCallback( (positions: IOperationPosition[], callback?: () => void) => { model.savePositions({ positions: positions }, () => { @@ -208,6 +223,7 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr share, setSelected, + openOperationSchema, savePositions, promptCreateOperation, deleteOperation diff --git a/rsconcept/frontend/src/utils/constants.ts b/rsconcept/frontend/src/utils/constants.ts index 0eb610ab..99956730 100644 --- a/rsconcept/frontend/src/utils/constants.ts +++ b/rsconcept/frontend/src/utils/constants.ts @@ -12,6 +12,8 @@ export const PARAMETER = { minimalTimeout: 10, // milliseconds delay for fast updates zoomDuration: 500, // milliseconds animation duration + ossImageWidth: 1280, // pixels - size of OSS image + ossImageHeight: 960, // pixels - size of OSS image graphHoverXLimit: 0.4, // ratio to clientWidth used to determine which side of screen popup should be graphHoverYLimit: 0.6, // ratio to clientHeight used to determine which side of screen popup should be @@ -110,6 +112,8 @@ export const storage = { rsgraphSizing: 'rsgraph.sizing', rsgraphFoldHidden: 'rsgraph.fold_hidden', + ossShowGrid: 'oss.show_grid', + cstFilterMatch: 'cst.filter.match', cstFilterGraph: 'cst.filter.graph' }; diff --git a/rsconcept/frontend/src/utils/labels.ts b/rsconcept/frontend/src/utils/labels.ts index 6365dee7..385ca4b5 100644 --- a/rsconcept/frontend/src/utils/labels.ts +++ b/rsconcept/frontend/src/utils/labels.ts @@ -948,7 +948,8 @@ export const information = { */ export const errors = { astFailed: 'Невозможно построить дерево разбора', - passwordsMismatch: 'Пароли не совпадают' + passwordsMismatch: 'Пароли не совпадают', + imageFailed: 'Ошибка при создании изображения' }; /**