F: Improve OSS UI

This commit is contained in:
Ivan 2025-07-01 16:13:24 +03:00
parent 976ab669e7
commit 6b36a3fd41
8 changed files with 44 additions and 184 deletions

View File

@ -107,7 +107,7 @@ export function HelpOssGraph() {
<kbd>Space</kbd> перемещение экрана <kbd>Space</kbd> перемещение экрана
</li> </li>
<li> <li>
<kbd>Shift</kbd> перемещение выделенных элементов в границах родителя <kbd>Shift</kbd> перемещение в границах блока
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -31,10 +31,6 @@ export function NodeCore({ node }: NodeCoreProps) {
const hasFile = !!node.data.operation.result; const hasFile = !!node.data.operation.result;
const longLabel = node.data.label.length > LONG_LABEL_CHARS; const longLabel = node.data.label.length > LONG_LABEL_CHARS;
function handleTouchStart(event: React.TouchEvent) {
console.log('handleTouchStart', event);
}
return ( return (
<div <div
className={cn( className={cn(
@ -42,7 +38,6 @@ export function NodeCore({ node }: NodeCoreProps) {
'relative flex items-center justify-center p-[2px]', 'relative flex items-center justify-center p-[2px]',
isChild && 'border-accent-orange' isChild && 'border-accent-orange'
)} )}
onTouchStart={handleTouchStart}
> >
<div className='absolute z-pop top-0 right-0 flex flex-col gap-[4px] p-[2px]'> <div className='absolute z-pop top-0 right-0 flex flex-col gap-[4px] p-[2px]'>
<Indicator <Indicator

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow'; import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow';
@ -8,7 +8,6 @@ import { useMainHeight } from '@/stores/app-layout';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { promptText } from '@/utils/labels'; import { promptText } from '@/utils/labels';
import { isIOS } from '@/utils/utils';
import { useDeleteBlock } from '../../../backend/use-delete-block'; import { useDeleteBlock } from '../../../backend/use-delete-block';
import { useMutatingOss } from '../../../backend/use-mutating-oss'; import { useMutatingOss } from '../../../backend/use-mutating-oss';
@ -68,9 +67,6 @@ export function OssFlow() {
const { isOpen: isContextMenuOpen, menuProps, openContextMenu, hideContextMenu } = useContextMenu(); const { isOpen: isContextMenuOpen, menuProps, openContextMenu, hideContextMenu } = useContextMenu();
const { handleDragStart, handleDrag, handleDragStop } = useDragging({ hideContextMenu }); const { handleDragStart, handleDrag, handleDragStop } = useDragging({ hideContextMenu });
const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
const longPressTarget = useRef<EventTarget | null>(null);
function handleSavePositions() { function handleSavePositions() {
void updateLayout({ itemID: schema.id, data: getLayout() }); void updateLayout({ itemID: schema.id, data: getLayout() });
} }
@ -193,62 +189,23 @@ export function OssFlow() {
openContextMenu(node, event.clientX, event.clientY); openContextMenu(node, event.clientX, event.clientY);
} }
function handleTouchStart(event: React.TouchEvent) {
if (!isIOS() || event.touches.length !== 1) {
return;
}
// Long-press support for iOS/iPadOS
const touch = event.touches[0];
longPressTarget.current = touch.target;
longPressTimeout.current = setTimeout(() => {
let targetID = null;
let element = touch.target as HTMLElement | null;
while (element) {
if (element?.getAttribute?.('data-id')) {
targetID = element.getAttribute('data-id');
break;
}
element = element.parentElement;
}
if (targetID) {
const targetNode = nodes.find(node => node.id === targetID);
if (targetNode) {
openContextMenu(targetNode, touch.clientX, touch.clientY);
}
}
}, PARAMETER.ossContextMenuDuration);
}
function handleTouchEnd() {
if (!isIOS()) return;
if (longPressTimeout.current) {
clearTimeout(longPressTimeout.current);
longPressTimeout.current = null;
}
}
function handleTouchMove() {
if (!isIOS()) return;
if (longPressTimeout.current) {
clearTimeout(longPressTimeout.current);
longPressTimeout.current = null;
}
}
return ( return (
<div tabIndex={-1} className='relative' onMouseMove={showCoordinates ? handleMouseMove : undefined}> <div tabIndex={-1} className='relative' onMouseMove={showCoordinates ? handleMouseMove : undefined}>
{showCoordinates ? <CoordinateDisplay mouseCoords={mouseCoords} className='absolute top-1 right-2' /> : null} {showCoordinates ? <CoordinateDisplay mouseCoords={mouseCoords} className='absolute top-1 right-2' /> : null}
<ContextMenu isOpen={isContextMenuOpen} onHide={hideContextMenu} {...menuProps} />
<ToolbarOssGraph <ToolbarOssGraph
className='absolute z-pop top-8 right-1/2 translate-x-1/2' className='absolute z-pop top-8 right-1/2 translate-x-1/2'
onCreateOperation={handleCreateOperation} onCreateOperation={handleCreateOperation}
onCreateBlock={handleCreateBlock} onCreateBlock={handleCreateBlock}
onDelete={handleDeleteSelected} onDelete={handleDeleteSelected}
onResetPositions={resetGraph} onResetPositions={resetGraph}
openContextMenu={openContextMenu}
isContextMenuOpen={isContextMenuOpen}
hideContextMenu={hideContextMenu}
/> />
<ContextMenu isOpen={isContextMenuOpen} onHide={hideContextMenu} {...menuProps} />
<DiagramFlow <DiagramFlow
{...flowOptions} {...flowOptions}
className={clsx(!containMovement && 'cursor-relocate')} className={clsx(!containMovement && 'cursor-relocate')}
@ -267,9 +224,6 @@ export function OssFlow() {
onNodeDragStart={handleDragStart} onNodeDragStart={handleDragStart}
onNodeDrag={handleDrag} onNodeDrag={handleDrag}
onNodeDragStop={handleDragStop} onNodeDragStop={handleDragStop}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
/> />
</div> </div>
); );

View File

@ -1,14 +1,16 @@
'use client'; 'use client';
import React from 'react';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components'; import { BadgeHelp } from '@/features/help/components';
import { type OssNode } from '@/features/oss/models/oss-layout';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { import {
IconConceptBlock, IconConceptBlock,
IconDestroy, IconDestroy,
IconEdit2, IconEdit2,
IconExecute,
IconFitImage, IconFitImage,
IconNewItem, IconNewItem,
IconReset, IconReset,
@ -18,14 +20,11 @@ import {
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { prepareTooltip } from '@/utils/utils'; import { isIOS, prepareTooltip } from '@/utils/utils';
import { OperationType } from '../../../backend/types';
import { useExecuteOperation } from '../../../backend/use-execute-operation';
import { useMutatingOss } from '../../../backend/use-mutating-oss'; import { useMutatingOss } from '../../../backend/use-mutating-oss';
import { useUpdateLayout } from '../../../backend/use-update-layout'; import { useUpdateLayout } from '../../../backend/use-update-layout';
import { NodeType } from '../../../models/oss'; import { NodeType } from '../../../models/oss';
import { LayoutManager } from '../../../models/oss-layout-api';
import { useOssEdit } from '../oss-edit-context'; import { useOssEdit } from '../oss-edit-context';
import { useOssFlow } from './oss-flow-context'; import { useOssFlow } from './oss-flow-context';
@ -36,6 +35,10 @@ interface ToolbarOssGraphProps extends Styling {
onCreateBlock: () => void; onCreateBlock: () => void;
onDelete: () => void; onDelete: () => void;
onResetPositions: () => void; onResetPositions: () => void;
isContextMenuOpen: boolean;
openContextMenu: (node: OssNode, clientX: number, clientY: number) => void;
hideContextMenu: () => void;
} }
export function ToolbarOssGraph({ export function ToolbarOssGraph({
@ -43,12 +46,16 @@ export function ToolbarOssGraph({
onCreateBlock, onCreateBlock,
onDelete, onDelete,
onResetPositions, onResetPositions,
isContextMenuOpen,
openContextMenu,
hideContextMenu,
className, className,
...restProps ...restProps
}: ToolbarOssGraphProps) { }: ToolbarOssGraphProps) {
const { schema, selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit(); const { schema, selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit();
const isProcessing = useMutatingOss(); const isProcessing = useMutatingOss();
const { resetView } = useOssFlow(); const { resetView, nodes } = useOssFlow();
const selectedOperation = const selectedOperation =
selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null; selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null;
const selectedBlock = const selectedBlock =
@ -56,33 +63,9 @@ export function ToolbarOssGraph({
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout(); const { updateLayout } = useUpdateLayout();
const { executeOperation } = useExecuteOperation();
const showEditOperation = useDialogsStore(state => state.showEditOperation);
const showEditBlock = useDialogsStore(state => state.showEditBlock);
const showOssOptions = useDialogsStore(state => state.showOssOptions); const showOssOptions = useDialogsStore(state => state.showOssOptions);
const readyForSynthesis = (() => {
if (!selectedOperation || selectedOperation.operation_type !== OperationType.SYNTHESIS) {
return false;
}
if (selectedOperation.result) {
return false;
}
const argumentIDs = schema.graph.expandInputs([selectedOperation.id]);
if (!argumentIDs || argumentIDs.length < 1) {
return false;
}
const argumentOperations = argumentIDs.map(id => schema.operationByID.get(id)!);
if (argumentOperations.some(item => item.result === null)) {
return false;
}
return true;
})();
function handleShowOptions() { function handleShowOptions() {
showOssOptions(); showOssOptions();
} }
@ -91,27 +74,18 @@ export function ToolbarOssGraph({
void updateLayout({ itemID: schema.id, data: getLayout() }); void updateLayout({ itemID: schema.id, data: getLayout() });
} }
function handleOperationExecute() { function handleEditItem(event: React.MouseEvent<HTMLButtonElement>) {
if (!readyForSynthesis || !selectedOperation) { if (isContextMenuOpen) {
hideContextMenu();
return; return;
} }
void executeOperation({ const nodeID = selectedOperation?.nodeID ?? selectedBlock?.nodeID;
itemID: schema.id, // if (!nodeID) {
data: { target: selectedOperation.id, layout: getLayout() } return;
}); }
} const node = nodes.find(node => node.id === nodeID);
if (node) {
function handleEditItem() { openContextMenu(node, event.clientX, event.clientY);
if (selectedOperation) {
showEditOperation({
manager: new LayoutManager(schema, getLayout()),
target: selectedOperation
});
} else if (selectedBlock) {
showEditBlock({
manager: new LayoutManager(schema, getLayout()),
target: selectedBlock
});
} }
} }
@ -153,11 +127,12 @@ export function ToolbarOssGraph({
disabled={isProcessing} disabled={isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Новый блок', 'Ctrl + Shift + Q')} titleHtml={prepareTooltip('Редактировать выбранную', isIOS() ? '' : 'Правый клик')}
aria-label='Новый блок' hideTitle={isContextMenuOpen}
icon={<IconConceptBlock size='1.25rem' className='icon-green' />} aria-label='Редактировать выбранную'
onClick={onCreateBlock} icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
disabled={isProcessing} onClick={handleEditItem}
disabled={selectedItems.length !== 1 || isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')} titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')}
@ -167,18 +142,13 @@ export function ToolbarOssGraph({
disabled={isProcessing} disabled={isProcessing}
/> />
<MiniButton <MiniButton
title='Активировать операцию' titleHtml={prepareTooltip('Новый блок', 'Ctrl + Shift + Q')}
icon={<IconExecute size='1.25rem' className='icon-green' />} aria-label='Новый блок'
onClick={handleOperationExecute} icon={<IconConceptBlock size='1.25rem' className='icon-green' />}
disabled={isProcessing || selectedItems.length !== 1 || !readyForSynthesis} onClick={onCreateBlock}
/> disabled={isProcessing}
<MiniButton
titleHtml={prepareTooltip('Редактировать выбранную', 'Двойной клик')}
aria-label='Редактировать выбранную'
icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
onClick={handleEditItem}
disabled={selectedItems.length !== 1 || isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')} titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}
aria-label='Удалить выбранную' aria-label='Удалить выбранную'

View File

@ -1,55 +0,0 @@
import { useAuthSuspense } from '@/features/auth';
import { MiniButton } from '@/components/control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown';
import { IconChild, IconEdit2 } from '@/components/icons';
import { useDialogsStore } from '@/stores/dialogs';
import { useMutatingOss } from '../../backend/use-mutating-oss';
import { useOssEdit } from './oss-edit-context';
export function MenuEditOss() {
const { isAnonymous } = useAuthSuspense();
const menu = useDropdown();
const { schema, isMutable } = useOssEdit();
const isProcessing = useMutatingOss();
const showRelocateConstituents = useDialogsStore(state => state.showRelocateConstituents);
function handleRelocate() {
menu.hide();
showRelocateConstituents({
oss: schema,
initialTarget: undefined
});
}
if (isAnonymous) {
return null;
}
return (
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
<MiniButton
noHover
noPadding
title='Редактирование'
hideTitle={menu.isOpen}
className='h-full px-3 text-muted-foreground hover:text-primary cc-animate-color'
icon={<IconEdit2 size='1.25rem' />}
onClick={menu.toggle}
/>
<Dropdown isOpen={menu.isOpen} margin='mt-3'>
<DropdownButton
text='Конституенты'
titleHtml='Перенос конституент</br>между схемами'
aria-label='Перенос конституент между схемами'
icon={<IconChild size='1rem' className='icon-green' />}
disabled={isProcessing || !isMutable}
onClick={handleRelocate}
/>
</Dropdown>
</div>
);
}

View File

@ -53,7 +53,7 @@ export function MenuMain() {
title='Меню' title='Меню'
hideTitle={menu.isOpen} hideTitle={menu.isOpen}
icon={<IconMenu size='1.25rem' />} icon={<IconMenu size='1.25rem' />}
className='h-full pl-2 text-muted-foreground hover:text-primary cc-animate-color' className='h-full px-2 text-muted-foreground hover:text-primary cc-animate-color'
onClick={menu.toggle} onClick={menu.toggle}
/> />
<Dropdown isOpen={menu.isOpen} margin='mt-3'> <Dropdown isOpen={menu.isOpen} margin='mt-3'>

View File

@ -3,7 +3,6 @@
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
import { MenuRole } from '@/features/library/components'; import { MenuRole } from '@/features/library/components';
import { MenuEditOss } from './menu-edit-oss';
import { MenuMain } from './menu-main'; import { MenuMain } from './menu-main';
import { useOssEdit } from './oss-edit-context'; import { useOssEdit } from './oss-edit-context';
@ -14,8 +13,6 @@ export function MenuOssTabs() {
<div className='flex border-r-2'> <div className='flex border-r-2'>
<MenuMain /> <MenuMain />
<MenuEditOss />
<MenuRole isOwned={isOwned} isEditor={!!user.id && schema.editors.includes(user.id)} /> <MenuRole isOwned={isOwned} isEditor={!!user.id && schema.editors.includes(user.id)} />
</div> </div>
); );

View File

@ -11,7 +11,6 @@ export const PARAMETER = {
notificationDelay: 300, // milliseconds delay for notifications notificationDelay: 300, // milliseconds delay for notifications
zoomDuration: 500, // milliseconds animation duration zoomDuration: 500, // milliseconds animation duration
navigationPopupDelay: 300, // milliseconds delay for navigation popup navigationPopupDelay: 300, // milliseconds delay for navigation popup
ossContextMenuDuration: 500, // milliseconds - duration of long-press to trigger context menu on iOS/iPadOS
moveDuration: 500, // milliseconds - duration of move animation moveDuration: 500, // milliseconds - duration of move animation