Improve OSS UI
Some checks are pending
Frontend CI / build (22.x) (push) Waiting to run

This commit is contained in:
Ivan 2024-07-24 18:11:39 +03:00
parent acd24a28e5
commit 8376c6bda1
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
- framer-motion
- reagraph
- html-to-image
- @tanstack/react-table
- @uiw/react-codemirror
- @uiw/codemirror-themes

View File

@ -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",

View File

@ -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",

View File

@ -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';

View File

@ -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<boolean>(storage.ossShowGrid, false);
return (
<ReactFlowProvider>
<OssFlow isModified={isModified} setIsModified={setIsModified} />
<OssFlow isModified={isModified} setIsModified={setIsModified} showGrid={showGrid} setShowGrid={setShowGrid} />
</ReactFlowProvider>
);
}

View File

@ -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 (
<>
<Handle type='source' position={Position.Bottom} />
<div className='flex justify-between items-center'>
<div className='flex-grow text-center'>{data.label}</div>
<div className='cc-icons'>
<MiniButton
icon={<IconEdit2 className='icon-primary' size='0.75rem' />}
noPadding
title='Редактировать'
onClick={() => {
handleEditOperation();
}}
/>
<MiniButton
noPadding
icon={<IconDestroy className='icon-red' size='0.75rem' />}
title='Удалить операцию'
onClick={handleDelete}
/>
</div>
</div>
<Overlay position='top-[-0.2rem] right-[-0.2rem]' className='cc-icons'>
<MiniButton
icon={<IconRSForm className={hasFile ? 'clr-text-green' : 'clr-text-red'} size='0.75rem' />}
noHover
title='Связанная КС'
onClick={() => {
handleOpenSchema();
}}
disabled={!hasFile}
/>
</Overlay>
<div className='flex-grow text-center text-sm'>{data.label}</div>
</>
);
}

View File

@ -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 (
<>
<Handle type='source' position={Position.Bottom} />
<div className='flex justify-between'>
<div className='flex-grow text-center'>{data.label}</div>
<div className='cc-icons'>
<MiniButton
icon={<IconEdit2 className='icon-primary' size='1rem' />}
title='Редактировать'
onClick={() => {
handleEditOperation();
}}
/>
<MiniButton
className='float-right'
icon={<VscDebugStart className='icon-green' size='1rem' />}
title='Синтез'
onClick={() => handleRunOperation()}
/>
<MiniButton
icon={<IconDestroy className='icon-red' size='1rem' />}
title='Удалить операцию'
onClick={handleDelete}
/>
</div>
</div>
<Overlay position='top-[-0.2rem] right-[-0.2rem]' className='cc-icons'>
<MiniButton
icon={<IconRSForm className={hasFile ? 'clr-text-green' : 'clr-text-red'} size='0.75rem' />}
noHover
title='Связанная КС'
onClick={() => {
handleOpenSchema();
}}
disabled={!hasFile}
/>
</Overlay>
<div className='flex-grow text-center text-sm'>{data.label}</div>
<Handle type='target' position={Position.Top} id='left' style={{ left: 40 }} />
<Handle type='target' position={Position.Top} id='right' style={{ right: 40, left: 'auto' }} />

View File

@ -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<React.SetStateAction<boolean>>;
showGrid: boolean;
setShowGrid: React.Dispatch<React.SetStateAction<boolean>>;
}
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<HTMLDivElement>) {
// 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 ? <Background gap={10} /> : null}
</ReactFlow>
),
[nodes, edges, proOptions, handleNodesChange, onEdgesChange, OssNodeTypes]
[nodes, edges, proOptions, handleNodesChange, onEdgesChange, OssNodeTypes, showGrid]
);
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'>
<ToolbarOssGraph
isModified={isModified}
onFitView={() => 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)}
/>
</Overlay>
<div className='relative' style={{ height: canvasHeight, width: canvasWidth }}>

View File

@ -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}
/>
<MiniButton
title={showGrid ? 'Скрыть сетку' : 'Отобразить сетку'}
icon={
showGrid ? (
<IconGrid size='1.25rem' className='icon-green' />
) : (
<IconGrid size='1.25rem' className='icon-primary' />
)
}
onClick={toggleShowGrid}
/>
{controller.isMutable ? (
<MiniButton
title='Новая операция'
@ -67,6 +84,11 @@ function ToolbarOssGraph({
onClick={onDelete}
/>
) : null}
<MiniButton
icon={<IconImage size='1.25rem' className='icon-primary' />}
title='Сохранить изображение'
onClick={onSaveImage}
/>
<BadgeHelp
topic={HelpTopic.UI_OSS_GRAPH}
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 { toast } from 'react-toastify';
import { urls } from '@/app/urls';
import { useAccessMode } from '@/context/AccessModeContext';
import { useAuth } from '@/context/AuthContext';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { useOSS } from '@/context/OssContext';
import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCreateOperation from '@/dialogs/DlgCreateOperation';
@ -34,6 +36,8 @@ export interface IOssEditContext {
share: () => 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

View File

@ -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'
};

View File

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