F: Improve termgraph UI accelerators

This commit is contained in:
Ivan 2025-11-17 18:44:46 +03:00
parent f63d9cf93e
commit 584f62579d
14 changed files with 311 additions and 51 deletions

View File

@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef } from 'react';
import { useReactFlow } from 'reactflow';
interface PanOptions {
panSpeed: number;
}
export function useContinuousPan(
ref: React.RefObject<HTMLDivElement | null>,
options: PanOptions = {
panSpeed: 15
}
) {
const { getViewport, setViewport } = useReactFlow();
const keysPressed = useRef<Set<string>>(new Set());
const rafRef = useRef<number | null>(null);
const panLoop = useCallback(() => {
const viewport = getViewport();
let { x, y } = viewport;
if (keysPressed.current.has('KeyW')) y += options.panSpeed;
if (keysPressed.current.has('KeyS')) y -= options.panSpeed;
if (keysPressed.current.has('KeyA')) x += options.panSpeed;
if (keysPressed.current.has('KeyD')) x -= options.panSpeed;
setViewport({ x, y, zoom: viewport.zoom }, { duration: 0 });
// eslint-disable-next-line react-hooks/immutability
rafRef.current = requestAnimationFrame(() => panLoop());
}, [options.panSpeed, getViewport, setViewport]);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return;
event.preventDefault();
event.stopPropagation();
keysPressed.current.add(event.code);
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(panLoop);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
keysPressed.current.delete(event.code);
if (keysPressed.current.size === 0 && rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
const handleBlur = () => {
keysPressed.current.clear();
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
element.addEventListener('keydown', handleKeyDown, { passive: false });
element.addEventListener('keyup', handleKeyUp);
element.addEventListener('blur', handleBlur);
return () => {
element.removeEventListener('keydown', handleKeyDown);
element.removeEventListener('keyup', handleKeyUp);
element.removeEventListener('blur', handleBlur);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [ref, panLoop]);
}

View File

@ -1,3 +1,7 @@
import { IconEdgeType } from '@/features/rsform/components/icon-edge-type';
import { IconGraphMode } from '@/features/rsform/components/icon-graph-mode';
import { InteractionMode, TGEdgeType } from '@/features/rsform/stores/term-graph';
import { Divider } from '@/components/container'; import { Divider } from '@/components/container';
import { import {
IconChild, IconChild,
@ -80,6 +84,9 @@ export function HelpRSGraphTerm() {
<li> <li>
<kbd>Space</kbd> перемещение экрана <kbd>Space</kbd> перемещение экрана
</li> </li>
<li>
<kbd>WASD</kbd> - направленное перемещение
</li>
<li> <li>
<IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} /> <IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} />
</li> </li>
@ -96,6 +103,18 @@ export function HelpRSGraphTerm() {
<IconTypeGraph className='inline-icon' /> Открыть{' '} <IconTypeGraph className='inline-icon' /> Открыть{' '}
<LinkTopic text='граф ступеней' topic={HelpTopic.UI_TYPE_GRAPH} /> <LinkTopic text='граф ступеней' topic={HelpTopic.UI_TYPE_GRAPH} />
</li> </li>
<li>
<IconGraphMode value={InteractionMode.explore} className='inline-icon' /> Просмотр графа
</li>
<li>
<IconGraphMode value={InteractionMode.edit} className='inline-icon icon-green' /> Редактирование связей
</li>
<li>
<IconEdgeType value={TGEdgeType.attribution} className='inline-icon' /> Атрибутирование
</li>
<li>
<IconEdgeType value={TGEdgeType.definition} className='inline-icon' /> Определение
</li>
</ul> </ul>
</div> </div>

View File

@ -22,7 +22,6 @@ export class RSFormLoader {
private schema: IRSForm; private schema: IRSForm;
private graph: Graph = new Graph(); private graph: Graph = new Graph();
private association_graph: Graph = new Graph(); private association_graph: Graph = new Graph();
private full_graph: Graph = new Graph();
private cstByAlias = new Map<string, IConstituenta>(); private cstByAlias = new Map<string, IConstituenta>();
private cstByID = new Map<number, IConstituenta>(); private cstByID = new Map<number, IConstituenta>();
@ -42,7 +41,6 @@ export class RSFormLoader {
result.cstByAlias = this.cstByAlias; result.cstByAlias = this.cstByAlias;
result.cstByID = this.cstByID; result.cstByID = this.cstByID;
result.attribution_graph = this.association_graph; result.attribution_graph = this.association_graph;
result.full_graph = this.full_graph;
return result; return result;
} }
@ -52,7 +50,6 @@ export class RSFormLoader {
this.cstByID.set(cst.id, cst); this.cstByID.set(cst.id, cst);
this.graph.addNode(cst.id); this.graph.addNode(cst.id);
this.association_graph.addNode(cst.id); this.association_graph.addNode(cst.id);
this.full_graph.addNode(cst.id);
}); });
} }
@ -63,7 +60,6 @@ export class RSFormLoader {
const source = this.cstByAlias.get(alias); const source = this.cstByAlias.get(alias);
if (source) { if (source) {
this.graph.addEdge(source.id, cst.id); this.graph.addEdge(source.id, cst.id);
this.full_graph.addEdge(source.id, cst.id);
} }
}); });
}); });
@ -113,9 +109,6 @@ export class RSFormLoader {
this.schema.attribution.forEach(attrib => { this.schema.attribution.forEach(attrib => {
const container = this.cstByID.get(attrib.container)!; const container = this.cstByID.get(attrib.container)!;
container.attributes.push(attrib.attribute); container.attributes.push(attrib.attribute);
if (!this.full_graph.hasEdge(attrib.attribute, attrib.container)) {
this.full_graph.addEdge(attrib.container, attrib.attribute);
}
this.association_graph.addEdge(attrib.container, attrib.attribute); this.association_graph.addEdge(attrib.container, attrib.attribute);
}); });
} }

View File

@ -200,7 +200,7 @@ export function colorBgCstClass(cstClass: CstClass): string {
case CstClass.BASIC: return APP_COLORS.bgGreen; case CstClass.BASIC: return APP_COLORS.bgGreen;
case CstClass.DERIVED: return APP_COLORS.bgBlue; case CstClass.DERIVED: return APP_COLORS.bgBlue;
case CstClass.STATEMENT: return APP_COLORS.bgRed; case CstClass.STATEMENT: return APP_COLORS.bgRed;
case CstClass.TEMPLATE: return APP_COLORS.bgTeal; case CstClass.TEMPLATE: return APP_COLORS.bgPurple;
} }
} }

View File

@ -29,6 +29,7 @@ export function TermGraphEdge({ id, markerEnd, style, ...props }: EdgeProps) {
return ( return (
<> <>
<path id={id} className='react-flow__edge-path' d={path} markerEnd={markerEnd} style={style} /> <path id={id} className='react-flow__edge-path' d={path} markerEnd={markerEnd} style={style} />
<path d={path} className='rf-edge-events cursor-pointer!' />
</> </>
); );
} }

View File

@ -18,6 +18,7 @@ import {
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { type Graph } from '@/models/graph'; import { type Graph } from '@/models/graph';
import { prepareTooltip } from '@/utils/utils';
interface ToolbarGraphSelectionProps extends Styling { interface ToolbarGraphSelectionProps extends Styling {
value: number[]; value: number[];
@ -26,12 +27,14 @@ interface ToolbarGraphSelectionProps extends Styling {
isCore: (item: number) => boolean; isCore: (item: number) => boolean;
isCrucial: (item: number) => boolean; isCrucial: (item: number) => boolean;
isInherited: (item: number) => boolean; isInherited: (item: number) => boolean;
tipHotkeys?: boolean;
} }
export function ToolbarGraphSelection({ export function ToolbarGraphSelection({
className, className,
graph, graph,
value, value,
tipHotkeys,
isCore, isCore,
isInherited, isInherited,
isCrucial, isCrucial,
@ -110,7 +113,8 @@ export function ToolbarGraphSelection({
return ( return (
<div className={cn('cc-icons items-center', className)} {...restProps}> <div className={cn('cc-icons items-center', className)} {...restProps}>
<MiniButton <MiniButton
title='Сбросить выделение' title={!tipHotkeys ? 'Сбросить выделение' : undefined}
titleHtml={tipHotkeys ? prepareTooltip('Сбросить выделение', 'Esc') : undefined}
icon={<IconReset size='1.25rem' className='icon-primary' />} icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={handleSelectReset} onClick={handleSelectReset}
disabled={emptySelection} disabled={emptySelection}
@ -127,14 +131,16 @@ export function ToolbarGraphSelection({
<Dropdown isOpen={isSelectedOpen} className='-translate-x-1/2'> <Dropdown isOpen={isSelectedOpen} className='-translate-x-1/2'>
<DropdownButton <DropdownButton
text='Поставщики' text='Поставщики'
title='Выделить поставщиков' title={!tipHotkeys ? 'Выделить поставщиков' : undefined}
titleHtml={tipHotkeys ? prepareTooltip('Выделить поставщиков', '1') : undefined}
icon={<IconGraphInputs size='1.25rem' className='icon-primary' />} icon={<IconGraphInputs size='1.25rem' className='icon-primary' />}
onClick={handleExpandInputs} onClick={handleExpandInputs}
disabled={emptySelection} disabled={emptySelection}
/> />
<DropdownButton <DropdownButton
text='Потребители' text='Потребители'
title='Выделить потребителей' title={!tipHotkeys ? 'Выделить потребителей' : undefined}
titleHtml={tipHotkeys ? prepareTooltip('Выделить потребителей', '2') : undefined}
icon={<IconGraphOutputs size='1.25rem' className='icon-primary' />} icon={<IconGraphOutputs size='1.25rem' className='icon-primary' />}
onClick={handleExpandOutputs} onClick={handleExpandOutputs}
disabled={emptySelection} disabled={emptySelection}
@ -142,14 +148,16 @@ export function ToolbarGraphSelection({
<DropdownButton <DropdownButton
text='Влияющие' text='Влияющие'
title='Выделить все влияющие' title={!tipHotkeys ? 'Выделить все влияющие' : undefined}
titleHtml={tipHotkeys ? prepareTooltip('Выделить все влияющие', '3') : undefined}
icon={<IconGraphCollapse size='1.25rem' className='icon-primary' />} icon={<IconGraphCollapse size='1.25rem' className='icon-primary' />}
onClick={handleSelectAllInputs} onClick={handleSelectAllInputs}
disabled={emptySelection} disabled={emptySelection}
/> />
<DropdownButton <DropdownButton
text='Зависимые' text='Зависимые'
title='Выделить все зависимые' title={!tipHotkeys ? 'Выделить все зависимые' : undefined}
titleHtml={tipHotkeys ? prepareTooltip('Выделить все зависимые', '4') : undefined}
icon={<IconGraphExpand size='1.25rem' className='icon-primary' />} icon={<IconGraphExpand size='1.25rem' className='icon-primary' />}
onClick={handleSelectAllOutputs} onClick={handleSelectAllOutputs}
disabled={emptySelection} disabled={emptySelection}
@ -157,7 +165,14 @@ export function ToolbarGraphSelection({
<DropdownButton <DropdownButton
text='Максимизация' text='Максимизация'
titleHtml='<b>Максимизация</b> <br/>дополнение выделения конституентами, <br/>зависимыми только от выделенных' titleHtml={
!tipHotkeys
? '<b>Максимизация</b> <br/>дополнение выделения конституентами, <br/>зависимыми только от выделенных'
: prepareTooltip(
'Максимизация - дополнение выделения конституентами, зависимыми только от выделенных',
'5'
)
}
aria-label='Максимизация - дополнение выделения конституентами, зависимыми только от выделенных' aria-label='Максимизация - дополнение выделения конституентами, зависимыми только от выделенных'
icon={<IconGraphMaximize size='1.25rem' className='icon-primary' />} icon={<IconGraphMaximize size='1.25rem' className='icon-primary' />}
onClick={handleSelectMaximize} onClick={handleSelectMaximize}
@ -165,6 +180,7 @@ export function ToolbarGraphSelection({
/> />
<DropdownButton <DropdownButton
text='Инвертировать' text='Инвертировать'
titleHtml={tipHotkeys ? prepareTooltip('Инвертировать', '6') : undefined}
icon={<IconGraphInverse size='1.25rem' className='icon-primary' />} icon={<IconGraphInverse size='1.25rem' className='icon-primary' />}
onClick={handleSelectInvert} onClick={handleSelectInvert}
/> />
@ -181,25 +197,29 @@ export function ToolbarGraphSelection({
<Dropdown isOpen={isGroupOpen} stretchLeft> <Dropdown isOpen={isGroupOpen} stretchLeft>
<DropdownButton <DropdownButton
text='ядро' text='ядро'
title='Выделить ядро' title={!tipHotkeys ? 'Выделить ядро' : undefined}
titleHtml={tipHotkeys ? prepareTooltip('Выделить ядро', 'Z') : undefined}
icon={<IconGraphCore size='1.25rem' className='icon-primary' />} icon={<IconGraphCore size='1.25rem' className='icon-primary' />}
onClick={handleSelectCore} onClick={handleSelectCore}
/> />
<DropdownButton <DropdownButton
text='ключевые' text='ключевые'
title='Выделить ключевые' title={!tipHotkeys ? 'Выделить ключевые' : undefined}
titleHtml={tipHotkeys ? prepareTooltip('Выделить ключевые', 'X') : undefined}
icon={<IconCrucial size='1.25rem' className='icon-primary' />} icon={<IconCrucial size='1.25rem' className='icon-primary' />}
onClick={handleSelectCrucial} onClick={handleSelectCrucial}
/> />
<DropdownButton <DropdownButton
text='собственные' text='собственные'
title='Выделить собственные' title={!tipHotkeys ? 'Выделить собственные' : undefined}
titleHtml={tipHotkeys ? prepareTooltip('Выделить собственные', 'C') : undefined}
icon={<IconPredecessor size='1.25rem' className='icon-primary' />} icon={<IconPredecessor size='1.25rem' className='icon-primary' />}
onClick={handleSelectOwned} onClick={handleSelectOwned}
/> />
<DropdownButton <DropdownButton
text='наследники' text='наследники'
title='Выделить наследников' title={!tipHotkeys ? 'Выделить наследников' : undefined}
titleHtml={tipHotkeys ? prepareTooltip('Выделить наследников', 'Y') : undefined}
icon={<IconChild size='1.25rem' className='icon-primary' />} icon={<IconChild size='1.25rem' className='icon-primary' />}
onClick={handleSelectInherited} onClick={handleSelectInherited}
/> />

View File

@ -5,6 +5,7 @@ import { Controller, useForm } from 'react-hook-form';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { Checkbox } from '@/components/input'; import { Checkbox } from '@/components/input';
import { ModalForm } from '@/components/modal'; import { ModalForm } from '@/components/modal';
import { prepareTooltip } from '@/utils/utils';
import { CstType } from '../backend/types'; import { CstType } from '../backend/types';
import { IconCstType } from '../components/icon-cst-type'; import { IconCstType } from '../components/icon-cst-type';
@ -35,19 +36,31 @@ export function DlgGraphParams() {
<Controller <Controller
control={control} control={control}
name='noText' name='noText'
render={({ field }) => <Checkbox {...field} label='Скрыть текст' title='Не отображать термины' />} render={({ field }) => (
<Checkbox {...field} label='Скрыть текст' titleHtml={prepareTooltip('Не отображать термины', 'T')} />
)}
/> />
<Controller <Controller
control={control} control={control}
name='foldDerived' name='foldDerived'
render={({ field }) => ( render={({ field }) => (
<Checkbox {...field} label='Скрыть порожденные' title='Не отображать порожденные понятия' /> <Checkbox
{...field}
label='Скрыть порожденные'
titleHtml={prepareTooltip('Не отображать порожденные понятия', 'V')}
/>
)} )}
/> />
<Controller <Controller
control={control} control={control}
name='noHermits' name='noHermits'
render={({ field }) => <Checkbox {...field} label='Скрыть несвязанные' title='Неиспользуемые конституенты' />} render={({ field }) => (
<Checkbox
{...field}
label='Скрыть несвязанные'
titleHtml={prepareTooltip('Неиспользуемые конституенты', 'B')}
/>
)}
/> />
<Controller <Controller
control={control} control={control}
@ -72,9 +85,9 @@ export function DlgGraphParams() {
)} )}
/> />
</div> </div>
<div className='flex flex-col gap-1'> <div className='flex flex-col items-center gap-1'>
<h1 className='mb-1'>Типы конституент</h1> <h1 className='mb-1'>Типы конституент</h1>
<div> <div className='grid grid-cols-3'>
{Object.values(CstType).map(cstType => { {Object.values(CstType).map(cstType => {
const fieldName = cstTypeToFilterKey[cstType]; const fieldName = cstTypeToFilterKey[cstType];
return ( return (

View File

@ -328,7 +328,8 @@ export function sortItemsForInlineSynthesis(receiver: IRSForm, items: readonly I
/** Remove alias from expression. */ /** Remove alias from expression. */
export function removeAliasReference(expression: string, alias: string): string { export function removeAliasReference(expression: string, alias: string): string {
return expression.replaceAll(new RegExp(`\\b${alias}\\b`, 'g'), 'DEL'); const result = expression.replaceAll(new RegExp(`\\b${alias}\\b`, 'g'), 'DEL');
return result === 'DEL' ? '' : result;
} }
/** Add alias to expression. */ /** Add alias to expression. */

View File

@ -148,7 +148,6 @@ export interface IRSForm extends ILibraryItemData {
stats: IRSFormStats; stats: IRSFormStats;
graph: Graph; graph: Graph;
attribution_graph: Graph; attribution_graph: Graph;
full_graph: Graph;
cstByAlias: Map<string, IConstituenta>; cstByAlias: Map<string, IConstituenta>;
cstByID: Map<number, IConstituenta>; cstByID: Map<number, IConstituenta>;
} }

View File

@ -15,13 +15,14 @@ import {
import clsx from 'clsx'; import clsx from 'clsx';
import { DiagramFlow, useReactFlow } from '@/components/flow/diagram-flow'; import { DiagramFlow, useReactFlow } from '@/components/flow/diagram-flow';
import { useContinuousPan } from '@/components/flow/use-continous-panning';
import { useWindowSize } from '@/hooks/use-window-size'; import { useWindowSize } from '@/hooks/use-window-size';
import { useFitHeight, useMainHeight } from '@/stores/app-layout'; import { useFitHeight, useMainHeight } from '@/stores/app-layout';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { errorMsg } from '@/utils/labels'; import { errorMsg } from '@/utils/labels';
import { withPreventDefault } from '@/utils/utils'; import { withPreventDefault } from '@/utils/utils';
import { ParsingStatus } from '../../../backend/types'; import { CstType, ParsingStatus } from '../../../backend/types';
import { useCreateAttribution } from '../../../backend/use-create-attribution'; import { useCreateAttribution } from '../../../backend/use-create-attribution';
import { useMutatingRSForm } from '../../../backend/use-mutating-rsform'; import { useMutatingRSForm } from '../../../backend/use-mutating-rsform';
import { useUpdateConstituenta } from '../../../backend/use-update-constituenta'; import { useUpdateConstituenta } from '../../../backend/use-update-constituenta';
@ -33,7 +34,7 @@ import { SelectColoring } from '../../../components/term-graph/select-coloring';
import { SelectEdgeType } from '../../../components/term-graph/select-edge-type'; import { SelectEdgeType } from '../../../components/term-graph/select-edge-type';
import { ViewHidden } from '../../../components/term-graph/view-hidden'; import { ViewHidden } from '../../../components/term-graph/view-hidden';
import { applyLayout, inferEdgeType, type TGNodeData } from '../../../models/graph-api'; import { applyLayout, inferEdgeType, type TGNodeData } from '../../../models/graph-api';
import { addAliasReference } from '../../../models/rsform-api'; import { addAliasReference, isBasicConcept } from '../../../models/rsform-api';
import { InteractionMode, TGEdgeType, useTermGraphStore, useTGConnectionStore } from '../../../stores/term-graph'; import { InteractionMode, TGEdgeType, useTermGraphStore, useTGConnectionStore } from '../../../stores/term-graph';
import { useRSEdit } from '../rsedit-context'; import { useRSEdit } from '../rsedit-context';
@ -55,6 +56,9 @@ export function TGFlow() {
const { isSmall } = useWindowSize(); const { isSmall } = useWindowSize();
const mainHeight = useMainHeight(); const mainHeight = useMainHeight();
const { fitView, viewportInitialized } = useReactFlow(); const { fitView, viewportInitialized } = useReactFlow();
const flowRef = useRef<HTMLDivElement>(null);
useContinuousPan(flowRef);
const mode = useTermGraphStore(state => state.mode); const mode = useTermGraphStore(state => state.mode);
const toggleMode = useTermGraphStore(state => state.toggleMode); const toggleMode = useTermGraphStore(state => state.toggleMode);
@ -62,6 +66,8 @@ export function TGFlow() {
const setConnectionStart = useTGConnectionStore(state => state.setStart); const setConnectionStart = useTGConnectionStore(state => state.setStart);
const connectionType = useTGConnectionStore(state => state.connectionType); const connectionType = useTGConnectionStore(state => state.connectionType);
const toggleText = useTermGraphStore(state => state.toggleText); const toggleText = useTermGraphStore(state => state.toggleText);
const toggleClustering = useTermGraphStore(state => state.toggleClustering);
const toggleHermits = useTermGraphStore(state => state.toggleHermits);
const { createAttribution } = useCreateAttribution(); const { createAttribution } = useCreateAttribution();
const { updateConstituenta } = useUpdateConstituenta(); const { updateConstituenta } = useUpdateConstituenta();
@ -79,7 +85,8 @@ export function TGFlow() {
navigateCst, navigateCst,
selectedEdges, selectedEdges,
setSelectedEdges, setSelectedEdges,
deselectAll deselectAll,
createCst
} = useRSEdit(); } = useRSEdit();
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]); const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
@ -90,14 +97,9 @@ export function TGFlow() {
const hiddenHeight = useFitHeight(isSmall ? '15rem + 2px' : '13.5rem + 2px', '4rem'); const hiddenHeight = useFitHeight(isSmall ? '15rem + 2px' : '13.5rem + 2px', '4rem');
function onSelectionChange({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) { function onSelectionChange({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) {
if (mode === InteractionMode.explore) { const ids = nodes.map(node => Number(node.id));
const ids = nodes.map(node => Number(node.id)); setSelectedCst(prev => [...prev.filter(nodeID => !filteredGraph.hasNode(nodeID)), ...ids]);
setSelectedCst(prev => [...prev.filter(nodeID => !filteredGraph.hasNode(nodeID)), ...ids]); setSelectedEdges(edges.map(edge => edge.id));
setSelectedEdges([]);
} else {
setSelectedCst([]);
setSelectedEdges(edges.map(edge => edge.id));
}
} }
useOnSelectionChange({ useOnSelectionChange({
onChange: onSelectionChange onChange: onSelectionChange
@ -294,14 +296,118 @@ export function TGFlow() {
deselectAll(); deselectAll();
} }
function handleCreateCst() {
const definition = selectedCst.map(id => schema.cstByID.get(id)!.alias).join(' ');
createCst(selectedCst.length === 0 ? CstType.BASE : CstType.TERM, false, definition);
}
function handleSelectCore() {
const isCore = (cstID: number) => {
const cst = schema.cstByID.get(cstID);
return !!cst && isBasicConcept(cst.cst_type);
};
const core = [...filteredGraph.nodes.keys()].filter(isCore);
setSelectedCst([...core, ...filteredGraph.expandInputs(core)]);
}
function handleSelectOwned() {
setSelectedCst([...filteredGraph.nodes.keys()].filter(cstID => !schema.cstByID.get(cstID)?.is_inherited));
}
function handleSelectInherited() {
setSelectedCst([...filteredGraph.nodes.keys()].filter(cstID => schema.cstByID.get(cstID)?.is_inherited ?? false));
}
function handleSelectCrucial() {
setSelectedCst([...filteredGraph.nodes.keys()].filter(cstID => schema.cstByID.get(cstID)?.crucial ?? false));
}
function handleExpandOutputs() {
setSelectedCst(prev => [...prev, ...filteredGraph.expandOutputs(prev)]);
}
function handleExpandInputs() {
setSelectedCst(prev => [...prev, ...filteredGraph.expandInputs(prev)]);
}
function handleSelectMaximize() {
setSelectedCst(prev => filteredGraph.maximizePart(prev));
}
function handleSelectInvert() {
setSelectedCst(prev => [...filteredGraph.nodes.keys()].filter(item => !prev.includes(item)));
}
function handleSelectAllInputs() {
setSelectedCst(prev => [...prev, ...filteredGraph.expandAllInputs(prev)]);
}
function handleSelectAllOutputs() {
setSelectedCst(prev => [...prev, ...filteredGraph.expandAllOutputs(prev)]);
}
function handleSelectionHotkey(eventCode: string): boolean {
if (eventCode === 'Escape') {
setFocus(null);
return true;
}
if (eventCode === 'Digit1') {
handleExpandInputs();
return true;
}
if (eventCode === 'Digit2') {
handleExpandOutputs();
return true;
}
if (eventCode === 'Digit3') {
handleSelectAllInputs();
return true;
}
if (eventCode === 'Digit4') {
handleSelectAllOutputs();
return true;
}
if (eventCode === 'Digit5') {
handleSelectMaximize();
return true;
}
if (eventCode === 'Digit6') {
handleSelectInvert();
return true;
}
if (eventCode === 'KeyZ') {
handleSelectCore();
return true;
}
if (eventCode === 'KeyX') {
handleSelectCrucial();
return true;
}
if (eventCode === 'KeyC') {
handleSelectOwned();
return true;
}
if (eventCode === 'KeyY') {
handleSelectInherited();
return true;
}
return false;
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (isProcessing) { if (isProcessing) {
return; return;
} }
if (event.code === 'Escape') { if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
withPreventDefault(() => setFocus(null))(event);
return; return;
} }
if (handleSelectionHotkey(event.code)) {
event.preventDefault();
event.stopPropagation();
return;
}
if (event.code === 'KeyG') { if (event.code === 'KeyG') {
withPreventDefault(() => fitView(flowOptions.fitViewOptions))(event); withPreventDefault(() => fitView(flowOptions.fitViewOptions))(event);
return; return;
@ -310,8 +416,16 @@ export function TGFlow() {
withPreventDefault(toggleText)(event); withPreventDefault(toggleText)(event);
return; return;
} }
if (event.code === 'KeyV') {
withPreventDefault(toggleClustering)(event);
return;
}
if (event.code === 'KeyB') {
withPreventDefault(toggleHermits)(event);
return;
}
if (isContentEditable && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) { if (isContentEditable) {
if (event.code === 'KeyQ') { if (event.code === 'KeyQ') {
withPreventDefault(handleToggleMode)(event); withPreventDefault(handleToggleMode)(event);
return; return;
@ -320,7 +434,11 @@ export function TGFlow() {
withPreventDefault(toggleEdgeType)(event); withPreventDefault(toggleEdgeType)(event);
return; return;
} }
if (event.code === 'Delete') { if (event.code === 'KeyR') {
withPreventDefault(handleCreateCst)(event);
return;
}
if (event.code === 'Delete' || event.code === 'Backquote') {
withPreventDefault(handleDeleteSelected)(event); withPreventDefault(handleDeleteSelected)(event);
return; return;
} }
@ -329,6 +447,7 @@ export function TGFlow() {
return ( return (
<div <div
ref={flowRef}
className={clsx('relative', mode === InteractionMode.explore ? 'mode-explore' : 'mode-edit')} className={clsx('relative', mode === InteractionMode.explore ? 'mode-explore' : 'mode-edit')}
tabIndex={-1} tabIndex={-1}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}

View File

@ -157,7 +157,7 @@ export function ToolbarTermGraph({ className, onDeleteSelected }: ToolbarTermGra
onClick={toggleText} onClick={toggleText}
/> />
<MiniButton <MiniButton
title={!filter.foldDerived ? 'Скрыть порожденные' : 'Отобразить порожденные'} titleHtml={prepareTooltip(!filter.foldDerived ? 'Скрыть порожденные' : 'Отобразить порожденные', 'V')}
icon={ icon={
!filter.foldDerived ? ( !filter.foldDerived ? (
<IconClustering size='1.25rem' className='icon-green' /> <IconClustering size='1.25rem' className='icon-green' />
@ -174,6 +174,7 @@ export function ToolbarTermGraph({ className, onDeleteSelected }: ToolbarTermGra
{focusCst ? <ToolbarFocusedCst resetFocus={() => setFocus(null)} /> : null} {focusCst ? <ToolbarFocusedCst resetFocus={() => setFocus(null)} /> : null}
{!focusCst && mode === InteractionMode.explore ? ( {!focusCst && mode === InteractionMode.explore ? (
<ToolbarGraphSelection <ToolbarGraphSelection
tipHotkeys
graph={filteredGraph} graph={filteredGraph}
isCore={cstID => { isCore={cstID => {
const cst = schema.cstByID.get(cstID); const cst = schema.cstByID.get(cstID);
@ -203,7 +204,7 @@ export function ToolbarTermGraph({ className, onDeleteSelected }: ToolbarTermGra
) : null} ) : null}
{isContentEditable ? ( {isContentEditable ? (
<MiniButton <MiniButton
title='Новая конституента' titleHtml={prepareTooltip('Новая конституента', 'R')}
icon={<IconNewItem size='1.25rem' className='icon-green' />} icon={<IconNewItem size='1.25rem' className='icon-green' />}
onClick={handleCreateCst} onClick={handleCreateCst}
disabled={isProcessing} disabled={isProcessing}
@ -211,7 +212,7 @@ export function ToolbarTermGraph({ className, onDeleteSelected }: ToolbarTermGra
) : null} ) : null}
{isContentEditable ? ( {isContentEditable ? (
<MiniButton <MiniButton
title='Удалить выбранные' titleHtml={prepareTooltip('Удалить выбранные', 'Delete, `')}
icon={<IconDestroy size='1.25rem' className='icon-red' />} icon={<IconDestroy size='1.25rem' className='icon-red' />}
onClick={onDeleteSelected} onClick={onDeleteSelected}
disabled={!canDeleteSelected || isProcessing} disabled={!canDeleteSelected || isProcessing}

View File

@ -26,7 +26,6 @@ import { useRSFormSuspense } from '../../backend/use-rsform';
import { useUpdateConstituenta } from '../../backend/use-update-constituenta'; import { useUpdateConstituenta } from '../../backend/use-update-constituenta';
import { type IConstituenta } from '../../models/rsform'; import { type IConstituenta } from '../../models/rsform';
import { generateAlias, removeAliasReference } from '../../models/rsform-api'; import { generateAlias, removeAliasReference } from '../../models/rsform-api';
import { InteractionMode, useTermGraphStore } from '../../stores/term-graph';
import { RSEditContext, RSTabID } from './rsedit-context'; import { RSEditContext, RSTabID } from './rsedit-context';
@ -58,13 +57,12 @@ export const RSEditState = ({
const isContentEditable = isMutable && !isArchive; const isContentEditable = isMutable && !isArchive;
const isAttachedToOSS = schema.oss.length > 0; const isAttachedToOSS = schema.oss.length > 0;
const isEditor = !!user.id && schema.editors.includes(user.id); const isEditor = !!user.id && schema.editors.includes(user.id);
const mode = useTermGraphStore(state => state.mode);
const [selectedCst, setSelectedCst] = useState<number[]>([]); const [selectedCst, setSelectedCst] = useState<number[]>([]);
const [selectedEdges, setSelectedEdges] = useState<string[]>([]); const [selectedEdges, setSelectedEdges] = useState<string[]>([]);
const canDeleteSelected = const canDeleteSelected =
(selectedCst.length > 0 && selectedCst.every(id => !schema.cstByID.get(id)?.is_inherited)) || (selectedCst.length > 0 && selectedCst.every(id => !schema.cstByID.get(id)?.is_inherited)) ||
(selectedEdges.length === 1 && mode === InteractionMode.edit); (selectedCst.length === 0 && selectedEdges.length === 1);
const [focusCst, setFocusCst] = useState<IConstituenta | null>(null); const [focusCst, setFocusCst] = useState<IConstituenta | null>(null);
const activeCst = selectedCst.length === 0 ? null : schema.cstByID.get(selectedCst[selectedCst.length - 1])!; const activeCst = selectedCst.length === 0 ? null : schema.cstByID.get(selectedCst[selectedCst.length - 1])!;
@ -268,9 +266,9 @@ export const RSEditState = ({
if (!canDeleteSelected) { if (!canDeleteSelected) {
return; return;
} }
if (mode === InteractionMode.explore || selectedEdges.length === 0) { if (selectedCst.length > 0) {
deleteSelectedCst(); deleteSelectedCst();
} else { } else if (selectedEdges.length === 1) {
deleteSelectedEdge(); deleteSelectedEdge();
} }
} }

View File

@ -72,6 +72,7 @@ interface TermGraphStore {
toggleText: () => void; toggleText: () => void;
toggleClustering: () => void; toggleClustering: () => void;
toggleGraphType: () => void; toggleGraphType: () => void;
toggleHermits: () => void;
foldHidden: boolean; foldHidden: boolean;
toggleFoldHidden: () => void; toggleFoldHidden: () => void;
@ -125,6 +126,7 @@ export const useTermGraphStore = create<TermGraphStore>()(
toggleFocusOutputs: () => toggleFocusOutputs: () =>
set(state => ({ filter: { ...state.filter, focusShowOutputs: !state.filter.focusShowOutputs } })), set(state => ({ filter: { ...state.filter, focusShowOutputs: !state.filter.focusShowOutputs } })),
toggleText: () => set(state => ({ filter: { ...state.filter, noText: !state.filter.noText } })), toggleText: () => set(state => ({ filter: { ...state.filter, noText: !state.filter.noText } })),
toggleHermits: () => set(state => ({ filter: { ...state.filter, noHermits: !state.filter.noHermits } })),
toggleClustering: () => set(state => ({ filter: { ...state.filter, foldDerived: !state.filter.foldDerived } })), toggleClustering: () => set(state => ({ filter: { ...state.filter, foldDerived: !state.filter.foldDerived } })),
toggleGraphType: () => toggleGraphType: () =>
set(state => ({ set(state => ({

View File

@ -84,11 +84,26 @@
cursor: inherit; cursor: inherit;
&.selected { &.selected {
filter: drop-shadow(0 0 4px var(--color-primary)); filter: drop-shadow(0 0 4px var(--color-primary)) drop-shadow(0 0 6px var(--color-primary));
.dark & {
filter: drop-shadow(0 0 4px var(--color-accent-orange-foreground))
drop-shadow(0 0 6px var(--color-accent-orange-foreground));
}
} }
.mode-edit & { .mode-space & {
cursor: pointer; pointer-events: none;
}
}
.rf-edge-events {
pointer-events: stroke;
stroke-width: 6;
stroke: transparent;
.mode-space & {
pointer-events: none;
} }
} }