Refactor constituenta selection and tooltip visibility

This commit is contained in:
IRBorisov 2024-02-03 15:33:28 +03:00
parent 5b3862d46c
commit 2b7a4c04ce
17 changed files with 268 additions and 305 deletions

View File

@ -1,19 +1,19 @@
import Overlay from '@/components/ui/Overlay';
interface SelectedCounterProps {
total: number;
selected: number;
totalCount: number;
selectedCount: number;
position?: string;
hideZero?: boolean;
}
function SelectedCounter({ total, selected, hideZero, position = 'top-0 left-0' }: SelectedCounterProps) {
if (selected === 0 && hideZero) {
function SelectedCounter({ totalCount, selectedCount, hideZero, position = 'top-0 left-0' }: SelectedCounterProps) {
if (selectedCount === 0 && hideZero) {
return null;
}
return (
<Overlay position={`px-2 ${position}`} className='select-none whitespace-nowrap clr-app'>
Выбор {selected} из {total}
Выбор {selectedCount} из {totalCount}
</Overlay>
);
}

View File

@ -9,6 +9,7 @@ interface ButtonProps extends CProps.Control, CProps.Colors, CProps.Button {
icon?: React.ReactNode;
dense?: boolean;
hideTitle?: boolean;
loading?: boolean;
}
@ -19,6 +20,7 @@ function Button({
loading,
dense,
disabled,
hideTitle,
noBorder,
noOutline,
colors = 'clr-btn-default',
@ -46,6 +48,7 @@ function Button({
)}
data-tooltip-id={title ? globalIDs.tooltip : undefined}
data-tooltip-content={title}
data-tooltip-hidden={hideTitle}
{...restProps}
>
{icon ? icon : null}

View File

@ -7,9 +7,10 @@ import { CProps } from '../props';
interface MiniButtonProps extends CProps.Button {
icon: React.ReactNode;
noHover?: boolean;
hideTitle?: boolean;
}
function MiniButton({ icon, noHover, tabIndex, title, className, ...restProps }: MiniButtonProps) {
function MiniButton({ icon, noHover, hideTitle, tabIndex, title, className, ...restProps }: MiniButtonProps) {
return (
<button
type='button'
@ -27,6 +28,7 @@ function MiniButton({ icon, noHover, tabIndex, title, className, ...restProps }:
)}
data-tooltip-id={title ? globalIDs.tooltip : undefined}
data-tooltip-content={title}
data-tooltip-hidden={hideTitle}
{...restProps}
>
{icon}

View File

@ -10,6 +10,7 @@ interface SelectorButtonProps extends CProps.Button {
colors?: string;
transparent?: boolean;
hideTitle?: boolean;
}
function SelectorButton({
@ -19,13 +20,12 @@ function SelectorButton({
colors = 'clr-btn-default',
className,
transparent,
hideTitle,
...restProps
}: SelectorButtonProps) {
return (
<button
type='button'
data-tooltip-id={title ? globalIDs.tooltip : undefined}
data-tooltip-content={title}
className={clsx(
'px-1 flex flex-start items-center gap-1',
'text-sm font-controls select-none',
@ -38,6 +38,9 @@ function SelectorButton({
className,
!transparent && colors
)}
data-tooltip-id={title ? globalIDs.tooltip : undefined}
data-tooltip-content={title}
data-tooltip-hidden={hideTitle}
{...restProps}
>
{icon ? icon : null}

View File

@ -47,6 +47,7 @@ function PickerStrategy({ value, onChange }: PickerStrategyProps) {
transparent
tabIndex={-1}
title='Список фильтров'
hideTitle={strategyMenu.isOpen}
className='h-full'
icon={<BiFilterAlt size='1.25rem' />}
text={labelLibraryFilter(value)}

View File

@ -1,12 +1,11 @@
'use client';
import { AnimatePresence } from 'framer-motion';
import { Dispatch, SetStateAction, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useRSForm } from '@/context/RSFormContext';
import useLocalStorage from '@/hooks/useLocalStorage';
import useWindowSize from '@/hooks/useWindowSize';
import { CstType, IConstituenta, ICstCreateData, ICstRenameData } from '@/models/rsform';
import { CstType, IConstituenta, IRSForm } from '@/models/rsform';
import { globalIDs } from '@/utils/constants';
import ViewConstituents from '../ViewConstituents';
@ -20,79 +19,43 @@ const UNFOLDED_HEIGHT = '59.1rem';
const SIDELIST_HIDE_THRESHOLD = 1100; // px
interface EditorConstituentaProps {
schema?: IRSForm;
isMutable: boolean;
activeID?: number;
activeCst?: IConstituenta | undefined;
activeCst?: IConstituenta;
isModified: boolean;
setIsModified: Dispatch<SetStateAction<boolean>>;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
onOpenEdit: (cstID: number) => void;
onCreateCst: (initial: ICstCreateData, skipDialog?: boolean) => void;
onRenameCst: (initial: ICstRenameData) => void;
onClone: () => void;
onCreate: (type?: CstType) => void;
onRename: () => void;
onEditTerm: () => void;
onDeleteCst: (selected: number[], callback?: (items: number[]) => void) => void;
onDelete: () => void;
}
function EditorConstituenta({
schema,
isMutable,
isModified,
setIsModified,
activeID,
activeCst,
onEditTerm,
onCreateCst,
onRenameCst,
onClone,
onCreate,
onRename,
onOpenEdit,
onDeleteCst
onDelete
}: EditorConstituentaProps) {
const windowSize = useWindowSize();
const { schema } = useRSForm();
const [showList, setShowList] = useLocalStorage('rseditor-show-list', true);
const [toggleReset, setToggleReset] = useState(false);
const disabled = useMemo(() => !activeCst || !isMutable, [activeCst, isMutable]);
function handleDelete() {
if (!schema || !activeID) {
return;
}
onDeleteCst([activeID]);
}
function handleCreate() {
if (!activeID || !schema) {
return;
}
const data: ICstCreateData = {
insert_after: activeID,
cst_type: activeCst?.cst_type ?? CstType.BASE,
alias: '',
term_raw: '',
definition_formal: '',
definition_raw: '',
convention: '',
term_forms: []
};
onCreateCst(data);
}
function handleClone() {
if (!activeID || !schema || !activeCst) {
return;
}
const data: ICstCreateData = {
insert_after: activeID,
cst_type: activeCst.cst_type,
alias: '',
term_raw: activeCst.term_raw,
definition_formal: activeCst.definition_formal,
definition_raw: activeCst.definition_raw,
convention: activeCst.convention,
term_forms: activeCst.term_forms
};
onCreateCst(data, true);
onCreate(activeCst?.cst_type);
}
function handleInput(event: React.KeyboardEvent<HTMLDivElement>) {
@ -125,7 +88,7 @@ function EditorConstituenta({
function processAltKey(code: string): boolean {
switch (code) {
case 'KeyV':
handleClone();
onClone();
return true;
}
return false;
@ -138,8 +101,8 @@ function EditorConstituenta({
isModified={isModified}
onSubmit={initiateSubmit}
onReset={() => setToggleReset(prev => !prev)}
onDelete={handleDelete}
onClone={handleClone}
onDelete={onDelete}
onClone={onClone}
onCreate={handleCreate}
/>
<div tabIndex={-1} className='flex max-w-[95rem]' onKeyDown={handleInput}>
@ -153,7 +116,7 @@ function EditorConstituenta({
onToggleList={() => setShowList(prev => !prev)}
setIsModified={setIsModified}
onEditTerm={onEditTerm}
onRenameCst={onRenameCst}
onRename={onRename}
/>
<AnimatePresence>
{showList && windowSize.width && windowSize.width >= SIDELIST_HIDE_THRESHOLD ? (
@ -161,7 +124,7 @@ function EditorConstituenta({
schema={schema}
expression={activeCst?.definition_formal ?? ''}
baseHeight={UNFOLDED_HEIGHT}
activeID={activeID}
activeID={activeCst?.id}
onOpenEdit={onOpenEdit}
/>
) : null}

View File

@ -1,7 +1,7 @@
'use client';
import clsx from 'clsx';
import { Dispatch, SetStateAction, useEffect, useLayoutEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import { FiSave } from 'react-icons/fi';
import { LiaEdit } from 'react-icons/lia';
import { toast } from 'react-toastify';
@ -12,7 +12,7 @@ import Overlay from '@/components/ui/Overlay';
import SubmitButton from '@/components/ui/SubmitButton';
import TextArea from '@/components/ui/TextArea';
import { useRSForm } from '@/context/RSFormContext';
import { IConstituenta, ICstRenameData, ICstUpdateData } from '@/models/rsform';
import { IConstituenta, ICstUpdateData } from '@/models/rsform';
import { classnames } from '@/utils/constants';
import { labelCstTypification } from '@/utils/labels';
@ -27,10 +27,10 @@ interface FormConstituentaProps {
isModified: boolean;
toggleReset: boolean;
setIsModified: Dispatch<SetStateAction<boolean>>;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
onToggleList: () => void;
onRenameCst: (initial: ICstRenameData) => void;
onRename: () => void;
onEditTerm: () => void;
}
@ -42,7 +42,7 @@ function FormConstituenta({
setIsModified,
constituenta,
toggleReset,
onRenameCst,
onRename,
onEditTerm,
onToggleList
}: FormConstituentaProps) {
@ -109,18 +109,6 @@ function FormConstituenta({
cstUpdate(data, () => toast.success('Изменения сохранены'));
}
function handleRename() {
if (!constituenta) {
return;
}
const data: ICstRenameData = {
id: constituenta.id,
alias: constituenta.alias,
cst_type: constituenta.cst_type
};
onRenameCst(data);
}
return (
<>
<Overlay position='top-1 left-[4.1rem]' className='flex select-none'>
@ -139,7 +127,7 @@ function FormConstituenta({
noHover
title='Переименовать конституенту'
disabled={disabled}
onClick={handleRename}
onClick={onRename}
icon={<LiaEdit size='1rem' className={!disabled ? 'clr-text-primary' : ''} />}
/>
</Overlay>

View File

@ -1,7 +1,5 @@
'use client';
import { Dispatch, SetStateAction } from 'react';
import InfoLibraryItem from '@/components/InfoLibraryItem';
import Divider from '@/components/ui/Divider';
import FlexColumn from '@/components/ui/FlexColumn';
@ -17,7 +15,7 @@ interface EditorRSFormProps {
isModified: boolean;
isMutable: boolean;
setIsModified: Dispatch<SetStateAction<boolean>>;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
onDestroy: () => void;
onClaim: () => void;
onShare: () => void;

View File

@ -1,7 +1,7 @@
'use client';
import clsx from 'clsx';
import { Dispatch, SetStateAction, useEffect, useLayoutEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import { FiSave } from 'react-icons/fi';
import { toast } from 'react-toastify';
@ -19,7 +19,7 @@ interface FormRSFormProps {
id?: string;
disabled: boolean;
isModified: boolean;
setIsModified: Dispatch<SetStateAction<boolean>>;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
}
function FormRSForm({ id, disabled, isModified, setIsModified }: FormRSFormProps) {

View File

@ -5,46 +5,59 @@ import { useLayoutEffect, useState } from 'react';
import { type RowSelectionState } from '@/components/DataTable';
import SelectedCounter from '@/components/SelectedCounter';
import { useRSForm } from '@/context/RSFormContext';
import { CstType, ICstCreateData, ICstMovetoData } from '@/models/rsform';
import { CstType, ICstMovetoData } from '@/models/rsform';
import RSListToolbar from './RSListToolbar';
import RSTable from './RSTable';
interface EditorRSListProps {
isMutable: boolean;
selected: number[];
setSelected: React.Dispatch<React.SetStateAction<number[]>>;
onOpenEdit: (cstID: number) => void;
onCreateCst: (initial: ICstCreateData, skipDialog?: boolean) => void;
onDeleteCst: (selected: number[], callback: (items: number[]) => void) => void;
onClone: () => void;
onCreate: (type?: CstType) => void;
onDelete: () => void;
}
function EditorRSList({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: EditorRSListProps) {
function EditorRSList({
selected,
setSelected,
isMutable,
onOpenEdit,
onClone,
onCreate,
onDelete
}: EditorRSListProps) {
const { schema, cstMoveTo } = useRSForm();
const [selected, setSelected] = useState<number[]>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
useLayoutEffect(() => {
if (!schema || Object.keys(rowSelection).length === 0) {
if (!schema || selected.length === 0) {
setRowSelection({});
} else {
const newRowSelection: RowSelectionState = {};
schema.items.forEach((cst, index) => {
newRowSelection[String(index)] = selected.includes(cst.id);
});
setRowSelection(newRowSelection);
}
}, [selected, schema]);
function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) {
if (!schema) {
setSelected([]);
} else {
const selected: number[] = [];
schema.items.forEach((cst, index) => {
if (rowSelection[String(index)] === true) {
selected.push(cst.id);
const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
const newSelection: number[] = [];
schema?.items.forEach((cst, index) => {
if (newRowSelection[String(index)] === true) {
newSelection.push(cst.id);
}
});
setSelected(selected);
setSelected(newSelection);
}
}, [rowSelection, schema]);
// Delete selected constituents
function handleDelete() {
if (!schema) {
return;
}
onDeleteCst(selected, () => {
setRowSelection({});
});
}
// Move selected cst up
@ -65,13 +78,7 @@ function EditorRSList({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Edito
items: selected,
move_to: target
};
cstMoveTo(data, () => {
const newSelection: RowSelectionState = {};
selected.forEach((_, index) => {
newSelection[String(target + index - 1)] = true;
});
setRowSelection(newSelection);
});
cstMoveTo(data);
}
// Move selected cst down
@ -96,57 +103,7 @@ function EditorRSList({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Edito
items: selected,
move_to: target
};
cstMoveTo(data, () => {
const newSelection: RowSelectionState = {};
selected.forEach((_, index) => {
newSelection[String(target + index - 1)] = true;
});
setRowSelection(newSelection);
});
}
function handleCreateCst(type?: CstType) {
if (!schema) {
return;
}
const selectedPosition = selected.reduce((prev, cstID) => {
const position = schema.items.findIndex(cst => cst.id === cstID);
return Math.max(position, prev);
}, -1);
const insert_where = selectedPosition >= 0 ? schema.items[selectedPosition].id : undefined;
const data: ICstCreateData = {
insert_after: insert_where ?? null,
cst_type: type ?? CstType.BASE,
alias: '',
term_raw: '',
definition_formal: '',
definition_raw: '',
convention: '',
term_forms: []
};
onCreateCst(data, type !== undefined);
}
// Clone selected
function handleClone() {
if (selected.length < 1 || !schema) {
return;
}
const activeCst = schema.items.find(cst => cst.id === selected[0]);
if (!activeCst) {
return;
}
const data: ICstCreateData = {
insert_after: activeCst.id,
cst_type: activeCst.cst_type,
alias: '',
term_raw: activeCst.term_raw,
definition_formal: activeCst.definition_formal,
definition_raw: activeCst.definition_raw,
convention: activeCst.convention,
term_forms: activeCst.term_forms
};
onCreateCst(data, true);
cstMoveTo(data);
}
// Implement hotkeys for working with constituents table
@ -156,7 +113,7 @@ function EditorRSList({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Edito
}
if (event.key === 'Delete' && selected.length > 0) {
event.preventDefault();
handleDelete();
onDelete();
return;
}
if (!event.altKey || event.shiftKey) {
@ -174,21 +131,21 @@ function EditorRSList({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Edito
switch (code) {
case 'ArrowUp': handleMoveUp(); return true;
case 'ArrowDown': handleMoveDown(); return true;
case 'KeyV': handleClone(); return true;
case 'KeyV': onClone(); return true;
}
}
// prettier-ignore
switch (code) {
case 'Backquote': handleCreateCst(); return true;
case 'Backquote': onCreate(); return true;
case 'Digit1': handleCreateCst(CstType.BASE); return true;
case 'Digit2': handleCreateCst(CstType.STRUCTURED); return true;
case 'Digit3': handleCreateCst(CstType.TERM); return true;
case 'Digit4': handleCreateCst(CstType.AXIOM); return true;
case 'KeyQ': handleCreateCst(CstType.FUNCTION); return true;
case 'KeyW': handleCreateCst(CstType.PREDICATE); return true;
case 'Digit5': handleCreateCst(CstType.CONSTANT); return true;
case 'Digit6': handleCreateCst(CstType.THEOREM); return true;
case 'Digit1': onCreate(CstType.BASE); return true;
case 'Digit2': onCreate(CstType.STRUCTURED); return true;
case 'Digit3': onCreate(CstType.TERM); return true;
case 'Digit4': onCreate(CstType.AXIOM); return true;
case 'KeyQ': onCreate(CstType.FUNCTION); return true;
case 'KeyW': onCreate(CstType.PREDICATE); return true;
case 'Digit5': onCreate(CstType.CONSTANT); return true;
case 'Digit6': onCreate(CstType.THEOREM); return true;
}
return false;
}
@ -196,8 +153,8 @@ function EditorRSList({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Edito
return (
<div tabIndex={-1} className='outline-none' onKeyDown={handleTableKey}>
<SelectedCounter
total={schema?.stats?.count_all ?? 0}
selected={selected.length}
totalCount={schema?.stats?.count_all ?? 0}
selectedCount={selected.length}
position='top-[0.3rem] left-2'
/>
@ -206,9 +163,9 @@ function EditorRSList({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Edito
isMutable={isMutable}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
onClone={handleClone}
onCreate={handleCreateCst}
onDelete={handleDelete}
onClone={onClone}
onCreate={onCreate}
onDelete={onDelete}
/>
<div className='pt-[2.3rem] border-b' />
@ -216,9 +173,9 @@ function EditorRSList({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Edito
<RSTable
items={schema?.items}
selected={rowSelection}
setSelected={setRowSelection}
setSelected={handleRowSelection}
onEdit={onOpenEdit}
onCreateNew={() => handleCreateCst()}
onCreateNew={onCreate}
/>
</div>
);

View File

@ -67,6 +67,7 @@ function RSListToolbar({
<div ref={insertMenu.ref}>
<MiniButton
title='Добавить пустую конституенту'
hideTitle={insertMenu.isOpen}
icon={<BiDownArrowCircle size='1.25rem' className={isMutable ? 'clr-text-success' : ''} />}
disabled={!isMutable}
onClick={insertMenu.toggle}

View File

@ -8,12 +8,11 @@ import { GraphEdge, GraphNode, LayoutTypes } from 'reagraph';
import InfoConstituenta from '@/components/InfoConstituenta';
import SelectedCounter from '@/components/SelectedCounter';
import Overlay from '@/components/ui/Overlay';
import { useRSForm } from '@/context/RSFormContext';
import { useConceptTheme } from '@/context/ThemeContext';
import DlgGraphParams from '@/dialogs/DlgGraphParams';
import useLocalStorage from '@/hooks/useLocalStorage';
import { GraphColoringScheme, GraphFilterParams } from '@/models/miscellaneous';
import { CstType, ICstCreateData } from '@/models/rsform';
import { CstType, IRSForm } from '@/models/rsform';
import { colorBgGraphNode } from '@/styling/color';
import { classnames, TIMEOUT_GRAPH_REFRESH } from '@/utils/constants';
@ -25,16 +24,25 @@ import ViewHidden from './ViewHidden';
interface EditorTermGraphProps {
isMutable: boolean;
selected: number[];
schema?: IRSForm;
setSelected: React.Dispatch<React.SetStateAction<number[]>>;
onOpenEdit: (cstID: number) => void;
onCreateCst: (initial: ICstCreateData, skipDialog?: boolean) => void;
onDeleteCst: (selected: number[], callback: (items: number[]) => void) => void;
onCreate: (type: CstType, definition: string) => void;
onDelete: () => void;
}
function EditorTermGraph({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGraphProps) {
const { schema } = useRSForm();
function EditorTermGraph({
schema,
selected,
setSelected,
isMutable,
onOpenEdit,
onCreate,
onDelete
}: EditorTermGraphProps) {
const { colors } = useConceptTheme();
const [toggleDataUpdate, setToggleDataUpdate] = useState(false);
const [filterParams, setFilterParams] = useLocalStorage<GraphFilterParams>('graph_filter', {
noHermits: true,
noTemplates: false,
@ -51,14 +59,10 @@ function EditorTermGraph({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Ed
allowTheorem: true
});
const [showParamsDialog, setShowParamsDialog] = useState(false);
const filtered = useGraphFilter(schema, filterParams, toggleDataUpdate);
const filtered = useGraphFilter(schema, filterParams);
const [selectedGraph, setSelectedGraph] = useState<number[]>([]);
const [hidden, setHidden] = useState<number[]>([]);
const [selectedHidden, setSelectedHidden] = useState<number[]>([]);
const selected: number[] = useMemo(() => {
return [...selectedHidden, ...selectedGraph];
}, [selectedHidden, selectedGraph]);
const nothingSelected = useMemo(() => selected.length === 0, [selected]);
const [layout, setLayout] = useLocalStorage<LayoutTypes>('graph_layout', 'treeTd2d');
@ -72,7 +76,6 @@ function EditorTermGraph({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Ed
}, [schema?.items, hoverID]);
const [toggleResetView, setToggleResetView] = useState(false);
const [toggleResetSelection, setToggleResetSelection] = useState(false);
useLayoutEffect(() => {
if (!schema) {
@ -85,9 +88,8 @@ function EditorTermGraph({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Ed
}
});
setHidden(newDismissed);
setSelectedHidden([]);
setHoverID(undefined);
}, [schema, filtered, toggleDataUpdate]);
}, [schema, filtered]);
const nodes: GraphNode[] = useMemo(() => {
const result: GraphNode[] = [];
@ -123,15 +125,20 @@ function EditorTermGraph({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Ed
return result;
}, [filtered.nodes]);
const handleGraphSelection = useCallback(
(newID: number) => {
setSelected(prev => [...prev, newID]);
},
[setSelected]
);
function toggleDismissed(cstID: number) {
setSelectedHidden(prev => {
const index = prev.findIndex(id => cstID === id);
if (index !== -1) {
prev.splice(index, 1);
setSelected(prev => {
if (prev.includes(cstID)) {
return [...prev.filter(id => id !== cstID)];
} else {
prev.push(cstID);
return [...prev, cstID];
}
return [...prev];
});
}
@ -139,29 +146,15 @@ function EditorTermGraph({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Ed
if (!schema) {
return;
}
const data: ICstCreateData = {
insert_after: null,
cst_type: selected.length === 0 ? CstType.BASE : CstType.TERM,
alias: '',
term_raw: '',
definition_formal: selected.map(id => schema.items.find(cst => cst.id === id)!.alias).join(' '),
definition_raw: '',
convention: '',
term_forms: []
};
onCreateCst(data);
const definition = selected.map(id => schema.items.find(cst => cst.id === id)!.alias).join(' ');
onCreate(selected.length === 0 ? CstType.BASE : CstType.TERM, definition);
}
function handleDeleteCst() {
if (!schema || selected.length === 0) {
return;
}
onDeleteCst(selected, () => {
setHidden([]);
setSelectedHidden([]);
setToggleResetSelection(prev => !prev);
setToggleDataUpdate(prev => !prev);
});
onDelete();
}
function handleChangeLayout(newLayout: LayoutTypes) {
@ -206,8 +199,8 @@ function EditorTermGraph({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Ed
<SelectedCounter
hideZero
total={schema?.stats?.count_all ?? 0}
selected={selected.length}
totalCount={schema?.stats?.count_all ?? 0}
selectedCount={selected.length}
position='top-[0.3rem] left-0'
/>
@ -249,7 +242,7 @@ function EditorTermGraph({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Ed
/>
<ViewHidden
items={hidden}
selected={selectedHidden}
selected={selected}
schema={schema!}
coloringScheme={coloringScheme}
toggleSelection={toggleDismissed}
@ -260,15 +253,15 @@ function EditorTermGraph({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Ed
<TermGraph
nodes={nodes}
edges={edges}
selectedIDs={selected}
layout={layout}
is3D={is3D}
orbit={orbit}
setSelected={setSelectedGraph}
onSelect={handleGraphSelection}
setHoverID={setHoverID}
onEdit={onOpenEdit}
onDeselect={() => setSelectedHidden([])}
onDeselectAll={() => setSelected([])}
toggleResetView={toggleResetView}
toggleResetSelection={toggleResetSelection}
/>
</div>
);

View File

@ -10,18 +10,18 @@ import { resources } from '@/utils/constants';
interface TermGraphProps {
nodes: GraphNode[];
edges: GraphEdge[];
selectedIDs: number[];
layout: LayoutTypes;
is3D: boolean;
orbit: boolean;
setSelected: (selected: number[]) => void;
setHoverID: (newID: number | undefined) => void;
onEdit: (cstID: number) => void;
onDeselect: () => void;
onSelect: (newID: number) => void;
onDeselectAll: () => void;
toggleResetView: boolean;
toggleResetSelection: boolean;
}
const TREE_SIZE_MILESTONE = 50;
@ -29,29 +29,28 @@ const TREE_SIZE_MILESTONE = 50;
function TermGraph({
nodes,
edges,
selectedIDs,
layout,
is3D,
orbit,
toggleResetView,
toggleResetSelection,
setHoverID,
onEdit,
setSelected,
onDeselect
onSelect,
onDeselectAll
}: TermGraphProps) {
const { noNavigation, darkMode } = useConceptTheme();
const graphRef = useRef<GraphCanvasRef | null>(null);
const { selections, actives, onNodeClick, clearSelections, onCanvasClick, onNodePointerOver, onNodePointerOut } =
useSelection({
ref: graphRef,
nodes,
edges,
type: 'multi', // 'single' | 'multi' | 'multiModifier'
pathSelectionType: 'out',
pathHoverType: 'all',
focusOnSelect: false
});
const { selections, actives, setSelections, onCanvasClick, onNodePointerOver, onNodePointerOut } = useSelection({
ref: graphRef,
nodes,
edges,
type: 'multi', // 'single' | 'multi' | 'multiModifier'
pathSelectionType: 'out',
pathHoverType: 'all',
focusOnSelect: false
});
const handleHoverIn = useCallback(
(node: GraphNode) => {
@ -73,19 +72,19 @@ function TermGraph({
(node: GraphNode) => {
if (selections.includes(node.id)) {
onEdit(Number(node.id));
return;
} else {
onSelect(Number(node.id));
}
if (onNodeClick) onNodeClick(node);
},
[onNodeClick, selections, onEdit]
[onSelect, selections, onEdit]
);
const handleCanvasClick = useCallback(
(event: MouseEvent) => {
onDeselect();
onDeselectAll();
if (onCanvasClick) onCanvasClick(event);
},
[onCanvasClick, onDeselect]
[onCanvasClick, onDeselectAll]
);
useLayoutEffect(() => {
@ -94,12 +93,9 @@ function TermGraph({
}, [toggleResetView]);
useLayoutEffect(() => {
clearSelections();
}, [toggleResetSelection, clearSelections]);
useLayoutEffect(() => {
setSelected(selections.map(id => Number(id)));
}, [selections, setSelected]);
const newSelections = nodes.filter(node => selectedIDs.includes(Number(node.id))).map(node => node.id);
setSelections(newSelections);
}, [selectedIDs, setSelections, nodes]);
const canvasWidth = useMemo(() => {
return 'calc(100vw - 1.1rem)';

View File

@ -1,10 +1,10 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { Graph } from '@/models/Graph';
import { GraphFilterParams } from '@/models/miscellaneous';
import { CstType, IRSForm } from '@/models/rsform';
import { Graph } from '@/models/Graph';
function useGraphFilter(schema: IRSForm | undefined, params: GraphFilterParams, toggleUpdate: boolean) {
function useGraphFilter(schema: IRSForm | undefined, params: GraphFilterParams) {
const [filtered, setFiltered] = useState<Graph>(new Graph());
const allowedTypes: CstType[] = useMemo(() => {
@ -47,7 +47,7 @@ function useGraphFilter(schema: IRSForm | undefined, params: GraphFilterParams,
});
}
setFiltered(graph);
}, [schema, params, allowedTypes, toggleUpdate]);
}, [schema, params, allowedTypes]);
return filtered;
}

View File

@ -28,7 +28,7 @@ import DlgRenameCst from '@/dialogs/DlgRenameCst';
import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm';
import useQueryStrings from '@/hooks/useQueryStrings';
import { UserAccessMode } from '@/models/miscellaneous';
import { IConstituenta, ICstCreateData, ICstRenameData, ICstUpdateData, TermForm } from '@/models/rsform';
import { CstType, IConstituenta, ICstCreateData, ICstRenameData, ICstUpdateData, TermForm } from '@/models/rsform';
import { generateAlias } from '@/models/rsformAPI';
import { EXTEOR_TRS_FILE, prefixes, TIMEOUT_UI_REFRESH } from '@/utils/constants';
@ -85,14 +85,18 @@ function RSTabs() {
);
}, [user?.is_staff, mode, isOwned, loading, processing]);
const [activeID, setActiveID] = useState<number | undefined>(undefined);
const activeCst = useMemo(() => schema?.items?.find(cst => cst.id === activeID), [schema?.items, activeID]);
const [selected, setSelected] = useState<number[]>([]);
const activeCst: IConstituenta | undefined = useMemo(() => {
if (!schema || selected.length === 0) {
return undefined;
} else {
return schema.items.find(cst => cst.id === selected.at(-1));
}
}, [schema, selected]);
const [showUpload, setShowUpload] = useState(false);
const [showClone, setShowClone] = useState(false);
const [afterDelete, setAfterDelete] = useState<((items: number[]) => void) | undefined>(undefined);
const [toBeDeleted, setToBeDeleted] = useState<number[]>([]);
const [showDeleteCst, setShowDeleteCst] = useState(false);
const [createInitialData, setCreateInitialData] = useState<ICstCreateData>();
@ -118,10 +122,19 @@ function RSTabs() {
useLayoutEffect(() => {
setNoFooter(activeTab === RSTabID.CST_EDIT || activeTab === RSTabID.CST_LIST);
setActiveID(Number(cstQuery) ?? (schema && schema?.items.length > 0 ? schema.items[0].id : undefined));
setIsModified(false);
if (activeTab === RSTabID.CST_EDIT) {
const cstID = Number(cstQuery);
if (cstID && schema && schema.items.find(cst => cst.id === cstID)) {
setSelected([cstID]);
} else if (schema && schema?.items.length > 0) {
setSelected([schema.items[0].id]);
} else {
setSelected([]);
}
}
return () => setNoFooter(false);
}, [activeTab, cstQuery, setActiveID, schema, setNoFooter, setIsModified]);
}, [activeTab, cstQuery, setSelected, schema, setNoFooter, setIsModified]);
useLayoutEffect(
() =>
@ -137,10 +150,6 @@ function RSTabs() {
[schema, setMode, isOwned]
);
function onSelectTab(index: number) {
navigateTab(index, activeID);
}
const navigateTab = useCallback(
(tab: RSTabID, activeID?: number) => {
if (!schema) {
@ -162,6 +171,10 @@ function RSTabs() {
[router, schema, activeTab]
);
function onSelectTab(index: number) {
navigateTab(index, selected.length > 0 ? selected.at(-1) : undefined);
}
const handleCreateCst = useCallback(
(data: ICstCreateData) => {
if (!schema?.items) {
@ -189,17 +202,44 @@ function RSTabs() {
);
const promptCreateCst = useCallback(
(initialData: ICstCreateData, skipDialog?: boolean) => {
(type: CstType | undefined, skipDialog: boolean, definition?: string) => {
const data: ICstCreateData = {
insert_after: activeCst?.id ?? null,
cst_type: type ?? activeCst?.cst_type ?? CstType.BASE,
alias: '',
term_raw: '',
definition_formal: definition ?? '',
definition_raw: '',
convention: '',
term_forms: []
};
if (skipDialog) {
handleCreateCst(initialData);
handleCreateCst(data);
} else {
setCreateInitialData(initialData);
setCreateInitialData(data);
setShowCreateCst(true);
}
},
[handleCreateCst]
[handleCreateCst, activeCst]
);
const handleCloneCst = useCallback(() => {
if (!activeCst) {
return;
}
const data: ICstCreateData = {
insert_after: activeCst.id,
cst_type: activeCst.cst_type,
alias: '',
term_raw: activeCst.term_raw,
definition_formal: activeCst.definition_formal,
definition_raw: activeCst.definition_raw,
convention: activeCst.convention,
term_forms: activeCst.term_forms
};
handleCreateCst(data);
}, [activeCst, handleCreateCst]);
const handleRenameCst = useCallback(
(data: ICstRenameData) => {
cstRename(data, () => toast.success(`Переименование: ${renameInitialData!.alias} -> ${data.alias}`));
@ -207,10 +247,18 @@ function RSTabs() {
[cstRename, renameInitialData]
);
const promptRenameCst = useCallback((initialData: ICstRenameData) => {
setRenameInitialData(initialData);
const promptRenameCst = useCallback(() => {
if (!activeCst) {
return;
}
const data: ICstRenameData = {
id: activeCst.id,
alias: activeCst.alias,
cst_type: activeCst.cst_type
};
setRenameInitialData(data);
setShowRenameCst(true);
}, []);
}, [activeCst]);
const onReindex = useCallback(() => resetAliases(() => toast.success('Имена конституент обновлены')), [resetAliases]);
@ -225,33 +273,26 @@ function RSTabs() {
const deletedNames = deleted.map(id => schema.items.find(cst => cst.id === id)?.alias).join(', ');
const isEmpty = deleted.length === schema.items.length;
const nextActive = isEmpty ? undefined : getNextActiveOnDelete(activeID, schema.items, deleted);
const nextActive = isEmpty ? undefined : getNextActiveOnDelete(activeCst?.id, schema.items, deleted);
cstDelete(data, () => {
toast.success(`Конституенты удалены: ${deletedNames}`);
if (isEmpty) {
navigateTab(RSTabID.CST_LIST);
} else if (!nextActive) {
navigateTab(activeTab);
} else {
} else if (activeTab === RSTabID.CST_EDIT) {
navigateTab(activeTab, nextActive);
} else {
setSelected(nextActive ? [nextActive] : []);
navigateTab(activeTab);
}
if (afterDelete) afterDelete(deleted);
});
},
[afterDelete, cstDelete, schema, activeID, activeTab, navigateTab]
[cstDelete, schema, activeTab, activeCst, navigateTab]
);
const promptDeleteCst = useCallback((selected: number[], callback?: (items: number[]) => void) => {
setAfterDelete(() => (items: number[]) => {
if (callback) callback(items);
});
setToBeDeleted(selected);
setShowDeleteCst(true);
}, []);
const onOpenCst = useCallback(
(cstID: number) => {
setSelected([cstID]);
navigateTab(RSTabID.CST_EDIT, cstID);
},
[navigateTab]
@ -334,16 +375,16 @@ function RSTabs() {
const handleSaveWordforms = useCallback(
(forms: TermForm[]) => {
if (!activeID) {
if (!activeCst) {
return;
}
const data: ICstUpdateData = {
id: activeID,
id: activeCst.id,
term_forms: forms
};
cstUpdate(data, () => toast.success('Изменения сохранены'));
},
[cstUpdate, activeID]
[cstUpdate, activeCst]
);
return (
@ -371,7 +412,7 @@ function RSTabs() {
schema={schema!}
hideWindow={() => setShowDeleteCst(false)}
onDelete={handleDeleteCst}
selected={toBeDeleted}
selected={selected}
/>
) : null}
{showEditTerm ? (
@ -438,34 +479,41 @@ function RSTabs() {
<TabPanel forceRender style={{ display: activeTab === RSTabID.CST_LIST ? '' : 'none' }}>
<EditorRSList
selected={selected}
setSelected={setSelected}
isMutable={isMutable}
onOpenEdit={onOpenCst}
onCreateCst={promptCreateCst}
onDeleteCst={promptDeleteCst}
onClone={handleCloneCst}
onCreate={type => promptCreateCst(type, type !== undefined)}
onDelete={() => setShowDeleteCst(true)}
/>
</TabPanel>
<TabPanel forceRender style={{ display: activeTab === RSTabID.CST_EDIT ? '' : 'none' }}>
<EditorConstituenta
schema={schema}
isMutable={isMutable}
isModified={isModified}
setIsModified={setIsModified}
activeID={activeID}
activeCst={activeCst}
onOpenEdit={onOpenCst}
onCreateCst={promptCreateCst}
onDeleteCst={promptDeleteCst}
onRenameCst={promptRenameCst}
onClone={handleCloneCst}
onCreate={type => promptCreateCst(type, false)}
onDelete={() => setShowDeleteCst(true)}
onRename={promptRenameCst}
onEditTerm={promptShowEditTerm}
/>
</TabPanel>
<TabPanel style={{ display: activeTab === RSTabID.TERM_GRAPH ? '' : 'none' }}>
<EditorTermGraph
schema={schema}
selected={selected}
setSelected={setSelected}
isMutable={isMutable}
onOpenEdit={onOpenCst}
onCreateCst={promptCreateCst}
onDeleteCst={promptDeleteCst}
onCreate={(type, definition) => promptCreateCst(type, false, definition)}
onDelete={() => setShowDeleteCst(true)}
/>
</TabPanel>
</AnimateFade>

View File

@ -118,6 +118,7 @@ function RSTabsMenu({
dense
tabIndex={-1}
title='Меню'
hideTitle={schemaMenu.isOpen}
icon={<BiMenu size='1.25rem' className='clr-text-controls' />}
className='h-full pl-2'
style={{ outlineColor: 'transparent' }}
@ -172,6 +173,7 @@ function RSTabsMenu({
noBorder
tabIndex={-1}
title={'Редактирование'}
hideTitle={editMenu.isOpen}
className='h-full'
style={{ outlineColor: 'transparent' }}
icon={<FiEdit size='1.25rem' className={isMutable ? 'clr-text-success' : 'clr-text-warning'} />}
@ -201,6 +203,7 @@ function RSTabsMenu({
noBorder
tabIndex={-1}
title={`Режим ${labelAccessMode(mode)}`}
hideTitle={accessMenu.isOpen}
className='h-full pr-2'
style={{ outlineColor: 'transparent' }}
icon={

View File

@ -82,6 +82,7 @@ function ConstituentsSearch({ schema, activeID, activeExpression, setFiltered }:
transparent
tabIndex={-1}
title='Настройка атрибутов для фильтрации'
hideTitle={matchModeMenu.isOpen}
className='h-full'
icon={<BiFilterAlt size='1.25rem' />}
text={labelCstMatchMode(filterMatch)}
@ -94,6 +95,7 @@ function ConstituentsSearch({ schema, activeID, activeExpression, setFiltered }:
const matchMode = value as CstMatchMode;
return (
<DropdownButton
className='w-[22rem]'
key={`${prefixes.cst_match_mode_list}${index}`}
onClick={() => handleMatchModeChange(matchMode)}
>
@ -111,6 +113,7 @@ function ConstituentsSearch({ schema, activeID, activeExpression, setFiltered }:
transparent
tabIndex={-1}
title='Настройка фильтрации по графу термов'
hideTitle={sourceMenu.isOpen}
className='h-full pr-2'
icon={<BiCog size='1.25rem' />}
text={labelCstSource(filterSource)}
@ -122,7 +125,11 @@ function ConstituentsSearch({ schema, activeID, activeExpression, setFiltered }:
.map((value, index) => {
const source = value as DependencyMode;
return (
<DropdownButton key={`${prefixes.cst_source_list}${index}`} onClick={() => handleSourceChange(source)}>
<DropdownButton
className='w-[23rem]'
key={`${prefixes.cst_source_list}${index}`}
onClick={() => handleSourceChange(source)}
>
<p>
<b>{labelCstSource(source)}:</b> {describeCstSource(source)}
</p>