Improve OSS UI
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled

This commit is contained in:
Ivan 2024-07-24 18:11:28 +03:00
parent b1491ccd35
commit 338ad2bb98
12 changed files with 171 additions and 78 deletions

View File

@ -43,6 +43,7 @@ This readme file is used mostly to document project dependencies
- use-debounce - use-debounce
- framer-motion - framer-motion
- reagraph - reagraph
- html-to-image
- @tanstack/react-table - @tanstack/react-table
- @uiw/react-codemirror - @uiw/react-codemirror
- @uiw/codemirror-themes - @uiw/codemirror-themes

View File

@ -15,6 +15,7 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.3.8", "framer-motion": "^11.3.8",
"html-to-image": "^1.11.11",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@ -6808,6 +6809,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/https-proxy-agent": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",

View File

@ -19,6 +19,7 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.3.8", "framer-motion": "^11.3.8",
"html-to-image": "^1.11.11",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@ -38,6 +38,7 @@ export { LuFolderClosed as IconFolderClosed } from 'react-icons/lu';
export { LuFolderDot as IconFolderEmpty } from 'react-icons/lu'; export { LuFolderDot as IconFolderEmpty } from 'react-icons/lu';
export { LuLightbulb as IconHelp } from 'react-icons/lu'; export { LuLightbulb as IconHelp } from 'react-icons/lu';
export { LuLightbulbOff as IconHelpOff } 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 { RiPushpinFill as IconPin } from 'react-icons/ri';
export { RiUnpinLine as IconUnpin } from 'react-icons/ri'; export { RiUnpinLine as IconUnpin } from 'react-icons/ri';
export { BiCaretDown as IconSortDesc } from 'react-icons/bi'; export { BiCaretDown as IconSortDesc } from 'react-icons/bi';

View File

@ -2,6 +2,9 @@
import { ReactFlowProvider } from 'reactflow'; import { ReactFlowProvider } from 'reactflow';
import useLocalStorage from '@/hooks/useLocalStorage';
import { storage } from '@/utils/constants';
import OssFlow from './OssFlow'; import OssFlow from './OssFlow';
interface EditorOssGraphProps { interface EditorOssGraphProps {
@ -10,9 +13,11 @@ interface EditorOssGraphProps {
} }
function EditorOssGraph({ isModified, setIsModified }: EditorOssGraphProps) { function EditorOssGraph({ isModified, setIsModified }: EditorOssGraphProps) {
const [showGrid, setShowGrid] = useLocalStorage<boolean>(storage.ossShowGrid, false);
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
<OssFlow isModified={isModified} setIsModified={setIsModified} /> <OssFlow isModified={isModified} setIsModified={setIsModified} showGrid={showGrid} setShowGrid={setShowGrid} />
</ReactFlowProvider> </ReactFlowProvider>
); );
} }

View File

@ -1,7 +1,9 @@
import { Handle, Position } from 'reactflow'; import { Handle, Position } from 'reactflow';
import { IconDestroy, IconEdit2 } from '@/components/Icons'; import { IconRSForm } from '@/components/Icons';
import MiniButton from '@/components/ui/MiniButton.tsx'; import MiniButton from '@/components/ui/MiniButton.tsx';
import Overlay from '@/components/ui/Overlay';
import { useOSS } from '@/context/OssContext';
import { useOssEdit } from '../OssEditContext'; import { useOssEdit } from '../OssEditContext';
@ -14,40 +16,30 @@ interface InputNodeProps {
function InputNode({ id, data }: InputNodeProps) { function InputNode({ id, data }: InputNodeProps) {
const controller = useOssEdit(); const controller = useOssEdit();
console.log(controller.isMutable); const model = useOSS();
const handleDelete = () => { const hasFile = !!model.schema?.operationByID.get(Number(id))?.result;
console.log('delete node ' + id);
};
const handleEditOperation = () => { const handleOpenSchema = () => {
console.log('edit operation ' + id); controller.openOperationSchema(Number(id));
//controller.selectNode(id);
//controller.showSynthesis();
}; };
return ( return (
<> <>
<Handle type='source' position={Position.Bottom} /> <Handle type='source' position={Position.Bottom} />
<div className='flex justify-between items-center'>
<div className='flex-grow text-center'>{data.label}</div> <Overlay position='top-[-0.2rem] right-[-0.2rem]' className='cc-icons'>
<div className='cc-icons'> <MiniButton
<MiniButton icon={<IconRSForm className={hasFile ? 'clr-text-green' : 'clr-text-red'} size='0.75rem' />}
icon={<IconEdit2 className='icon-primary' size='0.75rem' />} noHover
noPadding title='Связанная КС'
title='Редактировать' onClick={() => {
onClick={() => { handleOpenSchema();
handleEditOperation(); }}
}} disabled={!hasFile}
/> />
<MiniButton </Overlay>
noPadding <div className='flex-grow text-center text-sm'>{data.label}</div>
icon={<IconDestroy className='icon-red' size='0.75rem' />}
title='Удалить операцию'
onClick={handleDelete}
/>
</div>
</div>
</> </>
); );
} }

View File

@ -1,8 +1,9 @@
import { VscDebugStart } from 'react-icons/vsc';
import { Handle, Position } from 'reactflow'; import { Handle, Position } from 'reactflow';
import { IconDestroy, IconEdit2 } from '@/components/Icons'; import { IconRSForm } from '@/components/Icons';
import MiniButton from '@/components/ui/MiniButton.tsx'; import MiniButton from '@/components/ui/MiniButton.tsx';
import Overlay from '@/components/ui/Overlay';
import { useOSS } from '@/context/OssContext';
import { useOssEdit } from '../OssEditContext'; import { useOssEdit } from '../OssEditContext';
interface OperationNodeProps { interface OperationNodeProps {
@ -14,50 +15,30 @@ interface OperationNodeProps {
function OperationNode({ id, data }: OperationNodeProps) { function OperationNode({ id, data }: OperationNodeProps) {
const controller = useOssEdit(); const controller = useOssEdit();
console.log(controller.isMutable); const model = useOSS();
const handleDelete = () => { const hasFile = !!model.schema?.operationByID.get(Number(id))?.result;
console.log('delete node ' + id);
// onDelete(id);
};
const handleEditOperation = () => { const handleOpenSchema = () => {
console.log('edit operation ' + id); controller.openOperationSchema(Number(id));
//controller.selectNode(id);
//controller.showSynthesis();
};
const handleRunOperation = () => {
console.log('run operation');
// controller.singleSynthesis(id);
}; };
return ( return (
<> <>
<Handle type='source' position={Position.Bottom} /> <Handle type='source' position={Position.Bottom} />
<div className='flex justify-between'>
<div className='flex-grow text-center'>{data.label}</div> <Overlay position='top-[-0.2rem] right-[-0.2rem]' className='cc-icons'>
<div className='cc-icons'> <MiniButton
<MiniButton icon={<IconRSForm className={hasFile ? 'clr-text-green' : 'clr-text-red'} size='0.75rem' />}
icon={<IconEdit2 className='icon-primary' size='1rem' />} noHover
title='Редактировать' title='Связанная КС'
onClick={() => { onClick={() => {
handleEditOperation(); handleOpenSchema();
}} }}
/> disabled={!hasFile}
<MiniButton />
className='float-right' </Overlay>
icon={<VscDebugStart className='icon-green' size='1rem' />} <div className='flex-grow text-center text-sm'>{data.label}</div>
title='Синтез'
onClick={() => handleRunOperation()}
/>
<MiniButton
icon={<IconDestroy className='icon-red' size='1rem' />}
title='Удалить операцию'
onClick={handleDelete}
/>
</div>
</div>
<Handle type='target' position={Position.Top} id='left' style={{ left: 40 }} /> <Handle type='target' position={Position.Top} id='left' style={{ left: 40 }} />
<Handle type='target' position={Position.Top} id='right' style={{ right: 40, left: 'auto' }} /> <Handle type='target' position={Position.Top} id='right' style={{ right: 40, left: 'auto' }} />

View File

@ -1,7 +1,12 @@
'use client'; 'use client';
import { toSvg } from 'html-to-image';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { import {
Background,
getNodesBounds,
getViewportForBounds,
Node, Node,
NodeChange, NodeChange,
NodeTypes, NodeTypes,
@ -18,6 +23,7 @@ import AnimateFade from '@/components/wrap/AnimateFade';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useOSS } from '@/context/OssContext'; import { useOSS } from '@/context/OssContext';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { errors } from '@/utils/labels';
import { useOssEdit } from '../OssEditContext'; import { useOssEdit } from '../OssEditContext';
import InputNode from './InputNode'; import InputNode from './InputNode';
@ -27,10 +33,12 @@ import ToolbarOssGraph from './ToolbarOssGraph';
interface OssFlowProps { interface OssFlowProps {
isModified: boolean; isModified: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
showGrid: boolean;
setShowGrid: React.Dispatch<React.SetStateAction<boolean>>;
} }
function OssFlow({ isModified, setIsModified }: OssFlowProps) { function OssFlow({ isModified, setIsModified, showGrid, setShowGrid }: OssFlowProps) {
const { calculateHeight } = useConceptOptions(); const { calculateHeight, colors } = useConceptOptions();
const model = useOSS(); const model = useOSS();
const controller = useOssEdit(); const controller = useOssEdit();
const flow = useReactFlow(); const flow = useReactFlow();
@ -119,6 +127,47 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
controller.deleteOperation(controller.selected[0], getPositions()); controller.deleteOperation(controller.selected[0], getPositions());
}, [controller, 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<HTMLDivElement>) { function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
// Hotkeys implementation // Hotkeys implementation
if (controller.isProcessing) { if (controller.isProcessing) {
@ -127,6 +176,12 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
if (!controller.isMutable) { if (!controller.isMutable) {
return; return;
} }
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
event.stopPropagation();
handleSavePositions();
return;
}
if (event.key === 'Delete') { if (event.key === 'Delete') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@ -160,9 +215,13 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
maxZoom={2} maxZoom={2}
minZoom={0.75} minZoom={0.75}
nodesConnectable={false} nodesConnectable={false}
/> snapToGrid={true}
snapGrid={[10, 10]}
>
{showGrid ? <Background gap={10} /> : null}
</ReactFlow>
), ),
[nodes, edges, proOptions, handleNodesChange, onEdgesChange, OssNodeTypes] [nodes, edges, proOptions, handleNodesChange, onEdgesChange, OssNodeTypes, showGrid]
); );
return ( return (
@ -170,11 +229,14 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
<Overlay position='top-0 pt-1 right-1/2 translate-x-1/2' className='rounded-b-2xl cc-blur'> <Overlay position='top-0 pt-1 right-1/2 translate-x-1/2' className='rounded-b-2xl cc-blur'>
<ToolbarOssGraph <ToolbarOssGraph
isModified={isModified} isModified={isModified}
onFitView={() => flow.fitView({ duration: PARAMETER.zoomDuration })} showGrid={showGrid}
onFitView={handleFitView}
onCreate={handleCreateOperation} onCreate={handleCreateOperation}
onDelete={handleDeleteOperation} onDelete={handleDeleteOperation}
onResetPositions={() => setToggleReset(prev => !prev)} onResetPositions={handleResetPositions}
onSavePositions={handleSavePositions} onSavePositions={handleSavePositions}
onSaveImage={handleSaveImage}
toggleShowGrid={() => setShowGrid(prev => !prev)}
/> />
</Overlay> </Overlay>
<div className='relative' style={{ height: canvasHeight, width: canvasWidth }}> <div className='relative' style={{ height: canvasHeight, width: canvasWidth }}>

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'; 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 BadgeHelp from '@/components/info/BadgeHelp';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
import { HelpTopic } from '@/models/miscellaneous'; import { HelpTopic } from '@/models/miscellaneous';
@ -11,20 +11,26 @@ import { useOssEdit } from '../OssEditContext';
interface ToolbarOssGraphProps { interface ToolbarOssGraphProps {
isModified: boolean; isModified: boolean;
showGrid: boolean;
onCreate: () => void; onCreate: () => void;
onDelete: () => void; onDelete: () => void;
onFitView: () => void; onFitView: () => void;
onSaveImage: () => void;
onSavePositions: () => void; onSavePositions: () => void;
onResetPositions: () => void; onResetPositions: () => void;
toggleShowGrid: () => void;
} }
function ToolbarOssGraph({ function ToolbarOssGraph({
isModified, isModified,
showGrid,
onCreate, onCreate,
onDelete, onDelete,
onFitView, onFitView,
onSaveImage,
onSavePositions, onSavePositions,
onResetPositions onResetPositions,
toggleShowGrid
}: ToolbarOssGraphProps) { }: ToolbarOssGraphProps) {
const controller = useOssEdit(); const controller = useOssEdit();
@ -51,6 +57,17 @@ function ToolbarOssGraph({
title='Сбросить вид' title='Сбросить вид'
onClick={onFitView} onClick={onFitView}
/> />
<MiniButton
title={showGrid ? 'Скрыть сетку' : 'Отобразить сетку'}
icon={
showGrid ? (
<IconGrid size='1.25rem' className='icon-green' />
) : (
<IconGrid size='1.25rem' className='icon-primary' />
)
}
onClick={toggleShowGrid}
/>
{controller.isMutable ? ( {controller.isMutable ? (
<MiniButton <MiniButton
title='Новая операция' title='Новая операция'
@ -67,6 +84,11 @@ function ToolbarOssGraph({
onClick={onDelete} onClick={onDelete}
/> />
) : null} ) : null}
<MiniButton
icon={<IconImage size='1.25rem' className='icon-primary' />}
title='Сохранить изображение'
onClick={onSaveImage}
/>
<BadgeHelp <BadgeHelp
topic={HelpTopic.UI_OSS_GRAPH} topic={HelpTopic.UI_OSS_GRAPH}
className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')} className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')}

View File

@ -4,9 +4,11 @@ import { AnimatePresence } from 'framer-motion';
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { urls } from '@/app/urls';
import { useAccessMode } from '@/context/AccessModeContext'; import { useAccessMode } from '@/context/AccessModeContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { useOSS } from '@/context/OssContext'; import { useOSS } from '@/context/OssContext';
import DlgChangeLocation from '@/dialogs/DlgChangeLocation'; import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCreateOperation from '@/dialogs/DlgCreateOperation'; import DlgCreateOperation from '@/dialogs/DlgCreateOperation';
@ -34,6 +36,8 @@ export interface IOssEditContext {
share: () => void; share: () => void;
openOperationSchema: (target: OperationID) => void;
savePositions: (positions: IOperationPosition[], callback?: () => void) => void; savePositions: (positions: IOperationPosition[], callback?: () => void) => void;
promptCreateOperation: (x: number, y: number, positions: IOperationPosition[]) => void; promptCreateOperation: (x: number, y: number, positions: IOperationPosition[]) => void;
deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void; deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void;
@ -56,7 +60,7 @@ interface OssEditStateProps {
} }
export const OssEditState = ({ selected, setSelected, children }: OssEditStateProps) => { export const OssEditState = ({ selected, setSelected, children }: OssEditStateProps) => {
// const router = useConceptNavigation(); const router = useConceptNavigation();
const { user } = useAuth(); const { user } = useAuth();
const { adminMode } = useConceptOptions(); const { adminMode } = useConceptOptions();
const { accessLevel, setAccessLevel } = useAccessMode(); const { accessLevel, setAccessLevel } = useAccessMode();
@ -151,6 +155,17 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
[model] [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( const savePositions = useCallback(
(positions: IOperationPosition[], callback?: () => void) => { (positions: IOperationPosition[], callback?: () => void) => {
model.savePositions({ positions: positions }, () => { model.savePositions({ positions: positions }, () => {
@ -208,6 +223,7 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
share, share,
setSelected, setSelected,
openOperationSchema,
savePositions, savePositions,
promptCreateOperation, promptCreateOperation,
deleteOperation deleteOperation

View File

@ -12,6 +12,8 @@ export const PARAMETER = {
minimalTimeout: 10, // milliseconds delay for fast updates minimalTimeout: 10, // milliseconds delay for fast updates
zoomDuration: 500, // milliseconds animation duration 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 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 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', rsgraphSizing: 'rsgraph.sizing',
rsgraphFoldHidden: 'rsgraph.fold_hidden', rsgraphFoldHidden: 'rsgraph.fold_hidden',
ossShowGrid: 'oss.show_grid',
cstFilterMatch: 'cst.filter.match', cstFilterMatch: 'cst.filter.match',
cstFilterGraph: 'cst.filter.graph' cstFilterGraph: 'cst.filter.graph'
}; };

View File

@ -948,7 +948,8 @@ export const information = {
*/ */
export const errors = { export const errors = {
astFailed: 'Невозможно построить дерево разбора', astFailed: 'Невозможно построить дерево разбора',
passwordsMismatch: 'Пароли не совпадают' passwordsMismatch: 'Пароли не совпадают',
imageFailed: 'Ошибка при создании изображения'
}; };
/** /**