Compare commits

...

8 Commits

Author SHA1 Message Date
Ivan
b03e2033eb M: Improve Typification UI
Some checks failed
Frontend CI / build (22.x) (push) Has been cancelled
2024-11-21 22:14:11 +03:00
Ivan
71659b8c15 F: Implement AST visualization using reactflow 2024-11-21 21:38:32 +03:00
Ivan
4172e387c2 D: Improve TSDocs for frontend components 2024-11-21 15:09:31 +03:00
Ivan
c7df031041 R: Decouple setters from onChange events 2024-11-21 00:26:04 +03:00
Ivan
e8c3106563 Update frontend.yml 2024-11-20 21:46:29 +03:00
Ivan
a1bfa8a119 R: Add frontend linter to script 2024-11-20 21:40:47 +03:00
Ivan
3f398bd700 npm update 2024-11-20 21:40:09 +03:00
Ivan
03e6bc55d9 F: Use dagre for graph node layout 2024-11-20 21:25:34 +03:00
90 changed files with 1063 additions and 675 deletions

View File

@ -38,6 +38,7 @@ jobs:
npm install -g typescript vite jest npm install -g typescript vite jest
npm ci npm ci
npm run build --if-present npm run build --if-present
- name: Test - name: Run CI
run: | run: |
npm run lint
npm test npm test

View File

@ -77,6 +77,8 @@
"csrftoken", "csrftoken",
"cstlist", "cstlist",
"csttype", "csttype",
"dagre",
"dagrejs",
"datv", "datv",
"Debool", "Debool",
"Decart", "Decart",
@ -91,6 +93,7 @@
"Geologica", "Geologica",
"Grammeme", "Grammeme",
"Grammemes", "Grammemes",
"graphlib",
"GRND", "GRND",
"IDEF", "IDEF",
"impr", "impr",
@ -108,6 +111,7 @@
"multiword", "multiword",
"mypy", "mypy",
"nocheck", "nocheck",
"nodesep",
"nomn", "nomn",
"nooverlap", "nooverlap",
"NPRO", "NPRO",
@ -127,6 +131,8 @@
"pylint", "pylint",
"pymorphy", "pymorphy",
"Quantor", "Quantor",
"rankdir",
"ranksep",
"razdel", "razdel",
"reactflow", "reactflow",
"reagraph", "reagraph",

View File

@ -50,6 +50,7 @@ This readme file is used mostly to document project dependencies and conventions
- @uiw/react-codemirror - @uiw/react-codemirror
- @uiw/codemirror-themes - @uiw/codemirror-themes
- @lezer/lr - @lezer/lr
- @dagrejs/dagre
</pre> </pre>
</details> </details>
<details> <details>

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.20.5",
"@uiw/codemirror-themes": "^4.23.6", "@uiw/codemirror-themes": "^4.23.6",
@ -26,7 +27,7 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.1.2",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"react-intl": "^6.8.7", "react-intl": "^6.8.9",
"react-loader-spinner": "^6.1.6", "react-loader-spinner": "^6.1.6",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"react-select": "^5.8.3", "react-select": "^5.8.3",
@ -41,14 +42,14 @@
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.7.1", "@lezer/generator": "^1.7.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.9.0", "@types/node": "^22.9.1",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1", "@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.12.0", "globals": "^15.12.0",
@ -57,7 +58,7 @@
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.15",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"typescript-eslint": "^8.14.0", "typescript-eslint": "^8.15.0",
"vite": "^5.4.11" "vite": "^5.4.11"
}, },
"jest": { "jest": {

View File

@ -24,7 +24,7 @@ function UserMenu() {
<AnimatePresence mode='wait'> <AnimatePresence mode='wait'>
{loading ? ( {loading ? (
<AnimateFade key='nav_user_badge_loader'> <AnimateFade key='nav_user_badge_loader'>
<Loader circular size={3} /> <Loader circular scale={1.5} />
</AnimateFade> </AnimateFade>
) : null} ) : null}
{!user && !loading ? ( {!user && !loading ? (

View File

@ -45,6 +45,7 @@ export interface DomIconProps<RequestData> extends IconProps {
value: RequestData; value: RequestData;
} }
/** Icon for library item type. */
export function ItemTypeIcon({ value, size = '1.25rem', className }: DomIconProps<LibraryItemType>) { export function ItemTypeIcon({ value, size = '1.25rem', className }: DomIconProps<LibraryItemType>) {
switch (value) { switch (value) {
case LibraryItemType.RSFORM: case LibraryItemType.RSFORM:
@ -54,6 +55,7 @@ export function ItemTypeIcon({ value, size = '1.25rem', className }: DomIconProp
} }
} }
/** Icon for access policy. */
export function PolicyIcon({ value, size = '1.25rem', className }: DomIconProps<AccessPolicy>) { export function PolicyIcon({ value, size = '1.25rem', className }: DomIconProps<AccessPolicy>) {
switch (value) { switch (value) {
case AccessPolicy.PRIVATE: case AccessPolicy.PRIVATE:
@ -65,6 +67,7 @@ export function PolicyIcon({ value, size = '1.25rem', className }: DomIconProps<
} }
} }
/** Icon for visibility. */
export function VisibilityIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) { export function VisibilityIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) { if (value) {
return <IconShow size={size} className={className ?? 'clr-text-green'} />; return <IconShow size={size} className={className ?? 'clr-text-green'} />;
@ -73,6 +76,7 @@ export function VisibilityIcon({ value, size = '1.25rem', className }: DomIconPr
} }
} }
/** Icon for subfolders. */
export function SubfoldersIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) { export function SubfoldersIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) { if (value) {
return <IconSubfolders size={size} className={className ?? 'clr-text-green'} />; return <IconSubfolders size={size} className={className ?? 'clr-text-green'} />;
@ -81,6 +85,7 @@ export function SubfoldersIcon({ value, size = '1.25rem', className }: DomIconPr
} }
} }
/** Icon for location. */
export function LocationIcon({ value, size = '1.25rem', className }: DomIconProps<string>) { export function LocationIcon({ value, size = '1.25rem', className }: DomIconProps<string>) {
switch (value.substring(0, 2) as LocationHead) { switch (value.substring(0, 2) as LocationHead) {
case LocationHead.COMMON: case LocationHead.COMMON:
@ -94,6 +99,7 @@ export function LocationIcon({ value, size = '1.25rem', className }: DomIconProp
} }
} }
/** Icon for term graph dependency mode. */
export function DependencyIcon({ value, size = '1.25rem', className }: DomIconProps<DependencyMode>) { export function DependencyIcon({ value, size = '1.25rem', className }: DomIconProps<DependencyMode>) {
switch (value) { switch (value) {
case DependencyMode.ALL: case DependencyMode.ALL:
@ -109,6 +115,7 @@ export function DependencyIcon({ value, size = '1.25rem', className }: DomIconPr
} }
} }
/** Icon for constituenta match mode. */
export function MatchModeIcon({ value, size = '1.25rem', className }: DomIconProps<CstMatchMode>) { export function MatchModeIcon({ value, size = '1.25rem', className }: DomIconProps<CstMatchMode>) {
switch (value) { switch (value) {
case CstMatchMode.ALL: case CstMatchMode.ALL:
@ -124,6 +131,7 @@ export function MatchModeIcon({ value, size = '1.25rem', className }: DomIconPro
} }
} }
/** Icon for expression status. */
export function StatusIcon({ value, size = '1.25rem', className }: DomIconProps<ExpressionStatus>) { export function StatusIcon({ value, size = '1.25rem', className }: DomIconProps<ExpressionStatus>) {
switch (value) { switch (value) {
case ExpressionStatus.VERIFIED: case ExpressionStatus.VERIFIED:
@ -141,6 +149,7 @@ export function StatusIcon({ value, size = '1.25rem', className }: DomIconProps<
} }
} }
/** Icon for constituenta type. */
export function CstTypeIcon({ value, size = '1.25rem', className }: DomIconProps<CstType>) { export function CstTypeIcon({ value, size = '1.25rem', className }: DomIconProps<CstType>) {
switch (value) { switch (value) {
case CstType.BASE: case CstType.BASE:
@ -162,6 +171,7 @@ export function CstTypeIcon({ value, size = '1.25rem', className }: DomIconProps
} }
} }
/** Icon for relocation direction. */
export function RelocateUpIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) { export function RelocateUpIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) { if (value) {
return <IconMoveUp size={size} className={className ?? 'clr-text-primary'} />; return <IconMoveUp size={size} className={className ?? 'clr-text-primary'} />;

View File

@ -7,11 +7,19 @@ import { CProps } from '../props';
import TooltipConstituenta from './TooltipConstituenta'; import TooltipConstituenta from './TooltipConstituenta';
interface BadgeConstituentaProps extends CProps.Styling { interface BadgeConstituentaProps extends CProps.Styling {
/** Prefix for tooltip ID. */
prefixID?: string; prefixID?: string;
/** Constituenta to display. */
value: IConstituenta; value: IConstituenta;
/** Color theme to use. */
theme: IColorTheme; theme: IColorTheme;
} }
/**
* Displays a badge with a constituenta alias and information tooltip.
*/
function BadgeConstituenta({ value, prefixID, className, style, theme }: BadgeConstituentaProps) { function BadgeConstituenta({ value, prefixID, className, style, theme }: BadgeConstituentaProps) {
return ( return (
<div <div

View File

@ -6,9 +6,13 @@ import { colorFgGrammeme } from '@/styling/color';
import { labelGrammeme } from '@/utils/labels'; import { labelGrammeme } from '@/utils/labels';
interface BadgeGrammemeProps { interface BadgeGrammemeProps {
/** Grammeme to display. */
grammeme: GramData; grammeme: GramData;
} }
/**
* Displays a badge with a grammeme tag.
*/
function BadgeGrammeme({ grammeme }: BadgeGrammemeProps) { function BadgeGrammeme({ grammeme }: BadgeGrammemeProps) {
const { colors } = useConceptOptions(); const { colors } = useConceptOptions();
return ( return (

View File

@ -8,12 +8,22 @@ import { IconHelp } from '../Icons';
import { CProps } from '../props'; import { CProps } from '../props';
interface BadgeHelpProps extends CProps.Styling { interface BadgeHelpProps extends CProps.Styling {
/** Topic to display in a tooltip. */
topic: HelpTopic; topic: HelpTopic;
/** Offset from the cursor to the tooltip. */
offset?: number; offset?: number;
/** Classname for padding. */
padding?: string; padding?: string;
/** Place of the tooltip in relation to the cursor. */
place?: PlacesType; place?: PlacesType;
} }
/**
* Display help icon with a manual page tooltip.
*/
function BadgeHelp({ topic, padding = 'p-1', ...restProps }: BadgeHelpProps) { function BadgeHelp({ topic, padding = 'p-1', ...restProps }: BadgeHelpProps) {
const { showHelp } = useConceptOptions(); const { showHelp } = useConceptOptions();

View File

@ -3,9 +3,13 @@ import { globals } from '@/utils/constants';
import { LocationIcon } from '../DomainIcons'; import { LocationIcon } from '../DomainIcons';
interface BadgeLocationProps { interface BadgeLocationProps {
/** Location to display. */
location: string; location: string;
} }
/**
* Displays location icon with a full text tooltip.
*/
function BadgeLocation({ location }: BadgeLocationProps) { function BadgeLocation({ location }: BadgeLocationProps) {
return ( return (
<div className='pl-2' data-tooltip-id={globals.tooltip} data-tooltip-content={location}> <div className='pl-2' data-tooltip-id={globals.tooltip} data-tooltip-content={location}>

View File

@ -3,10 +3,16 @@ import { IWordForm } from '@/models/language';
import BadgeGrammeme from './BadgeGrammeme'; import BadgeGrammeme from './BadgeGrammeme';
interface BadgeWordFormProps { interface BadgeWordFormProps {
keyPrefix?: string; /** Word form to display. */
form: IWordForm; form: IWordForm;
/** Prefix for grammemes keys. */
keyPrefix?: string;
} }
/**
* Displays a badge with grammemes of a word form.
*/
function BadgeWordForm({ keyPrefix, form }: BadgeWordFormProps) { function BadgeWordForm({ keyPrefix, form }: BadgeWordFormProps) {
return ( return (
<div className='flex flex-wrap justify-start gap-1 select-none w-fit'> <div className='flex flex-wrap justify-start gap-1 select-none w-fit'>

View File

@ -98,8 +98,8 @@ function PickConstituenta({
id={id ? `${id}__search` : undefined} id={id ? `${id}__search` : undefined}
className='clr-input rounded-t-md' className='clr-input rounded-t-md'
noBorder noBorder
value={filterText} query={filterText}
onChange={newValue => setFilterText(newValue)} onChangeQuery={newValue => setFilterText(newValue)}
/> />
<DataTable <DataTable
id={id} id={id}

View File

@ -132,8 +132,8 @@ function PickMultiConstituenta({
id='dlg_constituents_search' id='dlg_constituents_search'
noBorder noBorder
className='min-w-[6rem] pr-2 flex-grow' className='min-w-[6rem] pr-2 flex-grow'
value={filterText} query={filterText}
onChange={setFilterText} onChangeQuery={setFilterText}
/> />
<ToolbarGraphSelection <ToolbarGraphSelection
graph={foldedGraph} graph={foldedGraph}

View File

@ -129,8 +129,8 @@ function PickSchema({
id={id ? `${id}__search` : undefined} id={id ? `${id}__search` : undefined}
className='clr-input flex-grow rounded-t-md' className='clr-input flex-grow rounded-t-md'
noBorder noBorder
value={filterText} query={filterText}
onChange={newValue => setFilterText(newValue)} onChangeQuery={newValue => setFilterText(newValue)}
/> />
<div ref={locationMenu.ref}> <div ref={locationMenu.ref}>
<MiniButton <MiniButton

View File

@ -11,11 +11,11 @@ interface SelectMultiGrammemeProps
extends Omit<SelectMultiProps<IGrammemeOption>, 'value' | 'onChange'>, extends Omit<SelectMultiProps<IGrammemeOption>, 'value' | 'onChange'>,
CProps.Styling { CProps.Styling {
value: IGrammemeOption[]; value: IGrammemeOption[];
setValue: React.Dispatch<React.SetStateAction<IGrammemeOption[]>>; onChangeValue: (newValue: IGrammemeOption[]) => void;
placeholder?: string; placeholder?: string;
} }
function SelectMultiGrammeme({ value, setValue, ...restProps }: SelectMultiGrammemeProps) { function SelectMultiGrammeme({ value, onChangeValue, ...restProps }: SelectMultiGrammemeProps) {
const [options, setOptions] = useState<IGrammemeOption[]>([]); const [options, setOptions] = useState<IGrammemeOption[]>([]);
useEffect(() => { useEffect(() => {
@ -29,7 +29,7 @@ function SelectMultiGrammeme({ value, setValue, ...restProps }: SelectMultiGramm
<SelectMulti <SelectMulti
options={options} options={options}
value={value} value={value}
onChange={newValue => setValue([...newValue].sort(compareGrammemeOptions))} onChange={newValue => onChangeValue([...newValue].sort(compareGrammemeOptions))}
{...restProps} {...restProps}
/> />
); );

View File

@ -25,45 +25,84 @@ import TableHeader from './TableHeader';
export { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState }; export { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState };
/** Style to conditionally apply to rows. */
export interface IConditionalStyle<TData> { export interface IConditionalStyle<TData> {
/** Callback to determine if the style should be applied. */
when: (rowData: TData) => boolean; when: (rowData: TData) => boolean;
/** Style to apply. */
style: React.CSSProperties; style: React.CSSProperties;
} }
export interface DataTableProps<TData extends RowData> export interface DataTableProps<TData extends RowData>
extends CProps.Styling, extends CProps.Styling,
Pick<TableOptions<TData>, 'data' | 'columns' | 'onRowSelectionChange' | 'onColumnVisibilityChange'> { Pick<TableOptions<TData>, 'data' | 'columns' | 'onRowSelectionChange' | 'onColumnVisibilityChange'> {
/** Id of the component. */
id?: string; id?: string;
/** Indicates that padding should be minimal. */
dense?: boolean; dense?: boolean;
/** Number of rows to display. */
rows?: number; rows?: number;
/** Height of the content. */
contentHeight?: string; contentHeight?: string;
/** Top position of sticky header (0 if no other sticky elements are present). */
headPosition?: string; headPosition?: string;
/** Disable header. */
noHeader?: boolean; noHeader?: boolean;
/** Disable footer. */
noFooter?: boolean; noFooter?: boolean;
/** List of styles to conditionally apply to rows. */
conditionalRowStyles?: IConditionalStyle<TData>[]; conditionalRowStyles?: IConditionalStyle<TData>[];
/** Component to display when there is no data. */
noDataComponent?: React.ReactNode; noDataComponent?: React.ReactNode;
/** Callback to be called when a row is clicked. */
onRowClicked?: (rowData: TData, event: CProps.EventMouse) => void; onRowClicked?: (rowData: TData, event: CProps.EventMouse) => void;
/** Callback to be called when a row is double clicked. */
onRowDoubleClicked?: (rowData: TData, event: CProps.EventMouse) => void; onRowDoubleClicked?: (rowData: TData, event: CProps.EventMouse) => void;
/** Enable row selection. */
enableRowSelection?: boolean; enableRowSelection?: boolean;
/** Current row selection. */
rowSelection?: RowSelectionState; rowSelection?: RowSelectionState;
/** Enable hiding of columns. */
enableHiding?: boolean; enableHiding?: boolean;
/** Current column visibility. */
columnVisibility?: VisibilityState; columnVisibility?: VisibilityState;
/** Enable pagination. */
enablePagination?: boolean; enablePagination?: boolean;
/** Number of rows per page. */
paginationPerPage?: number; paginationPerPage?: number;
/** List of options to choose from for pagination. */
paginationOptions?: number[]; paginationOptions?: number[];
/** Callback to be called when the pagination option is changed. */
onChangePaginationOption?: (newValue: number) => void; onChangePaginationOption?: (newValue: number) => void;
/** Enable sorting. */
enableSorting?: boolean; enableSorting?: boolean;
/** Initial sorting. */
initialSorting?: ColumnSort; initialSorting?: ColumnSort;
} }
/** /**
* UI element: data representation as a table. * Dta representation as a table.
* *
* @param headPosition - Top position of sticky header (0 if no other sticky elements are present). * @param headPosition - Top position of sticky header (0 if no other sticky elements are present).
* No sticky header if omitted * No sticky header if omitted
@ -157,7 +196,7 @@ function DataTable<TData extends RowData>({
enableRowSelection={enableRowSelection} enableRowSelection={enableRowSelection}
enableSorting={enableSorting} enableSorting={enableSorting}
headPosition={headPosition} headPosition={headPosition}
setLastSelected={setLastSelected} resetLastSelected={() => setLastSelected(undefined)}
/> />
) : null} ) : null}
@ -168,7 +207,7 @@ function DataTable<TData extends RowData>({
conditionalRowStyles={conditionalRowStyles} conditionalRowStyles={conditionalRowStyles}
enableRowSelection={enableRowSelection} enableRowSelection={enableRowSelection}
lastSelected={lastSelected} lastSelected={lastSelected}
setLastSelected={setLastSelected} onChangeLastSelected={setLastSelected}
onRowClicked={onRowClicked} onRowClicked={onRowClicked}
onRowDoubleClicked={onRowDoubleClicked} onRowDoubleClicked={onRowDoubleClicked}
/> />

View File

@ -4,12 +4,12 @@ import CheckboxTristate from '@/components/ui/CheckboxTristate';
interface SelectAllProps<TData> { interface SelectAllProps<TData> {
table: Table<TData>; table: Table<TData>;
setLastSelected: React.Dispatch<React.SetStateAction<string | undefined>>; resetLastSelected: () => void;
} }
function SelectAll<TData>({ table, setLastSelected }: SelectAllProps<TData>) { function SelectAll<TData>({ table, resetLastSelected }: SelectAllProps<TData>) {
function handleChange(value: boolean | null) { function handleChange(value: boolean | null) {
setLastSelected(undefined); resetLastSelected();
table.toggleAllPageRowsSelected(value !== false); table.toggleAllPageRowsSelected(value !== false);
} }

View File

@ -4,12 +4,12 @@ import Checkbox from '@/components/ui/Checkbox';
interface SelectRowProps<TData> { interface SelectRowProps<TData> {
row: Row<TData>; row: Row<TData>;
setLastSelected: React.Dispatch<React.SetStateAction<string | undefined>>; onChangeLastSelected: (newValue: string | undefined) => void;
} }
function SelectRow<TData>({ row, setLastSelected }: SelectRowProps<TData>) { function SelectRow<TData>({ row, onChangeLastSelected }: SelectRowProps<TData>) {
function handleChange(value: boolean) { function handleChange(value: boolean) {
setLastSelected(row.id); onChangeLastSelected(row.id);
row.toggleSelected(value); row.toggleSelected(value);
} }

View File

@ -14,7 +14,7 @@ interface TableBodyProps<TData> {
conditionalRowStyles?: IConditionalStyle<TData>[]; conditionalRowStyles?: IConditionalStyle<TData>[];
lastSelected: string | undefined; lastSelected: string | undefined;
setLastSelected: React.Dispatch<React.SetStateAction<string | undefined>>; onChangeLastSelected: (newValue: string | undefined) => void;
onRowClicked?: (rowData: TData, event: CProps.EventMouse) => void; onRowClicked?: (rowData: TData, event: CProps.EventMouse) => void;
onRowDoubleClicked?: (rowData: TData, event: CProps.EventMouse) => void; onRowDoubleClicked?: (rowData: TData, event: CProps.EventMouse) => void;
@ -27,7 +27,7 @@ function TableBody<TData>({
enableRowSelection, enableRowSelection,
conditionalRowStyles, conditionalRowStyles,
lastSelected, lastSelected,
setLastSelected, onChangeLastSelected,
onRowClicked, onRowClicked,
onRowDoubleClicked onRowDoubleClicked
}: TableBodyProps<TData>) { }: TableBodyProps<TData>) {
@ -49,9 +49,9 @@ function TableBody<TData>({
newSelection[row.id] = !target.getIsSelected(); newSelection[row.id] = !target.getIsSelected();
}); });
table.setRowSelection(prev => ({ ...prev, ...newSelection })); table.setRowSelection(prev => ({ ...prev, ...newSelection }));
setLastSelected(undefined); onChangeLastSelected(undefined);
} else { } else {
setLastSelected(target.id); onChangeLastSelected(target.id);
target.toggleSelected(!target.getIsSelected()); target.toggleSelected(!target.getIsSelected());
} }
} }
@ -83,7 +83,7 @@ function TableBody<TData>({
> >
{enableRowSelection ? ( {enableRowSelection ? (
<td key={`select-${row.id}`} className='pl-3 pr-1 align-middle border-y'> <td key={`select-${row.id}`} className='pl-3 pr-1 align-middle border-y'>
<SelectRow row={row} setLastSelected={setLastSelected} /> <SelectRow row={row} onChangeLastSelected={onChangeLastSelected} />
</td> </td>
) : null} ) : null}
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => ( {row.getVisibleCells().map((cell: Cell<TData, unknown>) => (

View File

@ -8,7 +8,7 @@ interface TableHeaderProps<TData> {
headPosition?: string; headPosition?: string;
enableRowSelection?: boolean; enableRowSelection?: boolean;
enableSorting?: boolean; enableSorting?: boolean;
setLastSelected: React.Dispatch<React.SetStateAction<string | undefined>>; resetLastSelected: () => void;
} }
function TableHeader<TData>({ function TableHeader<TData>({
@ -16,7 +16,7 @@ function TableHeader<TData>({
headPosition, headPosition,
enableRowSelection, enableRowSelection,
enableSorting, enableSorting,
setLastSelected resetLastSelected
}: TableHeaderProps<TData>) { }: TableHeaderProps<TData>) {
return ( return (
<thead <thead
@ -30,7 +30,7 @@ function TableHeader<TData>({
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{enableRowSelection ? ( {enableRowSelection ? (
<th className='pl-3 pr-1 align-middle'> <th className='pl-3 pr-1 align-middle'>
<SelectAll table={table} setLastSelected={setLastSelected} /> <SelectAll table={table} resetLastSelected={resetLastSelected} />
</th> </th>
) : null} ) : null}
{headerGroup.headers.map((header: Header<TData, unknown>) => ( {headerGroup.headers.map((header: Header<TData, unknown>) => (

View File

@ -7,18 +7,24 @@ import { useConceptOptions } from '@/context/ConceptOptionsContext';
import AnimateFade from '../wrap/AnimateFade'; import AnimateFade from '../wrap/AnimateFade';
interface LoaderProps { interface LoaderProps {
size?: number; /** Scale of the loader from 1 to 10. */
scale?: number;
/** Show a circular loader. */
circular?: boolean; circular?: boolean;
} }
function Loader({ size = 10, circular }: LoaderProps) { /**
* Displays animated loader.
*/
function Loader({ scale = 5, circular }: LoaderProps) {
const { colors } = useConceptOptions(); const { colors } = useConceptOptions();
return ( return (
<AnimateFade noFadeIn className='flex justify-center'> <AnimateFade noFadeIn className='flex justify-center'>
{circular ? ( {circular ? (
<ThreeCircles color={colors.bgPrimary} height={size * 10} width={size * 10} /> <ThreeCircles color={colors.bgPrimary} height={scale * 20} width={scale * 20} />
) : ( ) : (
<ThreeDots color={colors.bgPrimary} height={size * 10} width={size * 10} radius={size} /> <ThreeDots color={colors.bgPrimary} height={scale * 20} width={scale * 20} radius={scale * 2} />
)} )}
</AnimateFade> </AnimateFade>
); );

View File

@ -5,11 +5,19 @@ import { globals } from '@/utils/constants';
import { CProps } from '../props'; import { CProps } from '../props';
interface MiniButtonProps extends CProps.Button { interface MiniButtonProps extends CProps.Button {
/** Icon to display in the button. */
icon: React.ReactNode; icon: React.ReactNode;
/** Disable hover effect. */
noHover?: boolean; noHover?: boolean;
/** Disable padding. */
noPadding?: boolean; noPadding?: boolean;
} }
/**
* Displays small transparent button with an icon.
*/
function MiniButton({ function MiniButton({
icon, icon,
noHover, noHover,

View File

@ -18,23 +18,46 @@ import MiniButton from './MiniButton';
import Overlay from './Overlay'; import Overlay from './Overlay';
export interface ModalProps extends CProps.Styling { export interface ModalProps extends CProps.Styling {
/** Title of the modal window. */
header?: string; header?: string;
/** Text of the submit button. */
submitText?: string; submitText?: string;
/** Tooltip for the submit button when the form is invalid. */
submitInvalidTooltip?: string; submitInvalidTooltip?: string;
/** Indicates that form is readonly. */
readonly?: boolean; readonly?: boolean;
/** Indicates that submit button is enabled. */
canSubmit?: boolean; canSubmit?: boolean;
/** Indicates that the modal window should be scrollable. */
overflowVisible?: boolean; overflowVisible?: boolean;
/** Callback to be called when the modal window is closed. */
hideWindow: () => void; hideWindow: () => void;
/** Callback to be called before submit. */
beforeSubmit?: () => boolean; beforeSubmit?: () => boolean;
/** Callback to be called after submit. */
onSubmit?: () => void; onSubmit?: () => void;
/** Callback to be called after cancel. */
onCancel?: () => void; onCancel?: () => void;
/** Help topic to be displayed in the modal window. */
helpTopic?: HelpTopic; helpTopic?: HelpTopic;
/** Callback to determine if help should be displayed. */
hideHelpWhen?: () => boolean; hideHelpWhen?: () => boolean;
} }
/**
* Displays a customizable modal window.
*/
function Modal({ function Modal({
children, children,

View File

@ -2,6 +2,9 @@ import clsx from 'clsx';
import { CProps } from '../props'; import { CProps } from '../props';
/**
* Wraps content in a div with a centered text.
*/
function NoData({ className, children, ...restProps }: CProps.Div) { function NoData({ className, children, ...restProps }: CProps.Div) {
return ( return (
<div className={clsx('p-3 flex flex-col items-center text-center select-none w-full', className)} {...restProps}> <div className={clsx('p-3 flex flex-col items-center text-center select-none w-full', className)} {...restProps}>

View File

@ -3,11 +3,19 @@ import clsx from 'clsx';
import { CProps } from '../props'; import { CProps } from '../props';
interface OverlayProps extends CProps.Styling { interface OverlayProps extends CProps.Styling {
/** Id of the overlay. */
id?: string; id?: string;
/** Classnames for position of the overlay. */
position?: string; position?: string;
/** Classname for z-index of the overlay. */
layer?: string; layer?: string;
} }
/**
* Displays a transparent overlay over the main content.
*/
function Overlay({ function Overlay({
children, children,
className, className,

View File

@ -5,15 +5,26 @@ import { useMemo } from 'react';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import useWindowSize from '@/hooks/useWindowSize'; import useWindowSize from '@/hooks/useWindowSize';
/** Maximum width of the viewer. */
const MAXIMUM_WIDTH = 1600; const MAXIMUM_WIDTH = 1600;
/** Minimum width of the viewer. */
const MINIMUM_WIDTH = 300; const MINIMUM_WIDTH = 300;
interface PDFViewerProps { interface PDFViewerProps {
/** PDF file to display. */
file?: string; file?: string;
/** Offset from the left side of the window. */
offsetXpx?: number; offsetXpx?: number;
/** Minimum width of the viewer. */
minWidth?: number; minWidth?: number;
} }
/**
* Displays a PDF file using an embedded viewer.
*/
function PDFViewer({ file, offsetXpx, minWidth = MINIMUM_WIDTH }: PDFViewerProps) { function PDFViewer({ file, offsetXpx, minWidth = MINIMUM_WIDTH }: PDFViewerProps) {
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const { calculateHeight } = useConceptOptions(); const { calculateHeight } = useConceptOptions();

View File

@ -2,6 +2,9 @@ interface PrettyJsonProps {
data: unknown; data: unknown;
} }
/**
* Displays JSON data in a formatted string.
*/
function PrettyJson({ data }: PrettyJsonProps) { function PrettyJson({ data }: PrettyJsonProps) {
return <pre>{JSON.stringify(data, null, 2)}</pre>; return <pre>{JSON.stringify(data, null, 2)}</pre>;
} }

View File

@ -6,15 +6,37 @@ import Overlay from './Overlay';
import TextInput from './TextInput'; import TextInput from './TextInput';
interface SearchBarProps extends CProps.Styling { interface SearchBarProps extends CProps.Styling {
value: string; /** Id of the search bar. */
noIcon?: boolean;
id?: string; id?: string;
/** Search query. */
query: string;
/** Placeholder text. */
placeholder?: string; placeholder?: string;
onChange?: (newValue: string) => void;
/** Callback to be called when the search query changes. */
onChangeQuery?: (newValue: string) => void;
/** Disable search icon. */
noIcon?: boolean;
/** Disable border. */
noBorder?: boolean; noBorder?: boolean;
} }
function SearchBar({ id, value, noIcon, onChange, noBorder, placeholder = 'Поиск', ...restProps }: SearchBarProps) { /**
* Displays a search bar with a search icon and text input.
*/
function SearchBar({
id,
query,
noIcon,
onChangeQuery,
noBorder,
placeholder = 'Поиск',
...restProps
}: SearchBarProps) {
return ( return (
<div {...restProps}> <div {...restProps}>
{!noIcon ? ( {!noIcon ? (
@ -29,8 +51,8 @@ function SearchBar({ id, value, noIcon, onChange, noBorder, placeholder = 'По
type='search' type='search'
className={clsx('outline-none bg-transparent', !noIcon && 'pl-10')} className={clsx('outline-none bg-transparent', !noIcon && 'pl-10')}
noBorder={noBorder} noBorder={noBorder}
value={value} value={query}
onChange={event => (onChange ? onChange(event.target.value) : undefined)} onChange={event => (onChangeQuery ? onChangeQuery(event.target.value) : undefined)}
/> />
</div> </div>
); );

View File

@ -45,6 +45,9 @@ export interface SelectMultiProps<Option, Group extends GroupBase<Option> = Grou
noPortal?: boolean; noPortal?: boolean;
} }
/**
* Displays a multi-select component.
*/
function SelectMulti<Option, Group extends GroupBase<Option> = GroupBase<Option>>({ function SelectMulti<Option, Group extends GroupBase<Option> = GroupBase<Option>>({
noPortal, noPortal,
...restProps ...restProps

View File

@ -46,6 +46,9 @@ export interface SelectSingleProps<Option, Group extends GroupBase<Option> = Gro
noBorder?: boolean; noBorder?: boolean;
} }
/**
* Displays a single-select component.
*/
function SelectSingle<Option, Group extends GroupBase<Option> = GroupBase<Option>>({ function SelectSingle<Option, Group extends GroupBase<Option> = GroupBase<Option>>({
noPortal, noPortal,
noBorder, noBorder,

View File

@ -11,22 +11,38 @@ import MiniButton from './MiniButton';
import Overlay from './Overlay'; import Overlay from './Overlay';
interface SelectTreeProps<ItemType> extends CProps.Styling { interface SelectTreeProps<ItemType> extends CProps.Styling {
items: ItemType[]; /** Current value. */
value: ItemType; value: ItemType;
setValue: (newItem: ItemType) => void;
getParent: (item: ItemType) => ItemType; /** List of available items. */
getLabel: (item: ItemType) => string; items: ItemType[];
getDescription: (item: ItemType) => string;
/** Prefix for the ids of the elements. */
prefix: string; prefix: string;
/** Callback to be called when the value changes. */
onChangeValue: (newItem: ItemType) => void;
/** Callback providing the parent of the item. */
getParent: (item: ItemType) => ItemType;
/** Callback providing the label of the item. */
getLabel: (item: ItemType) => string;
/** Callback providing the description of the item. */
getDescription: (item: ItemType) => string;
} }
/**
* Displays a tree of items and allows user to select one.
*/
function SelectTree<ItemType>({ function SelectTree<ItemType>({
items, items,
value, value,
getParent, getParent,
getLabel, getLabel,
getDescription, getDescription,
setValue, onChangeValue,
prefix, prefix,
...restProps ...restProps
}: SelectTreeProps<ItemType>) { }: SelectTreeProps<ItemType>) {
@ -71,9 +87,9 @@ function SelectTree<ItemType>({
(event: CProps.EventMouse, target: ItemType) => { (event: CProps.EventMouse, target: ItemType) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setValue(target); onChangeValue(target);
}, },
[setValue] [onChangeValue]
); );
return ( return (

View File

@ -5,13 +5,22 @@ import { globals } from '@/utils/constants';
import { CProps } from '../props'; import { CProps } from '../props';
interface SelectorButtonProps extends CProps.Button { interface SelectorButtonProps extends CProps.Button {
/** Text to display in the button. */
text?: string; text?: string;
/** Icon to display in the button. */
icon?: React.ReactNode; icon?: React.ReactNode;
/** Classnames for the colors of the button. */
colors?: string; colors?: string;
/** Indicates if button background should be transparent. */
transparent?: boolean; transparent?: boolean;
} }
/**
* Displays a button with an icon and text that opens a dropdown menu.
*/
function SelectorButton({ function SelectorButton({
text, text,
icon, icon,

View File

@ -3,11 +3,19 @@ import clsx from 'clsx';
import { CProps } from '../props'; import { CProps } from '../props';
interface SubmitButtonProps extends CProps.Button { interface SubmitButtonProps extends CProps.Button {
/** Text to display in the button. */
text?: string; text?: string;
loading?: boolean;
/** Icon to display in the button. */
icon?: React.ReactNode; icon?: React.ReactNode;
/** Indicates that loading is in progress. */
loading?: boolean;
} }
/**
* Displays submit type button with icon and text.
*/
function SubmitButton({ text = 'ОК', icon, disabled, loading, className, ...restProps }: SubmitButtonProps) { function SubmitButton({ text = 'ОК', icon, disabled, loading, className, ...restProps }: SubmitButtonProps) {
return ( return (
<button <button

View File

@ -7,9 +7,13 @@ import { globals } from '@/utils/constants';
import { CProps } from '../props'; import { CProps } from '../props';
interface TabLabelProps extends Omit<TabPropsImpl, 'children'>, CProps.Titled { interface TabLabelProps extends Omit<TabPropsImpl, 'children'>, CProps.Titled {
/** Label to display in the tab. */
label?: string; label?: string;
} }
/**
* Displays a tab header with a label.
*/
function TabLabel({ label, title, titleHtml, hideTitle, className, ...otherProps }: TabLabelProps) { function TabLabel({ label, title, titleHtml, hideTitle, className, ...otherProps }: TabLabelProps) {
return ( return (
<TabImpl <TabImpl

View File

@ -4,11 +4,19 @@ import { CProps } from '../props';
import Label from './Label'; import Label from './Label';
export interface TextAreaProps extends CProps.Editor, CProps.Colors, CProps.TextArea { export interface TextAreaProps extends CProps.Editor, CProps.Colors, CProps.TextArea {
/** Indicates that padding should be minimal. */
dense?: boolean; dense?: boolean;
/** Disable resize when content overflows. */
noResize?: boolean; noResize?: boolean;
/** Disable resize to fit content. */
fitContent?: boolean; fitContent?: boolean;
} }
/**
* Displays a customizable textarea with a label.
*/
function TextArea({ function TextArea({
id, id,
label, label,

View File

@ -6,11 +6,19 @@ import { truncateToLastWord } from '@/utils/utils';
import { CProps } from '../props'; import { CProps } from '../props';
export interface TextContentProps extends CProps.Styling { export interface TextContentProps extends CProps.Styling {
/** Text to display. */
text: string; text: string;
/** Maximum number of symbols to display. */
maxLength?: number; maxLength?: number;
/** Disable full text in a tooltip. */
noTooltip?: boolean; noTooltip?: boolean;
} }
/**
* Displays text limited to a certain number of symbols.
*/
function TextContent({ className, text, maxLength, noTooltip, ...restProps }: TextContentProps) { function TextContent({ className, text, maxLength, noTooltip, ...restProps }: TextContentProps) {
const truncated = maxLength ? truncateToLastWord(text, maxLength) : text; const truncated = maxLength ? truncateToLastWord(text, maxLength) : text;
const isTruncated = maxLength && text.length > maxLength; const isTruncated = maxLength && text.length > maxLength;

View File

@ -4,7 +4,10 @@ import { CProps } from '../props';
import Label from './Label'; import Label from './Label';
interface TextInputProps extends CProps.Editor, CProps.Colors, CProps.Input { interface TextInputProps extends CProps.Editor, CProps.Colors, CProps.Input {
/** Indicates that padding should be minimal. */
dense?: boolean; dense?: boolean;
/** Capture enter key. */
allowEnter?: boolean; allowEnter?: boolean;
} }
@ -14,6 +17,9 @@ function preventEnterCapture(event: React.KeyboardEvent<HTMLInputElement>) {
} }
} }
/**
* Displays a customizable input with a label.
*/
function TextInput({ function TextInput({
id, id,
label, label,

View File

@ -1,13 +1,25 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
interface TextURLProps { interface TextURLProps {
/** Text to display. */
text: string; text: string;
/** Tooltip for the link. */
title?: string; title?: string;
/** URL to link to. */
href?: string; href?: string;
/** Color of the link. */
color?: string; color?: string;
/** Callback to be called when the link is clicked. */
onClick?: () => void; onClick?: () => void;
} }
/**
* Displays a text with a clickable link.
*/
function TextURL({ text, href, title, color = 'clr-text-url', onClick }: TextURLProps) { function TextURL({ text, href, title, color = 'clr-text-url', onClick }: TextURLProps) {
const design = `cursor-pointer hover:underline ${color}`; const design = `cursor-pointer hover:underline ${color}`;
if (href) { if (href) {

View File

@ -10,10 +10,16 @@ import { useConceptOptions } from '@/context/ConceptOptionsContext';
export type { PlacesType } from 'react-tooltip'; export type { PlacesType } from 'react-tooltip';
interface TooltipProps extends Omit<ITooltip, 'variant'> { interface TooltipProps extends Omit<ITooltip, 'variant'> {
layer?: string; /** Text to display in the tooltip. */
text?: string; text?: string;
/** Classname for z-index */
layer?: string;
} }
/**
* Displays content in a tooltip container.
*/
function Tooltip({ function Tooltip({
text, text,
children, children,

View File

@ -7,16 +7,34 @@ import { CProps } from '../props';
import MiniButton from './MiniButton'; import MiniButton from './MiniButton';
interface ValueIconProps extends CProps.Styling, CProps.Titled { interface ValueIconProps extends CProps.Styling, CProps.Titled {
/** Id of the component. */
id?: string; id?: string;
icon: React.ReactNode;
/** Value to display. */
value: string | number; value: string | number;
/** Icon to display. */
icon: React.ReactNode;
/** Classname for the text. */
textClassName?: string; textClassName?: string;
/** Callback to be called when the component is clicked. */
onClick?: (event: CProps.EventMouse) => void; onClick?: (event: CProps.EventMouse) => void;
/** Number of symbols to display in a small size. */
smallThreshold?: number; smallThreshold?: number;
/** Indicates that padding should be minimal. */
dense?: boolean; dense?: boolean;
/** Disable interaction. */
disabled?: boolean; disabled?: boolean;
} }
/**
* Displays a value with an icon that can be clicked.
*/
function ValueIcon({ function ValueIcon({
id, id,
dense, dense,

View File

@ -3,12 +3,22 @@ import clsx from 'clsx';
import { CProps } from '../props'; import { CProps } from '../props';
interface ValueLabeledProps extends CProps.Styling { interface ValueLabeledProps extends CProps.Styling {
/** Id of the component. */
id?: string; id?: string;
/** Label to display. */
label: string; label: string;
/** Value to display. */
text: string | number; text: string | number;
/** Tooltip for the component. */
title?: string; title?: string;
} }
/**
* Displays a labeled value.
*/
function ValueLabeled({ id, label, text, title, className, ...restProps }: ValueLabeledProps) { function ValueLabeled({ id, label, text, title, className, ...restProps }: ValueLabeledProps) {
return ( return (
<div className={clsx('flex justify-between gap-6', className)} {...restProps}> <div className={clsx('flex justify-between gap-6', className)} {...restProps}>

View File

@ -4,11 +4,19 @@ import { CProps } from '../props';
import ValueIcon from './ValueIcon'; import ValueIcon from './ValueIcon';
interface ValueStatsProps extends CProps.Styling, CProps.Titled { interface ValueStatsProps extends CProps.Styling, CProps.Titled {
/** Id of the component. */
id: string; id: string;
/** Icon to display. */
icon: React.ReactNode; icon: React.ReactNode;
/** Value to display. */
value: string | number; value: string | number;
} }
/**
* Displays statistics value with an icon.
*/
function ValueStats(props: ValueStatsProps) { function ValueStats(props: ValueStatsProps) {
return <ValueIcon dense smallThreshold={PARAMETER.statSmallThreshold} textClassName='min-w-[1.4rem]' {...props} />; return <ValueIcon dense smallThreshold={PARAMETER.statSmallThreshold} textClassName='min-w-[1.4rem]' {...props} />;
} }

View File

@ -159,7 +159,7 @@ export const OptionsState = ({ children }: React.PropsWithChildren) => {
id={`${globals.tooltip}`} id={`${globals.tooltip}`}
layer='z-topmost' layer='z-topmost'
place='right-start' place='right-start'
className='mt-8 max-w-[20rem]' className='mt-8 max-w-[20rem] break-words'
/> />
<Tooltip <Tooltip
float float

View File

@ -103,15 +103,15 @@ function DlgCreateOperation({ hideWindow, oss, onCreate, initialInputs }: DlgCre
<TabInputOperation <TabInputOperation
oss={oss} oss={oss}
alias={alias} alias={alias}
setAlias={setAlias} onChangeAlias={setAlias}
comment={comment} comment={comment}
setComment={setComment} onChangeComment={setComment}
title={title} title={title}
setTitle={setTitle} onChangeTitle={setTitle}
attachedID={attachedID} attachedID={attachedID}
setAttachedID={setAttachedID} onChangeAttachedID={setAttachedID}
createSchema={createSchema} createSchema={createSchema}
setCreateSchema={setCreateSchema} onChangeCreateSchema={setCreateSchema}
/> />
</TabPanel> </TabPanel>
), ),
@ -124,11 +124,11 @@ function DlgCreateOperation({ hideWindow, oss, onCreate, initialInputs }: DlgCre
<TabSynthesisOperation <TabSynthesisOperation
oss={oss} oss={oss}
alias={alias} alias={alias}
setAlias={setAlias} onChangeAlias={setAlias}
comment={comment} comment={comment}
setComment={setComment} onChangeComment={setComment}
title={title} title={title}
setTitle={setTitle} onChangeTitle={setTitle}
inputs={inputs} inputs={inputs}
setInputs={setInputs} setInputs={setInputs}
/> />

View File

@ -18,29 +18,29 @@ import { sortItemsForOSS } from '@/models/ossAPI';
interface TabInputOperationProps { interface TabInputOperationProps {
oss: IOperationSchema; oss: IOperationSchema;
alias: string; alias: string;
setAlias: React.Dispatch<React.SetStateAction<string>>; onChangeAlias: (newValue: string) => void;
title: string; title: string;
setTitle: React.Dispatch<React.SetStateAction<string>>; onChangeTitle: (newValue: string) => void;
comment: string; comment: string;
setComment: React.Dispatch<React.SetStateAction<string>>; onChangeComment: (newValue: string) => void;
attachedID: LibraryItemID | undefined; attachedID: LibraryItemID | undefined;
setAttachedID: React.Dispatch<React.SetStateAction<LibraryItemID | undefined>>; onChangeAttachedID: (newValue: LibraryItemID | undefined) => void;
createSchema: boolean; createSchema: boolean;
setCreateSchema: React.Dispatch<React.SetStateAction<boolean>>; onChangeCreateSchema: (newValue: boolean) => void;
} }
function TabInputOperation({ function TabInputOperation({
oss, oss,
alias, alias,
setAlias, onChangeAlias,
title, title,
setTitle, onChangeTitle,
comment, comment,
setComment, onChangeComment,
attachedID, attachedID,
setAttachedID, onChangeAttachedID,
createSchema, createSchema,
setCreateSchema onChangeCreateSchema
}: TabInputOperationProps) { }: TabInputOperationProps) {
const baseFilter = useCallback((item: ILibraryItem) => !oss.schemas.includes(item.id), [oss]); const baseFilter = useCallback((item: ILibraryItem) => !oss.schemas.includes(item.id), [oss]);
const library = useLibrary(); const library = useLibrary();
@ -48,9 +48,9 @@ function TabInputOperation({
useEffect(() => { useEffect(() => {
if (createSchema) { if (createSchema) {
setAttachedID(undefined); onChangeAttachedID(undefined);
} }
}, [createSchema, setAttachedID]); }, [createSchema, onChangeAttachedID]);
return ( return (
<AnimateFade className='cc-column'> <AnimateFade className='cc-column'>
@ -58,7 +58,7 @@ function TabInputOperation({
id='operation_title' id='operation_title'
label='Полное название' label='Полное название'
value={title} value={title}
onChange={event => setTitle(event.target.value)} onChange={event => onChangeTitle(event.target.value)}
disabled={attachedID !== undefined} disabled={attachedID !== undefined}
/> />
<div className='flex gap-6'> <div className='flex gap-6'>
@ -67,7 +67,7 @@ function TabInputOperation({
label='Сокращение' label='Сокращение'
className='w-[16rem]' className='w-[16rem]'
value={alias} value={alias}
onChange={event => setAlias(event.target.value)} onChange={event => onChangeAlias(event.target.value)}
disabled={attachedID !== undefined} disabled={attachedID !== undefined}
/> />
@ -77,7 +77,7 @@ function TabInputOperation({
noResize noResize
rows={3} rows={3}
value={comment} value={comment}
onChange={event => setComment(event.target.value)} onChange={event => onChangeComment(event.target.value)}
disabled={attachedID !== undefined} disabled={attachedID !== undefined}
/> />
</div> </div>
@ -90,13 +90,13 @@ function TabInputOperation({
noHover noHover
noPadding noPadding
icon={<IconReset size='1.25rem' className='icon-primary' />} icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => setAttachedID(undefined)} onClick={() => onChangeAttachedID(undefined)}
disabled={attachedID == undefined} disabled={attachedID == undefined}
/> />
</div> </div>
<Checkbox <Checkbox
value={createSchema} value={createSchema}
setValue={setCreateSchema} setValue={onChangeCreateSchema}
label='Создать новую схему' label='Создать новую схему'
titleHtml='Создать пустую схему для загрузки' titleHtml='Создать пустую схему для загрузки'
/> />
@ -106,7 +106,7 @@ function TabInputOperation({
items={sortedItems} items={sortedItems}
value={attachedID} value={attachedID}
itemType={LibraryItemType.RSFORM} itemType={LibraryItemType.RSFORM}
onSelectValue={setAttachedID} onSelectValue={onChangeAttachedID}
rows={8} rows={8}
baseFilter={baseFilter} baseFilter={baseFilter}
/> />

View File

@ -10,11 +10,11 @@ import PickMultiOperation from '../../components/select/PickMultiOperation';
interface TabSynthesisOperationProps { interface TabSynthesisOperationProps {
oss: IOperationSchema; oss: IOperationSchema;
alias: string; alias: string;
setAlias: React.Dispatch<React.SetStateAction<string>>; onChangeAlias: (newValue: string) => void;
title: string; title: string;
setTitle: React.Dispatch<React.SetStateAction<string>>; onChangeTitle: (newValue: string) => void;
comment: string; comment: string;
setComment: React.Dispatch<React.SetStateAction<string>>; onChangeComment: (newValue: string) => void;
inputs: OperationID[]; inputs: OperationID[];
setInputs: React.Dispatch<React.SetStateAction<OperationID[]>>; setInputs: React.Dispatch<React.SetStateAction<OperationID[]>>;
} }
@ -22,11 +22,11 @@ interface TabSynthesisOperationProps {
function TabSynthesisOperation({ function TabSynthesisOperation({
oss, oss,
alias, alias,
setAlias, onChangeAlias,
title, title,
setTitle, onChangeTitle,
comment, comment,
setComment, onChangeComment,
inputs, inputs,
setInputs setInputs
}: TabSynthesisOperationProps) { }: TabSynthesisOperationProps) {
@ -36,7 +36,7 @@ function TabSynthesisOperation({
id='operation_title' id='operation_title'
label='Полное название' label='Полное название'
value={title} value={title}
onChange={event => setTitle(event.target.value)} onChange={event => onChangeTitle(event.target.value)}
/> />
<div className='flex gap-6'> <div className='flex gap-6'>
<TextInput <TextInput
@ -44,7 +44,7 @@ function TabSynthesisOperation({
label='Сокращение' label='Сокращение'
className='w-[16rem]' className='w-[16rem]'
value={alias} value={alias}
onChange={event => setAlias(event.target.value)} onChange={event => onChangeAlias(event.target.value)}
/> />
<TextArea <TextArea
@ -53,7 +53,7 @@ function TabSynthesisOperation({
noResize noResize
rows={3} rows={3}
value={comment} value={comment}
onChange={event => setComment(event.target.value)} onChange={event => onChangeComment(event.target.value)}
/> />
</div> </div>

View File

@ -141,11 +141,11 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
<TabPanel> <TabPanel>
<TabOperation <TabOperation
alias={alias} alias={alias}
setAlias={setAlias} onChangeAlias={setAlias}
comment={comment} comment={comment}
setComment={setComment} onChangeComment={setComment}
title={title} title={title}
setTitle={setTitle} onChangeTitle={setTitle}
/> />
</TabPanel> </TabPanel>
), ),

View File

@ -4,21 +4,21 @@ import AnimateFade from '@/components/wrap/AnimateFade';
interface TabOperationProps { interface TabOperationProps {
alias: string; alias: string;
setAlias: React.Dispatch<React.SetStateAction<string>>; onChangeAlias: (newValue: string) => void;
title: string; title: string;
setTitle: React.Dispatch<React.SetStateAction<string>>; onChangeTitle: (newValue: string) => void;
comment: string; comment: string;
setComment: React.Dispatch<React.SetStateAction<string>>; onChangeComment: (newValue: string) => void;
} }
function TabOperation({ alias, setAlias, title, setTitle, comment, setComment }: TabOperationProps) { function TabOperation({ alias, onChangeAlias, title, onChangeTitle, comment, onChangeComment }: TabOperationProps) {
return ( return (
<AnimateFade className='cc-column'> <AnimateFade className='cc-column'>
<TextInput <TextInput
id='operation_title' id='operation_title'
label='Полное название' label='Полное название'
value={title} value={title}
onChange={event => setTitle(event.target.value)} onChange={event => onChangeTitle(event.target.value)}
/> />
<div className='flex gap-6'> <div className='flex gap-6'>
<TextInput <TextInput
@ -26,7 +26,7 @@ function TabOperation({ alias, setAlias, title, setTitle, comment, setComment }:
label='Сокращение' label='Сокращение'
className='w-[16rem]' className='w-[16rem]'
value={alias} value={alias}
onChange={event => setAlias(event.target.value)} onChange={event => onChangeAlias(event.target.value)}
/> />
<TextArea <TextArea
@ -35,7 +35,7 @@ function TabOperation({ alias, setAlias, title, setTitle, comment, setComment }:
noResize noResize
rows={3} rows={3}
value={comment} value={comment}
onChange={event => setComment(event.target.value)} onChange={event => onChangeComment(event.target.value)}
/> />
</div> </div>
</AnimateFade> </AnimateFade>

View File

@ -45,7 +45,12 @@ function DlgEditReference({ hideWindow, schema, initial, onSave }: DlgEditRefere
const entityPanel = useMemo( const entityPanel = useMemo(
() => ( () => (
<TabPanel> <TabPanel>
<TabEntityReference initial={initial} schema={schema} setReference={setReference} setIsValid={setIsValid} /> <TabEntityReference
initial={initial}
schema={schema}
onChangeReference={setReference}
onChangeValid={setIsValid}
/>
</TabPanel> </TabPanel>
), ),
[initial, schema] [initial, schema]
@ -54,7 +59,7 @@ function DlgEditReference({ hideWindow, schema, initial, onSave }: DlgEditRefere
const syntacticPanel = useMemo( const syntacticPanel = useMemo(
() => ( () => (
<TabPanel> <TabPanel>
<TabSyntacticReference initial={initial} setReference={setReference} setIsValid={setIsValid} /> <TabSyntacticReference initial={initial} onChangeReference={setReference} onChangeValid={setIsValid} />
</TabPanel> </TabPanel>
), ),
[initial] [initial]

View File

@ -21,11 +21,11 @@ import { IReferenceInputState } from './DlgEditReference';
interface TabEntityReferenceProps { interface TabEntityReferenceProps {
initial: IReferenceInputState; initial: IReferenceInputState;
schema: IRSForm; schema: IRSForm;
setIsValid: React.Dispatch<React.SetStateAction<boolean>>; onChangeValid: (newValue: boolean) => void;
setReference: React.Dispatch<React.SetStateAction<string>>; onChangeReference: (newValue: string) => void;
} }
function TabEntityReference({ initial, schema, setIsValid, setReference }: TabEntityReferenceProps) { function TabEntityReference({ initial, schema, onChangeValid, onChangeReference }: TabEntityReferenceProps) {
const [selectedCst, setSelectedCst] = useState<IConstituenta | undefined>(undefined); const [selectedCst, setSelectedCst] = useState<IConstituenta | undefined>(undefined);
const [alias, setAlias] = useState(''); const [alias, setAlias] = useState('');
const [term, setTerm] = useState(''); const [term, setTerm] = useState('');
@ -43,9 +43,9 @@ function TabEntityReference({ initial, schema, setIsValid, setReference }: TabEn
// Produce result // Produce result
useEffect(() => { useEffect(() => {
setIsValid(alias !== '' && selectedGrams.length > 0); onChangeValid(alias !== '' && selectedGrams.length > 0);
setReference(`@{${alias}|${selectedGrams.map(gram => gram.value).join(',')}}`); onChangeReference(`@{${alias}|${selectedGrams.map(gram => gram.value).join(',')}}`);
}, [alias, selectedGrams, setIsValid, setReference]); }, [alias, selectedGrams, onChangeValid, onChangeReference]);
// Update term when alias changes // Update term when alias changes
useEffect(() => { useEffect(() => {
@ -105,7 +105,7 @@ function TabEntityReference({ initial, schema, setIsValid, setReference }: TabEn
className='flex-grow' className='flex-grow'
menuPlacement='top' menuPlacement='top'
value={selectedGrams} value={selectedGrams}
setValue={setSelectedGrams} onChangeValue={setSelectedGrams}
/> />
</div> </div>
</AnimateFade> </AnimateFade>

View File

@ -11,11 +11,11 @@ import { IReferenceInputState } from './DlgEditReference';
interface TabSyntacticReferenceProps { interface TabSyntacticReferenceProps {
initial: IReferenceInputState; initial: IReferenceInputState;
setIsValid: React.Dispatch<React.SetStateAction<boolean>>; onChangeValid: (newValue: boolean) => void;
setReference: React.Dispatch<React.SetStateAction<string>>; onChangeReference: (newValue: string) => void;
} }
function TabSyntacticReference({ initial, setIsValid, setReference }: TabSyntacticReferenceProps) { function TabSyntacticReference({ initial, onChangeValid, onChangeReference }: TabSyntacticReferenceProps) {
const [nominal, setNominal] = useState(''); const [nominal, setNominal] = useState('');
const [offset, setOffset] = useState(1); const [offset, setOffset] = useState(1);
@ -39,9 +39,9 @@ function TabSyntacticReference({ initial, setIsValid, setReference }: TabSyntact
}, [initial]); }, [initial]);
useEffect(() => { useEffect(() => {
setIsValid(nominal !== '' && offset !== 0); onChangeValid(nominal !== '' && offset !== 0);
setReference(`@{${offset}|${nominal}}`); onChangeReference(`@{${offset}|${nominal}}`);
}, [nominal, offset, setIsValid, setReference]); }, [nominal, offset, onChangeValid, onChangeReference]);
return ( return (
<AnimateFade className='flex flex-col gap-2'> <AnimateFade className='flex flex-col gap-2'>

View File

@ -170,7 +170,7 @@ function DlgEditWordForms({ hideWindow, target, onSave }: DlgEditWordFormsProps)
placeholder='Выберите граммемы' placeholder='Выберите граммемы'
className='min-w-[15rem] h-fit' className='min-w-[15rem] h-fit'
value={inputGrams} value={inputGrams}
setValue={setInputGrams} onChangeValue={setInputGrams}
/> />
</div> </div>

View File

@ -0,0 +1,74 @@
'use client';
import { useLayoutEffect } from 'react';
import { Edge, MarkerType, Node, ReactFlow, useEdgesState, useNodesState, useReactFlow } from 'reactflow';
import { SyntaxTree } from '@/models/rslang';
import { ASTEdgeTypes } from './graph/ASTEdgeTypes';
import { applyLayout } from './graph/ASTLayout';
import { ASTNodeTypes } from './graph/ASTNodeTypes';
interface ASTFlowProps {
data: SyntaxTree;
onNodeEnter: (node: Node) => void;
onNodeLeave: (node: Node) => void;
}
function ASTFlow({ data, onNodeEnter, onNodeLeave }: ASTFlowProps) {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]);
const flow = useReactFlow();
useLayoutEffect(() => {
const newNodes = data.map(node => ({
id: String(node.uid),
data: node,
position: { x: 0, y: 0 },
type: 'token'
}));
const newEdges: Edge[] = [];
data.forEach(node => {
if (node.parent !== node.uid) {
newEdges.push({
id: String(node.uid),
source: String(node.parent),
target: String(node.uid),
type: 'dynamic',
focusable: false,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 20,
height: 20
}
});
}
});
applyLayout(newNodes, newEdges);
setNodes(newNodes);
setEdges(newEdges);
}, [data, setNodes, setEdges, flow]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
edgesFocusable={false}
nodesFocusable={false}
onNodeMouseEnter={(_, node) => onNodeEnter(node)}
onNodeMouseLeave={(_, node) => onNodeLeave(node)}
onNodesChange={onNodesChange}
nodeTypes={ASTNodeTypes}
edgeTypes={ASTEdgeTypes}
fitView
maxZoom={2}
minZoom={0.5}
nodesConnectable={false}
/>
);
}
export default ASTFlow;

View File

@ -1,17 +1,16 @@
'use client'; 'use client';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { ReactFlowProvider } from 'reactflow';
import { Node } from 'reactflow';
import GraphUI, { GraphEdge, GraphNode } from '@/components/ui/GraphUI';
import Modal, { ModalProps } from '@/components/ui/Modal'; import Modal, { ModalProps } from '@/components/ui/Modal';
import Overlay from '@/components/ui/Overlay'; import Overlay from '@/components/ui/Overlay';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { HelpTopic } from '@/models/miscellaneous'; import { HelpTopic } from '@/models/miscellaneous';
import { SyntaxTree } from '@/models/rslang'; import { SyntaxTree } from '@/models/rslang';
import { graphDarkT, graphLightT } from '@/styling/color';
import { colorBgSyntaxTree } from '@/styling/color'; import ASTFlow from './ASTFlow';
import { resources } from '@/utils/constants';
import { labelSyntaxTree } from '@/utils/labels';
interface DlgShowASTProps extends Pick<ModalProps, 'hideWindow'> { interface DlgShowASTProps extends Pick<ModalProps, 'hideWindow'> {
syntaxTree: SyntaxTree; syntaxTree: SyntaxTree;
@ -19,36 +18,11 @@ interface DlgShowASTProps extends Pick<ModalProps, 'hideWindow'> {
} }
function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) { function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
const { darkMode, colors } = useConceptOptions(); const { colors } = useConceptOptions();
const [hoverID, setHoverID] = useState<number | undefined>(undefined); const [hoverID, setHoverID] = useState<number | undefined>(undefined);
const hoverNode = useMemo(() => syntaxTree.find(node => node.uid === hoverID), [hoverID, syntaxTree]); const hoverNode = useMemo(() => syntaxTree.find(node => node.uid === hoverID), [hoverID, syntaxTree]);
const nodes: GraphNode[] = useMemo( const handleHoverIn = useCallback((node: Node) => setHoverID(Number(node.id)), []);
() =>
syntaxTree.map(node => ({
id: String(syntaxTree.length - node.uid), // invert order of IDs to force correct ordering in graph layout
label: labelSyntaxTree(node),
fill: colorBgSyntaxTree(node, colors)
})),
[syntaxTree, colors]
);
const edges: GraphEdge[] = useMemo(() => {
const result: GraphEdge[] = [];
syntaxTree.forEach(node => {
if (node.parent !== node.uid) {
result.push({
id: String(node.uid),
source: String(syntaxTree.length - node.parent),
target: String(syntaxTree.length - node.uid)
});
}
});
return result;
}, [syntaxTree]);
const handleHoverIn = useCallback((node: GraphNode) => setHoverID(syntaxTree.length - Number(node.id)), [syntaxTree]);
const handleHoverOut = useCallback(() => setHoverID(undefined), []); const handleHoverOut = useCallback(() => setHoverID(undefined), []);
return ( return (
@ -72,18 +46,9 @@ function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
</div> </div>
) : null} ) : null}
</Overlay> </Overlay>
<div className='flex-grow relative'> <ReactFlowProvider>
<GraphUI <ASTFlow data={syntaxTree} onNodeEnter={handleHoverIn} onNodeLeave={handleHoverOut} />
animated={false} </ReactFlowProvider>
nodes={nodes}
edges={edges}
layoutType='hierarchicalTd'
labelFontUrl={resources.graph_font}
theme={darkMode ? graphDarkT : graphLightT}
onNodePointerOver={handleHoverIn}
onNodePointerOut={handleHoverOut}
/>
</div>
</Modal> </Modal>
); );
} }

View File

@ -0,0 +1,28 @@
import { EdgeProps, getStraightPath } from 'reactflow';
const NODE_RADIUS = 20;
const EDGE_RADIUS = 25;
function ASTEdge({ id, markerEnd, style, ...props }: EdgeProps) {
const scale =
EDGE_RADIUS /
Math.sqrt(
Math.pow(props.sourceX - props.targetX, 2) +
Math.pow(Math.abs(props.sourceY - props.targetY) + 2 * NODE_RADIUS, 2)
);
const [path] = getStraightPath({
sourceX: props.sourceX - (props.sourceX - props.targetX) * scale,
sourceY: props.sourceY - (props.sourceY - props.targetY - 2 * NODE_RADIUS) * scale - NODE_RADIUS,
targetX: props.targetX + (props.sourceX - props.targetX) * scale,
targetY: props.targetY + (props.sourceY - props.targetY - 2 * NODE_RADIUS) * scale + NODE_RADIUS
});
return (
<>
<path id={id} className='react-flow__edge-path' d={path} markerEnd={markerEnd} style={style} />
</>
);
}
export default ASTEdge;

View File

@ -0,0 +1,7 @@
import { EdgeTypes } from 'reactflow';
import ASTEdge from './ASTEdge';
export const ASTEdgeTypes: EdgeTypes = {
dynamic: ASTEdge
};

View File

@ -0,0 +1,35 @@
import dagre from '@dagrejs/dagre';
import { Edge, Node } from 'reactflow';
import { ISyntaxTreeNode } from '@/models/rslang';
const NODE_WIDTH = 44;
const NODE_HEIGHT = 44;
const HOR_SEPARATION = 40;
const VERT_SEPARATION = 40;
export function applyLayout(nodes: Node<ISyntaxTreeNode>[], edges: Edge[]) {
const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({
rankdir: 'TB',
ranksep: VERT_SEPARATION,
nodesep: HOR_SEPARATION,
ranker: 'network-simplex',
align: undefined
});
nodes.forEach(node => {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
edges.forEach(edge => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
nodes.forEach(node => {
const nodeWithPosition = dagreGraph.node(node.id);
node.position.x = nodeWithPosition.x - NODE_WIDTH / 2;
node.position.y = nodeWithPosition.y - NODE_HEIGHT / 2;
});
}

View File

@ -0,0 +1,44 @@
'use client';
import { useMemo } from 'react';
import { Handle, Position } from 'reactflow';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { ISyntaxTreeNode } from '@/models/rslang';
import { colorBgSyntaxTree } from '@/styling/color';
import { labelSyntaxTree } from '@/utils/labels';
/**
* Represents graph AST node internal data.
*/
interface ASTNodeInternal {
id: string;
data: ISyntaxTreeNode;
dragging: boolean;
xPos: number;
yPos: number;
}
function ASTNode(node: ASTNodeInternal) {
const { colors } = useConceptOptions();
const label = useMemo(() => labelSyntaxTree(node.data), [node.data]);
return (
<>
<Handle type='target' position={Position.Top} style={{ opacity: 0 }} />
<div
className='w-full h-full cursor-default flex items-center justify-center rounded-full'
style={{ backgroundColor: colorBgSyntaxTree(node.data, colors) }}
/>
<Handle type='source' position={Position.Bottom} style={{ opacity: 0 }} />
<div
className='font-math mt-1 w-fit px-1 text-center translate-x-[calc(-50%+20px)]'
style={{ backgroundColor: colors.bgDefault, fontSize: label.length > 3 ? 12 : 14 }}
>
{label}
</div>
</>
);
}
export default ASTNode;

View File

@ -0,0 +1,7 @@
import { NodeTypes } from 'reactflow';
import ASTNode from './ASTNode';
export const ASTNodeTypes: NodeTypes = {
token: ASTNode
};

View File

@ -0,0 +1 @@
export { default } from './DlgShowAST';

View File

@ -4,7 +4,6 @@ import { useLayoutEffect } from 'react';
import { Edge, ReactFlow, useEdgesState, useNodesState, useReactFlow } from 'reactflow'; import { Edge, ReactFlow, useEdgesState, useNodesState, useReactFlow } from 'reactflow';
import { TMGraph } from '@/models/TMGraph'; import { TMGraph } from '@/models/TMGraph';
import { PARAMETER } from '@/utils/constants';
import { TMGraphEdgeTypes } from './graph/MGraphEdgeTypes'; import { TMGraphEdgeTypes } from './graph/MGraphEdgeTypes';
import { applyLayout } from './graph/MGraphLayout'; import { applyLayout } from './graph/MGraphLayout';
@ -15,7 +14,7 @@ interface MGraphFlowProps {
} }
function MGraphFlow({ data }: MGraphFlowProps) { function MGraphFlow({ data }: MGraphFlowProps) {
const [nodes, setNodes] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]); const [edges, setEdges] = useEdgesState([]);
const flow = useReactFlow(); const flow = useReactFlow();
@ -49,11 +48,10 @@ function MGraphFlow({ data }: MGraphFlowProps) {
}); });
}); });
applyLayout(newNodes); applyLayout(newNodes, newEdges);
setNodes(newNodes); setNodes(newNodes);
setEdges(newEdges); setEdges(newEdges);
flow.fitView({ duration: PARAMETER.zoomDuration });
}, [data, setNodes, setEdges, flow]); }, [data, setNodes, setEdges, flow]);
return ( return (
@ -62,14 +60,13 @@ function MGraphFlow({ data }: MGraphFlowProps) {
edges={edges} edges={edges}
edgesFocusable={false} edgesFocusable={false}
nodesFocusable={false} nodesFocusable={false}
onNodesChange={onNodesChange}
nodeTypes={TMGraphNodeTypes} nodeTypes={TMGraphNodeTypes}
edgeTypes={TMGraphEdgeTypes} edgeTypes={TMGraphEdgeTypes}
fitView fitView
maxZoom={2} maxZoom={2}
minZoom={0.5} minZoom={0.5}
nodesConnectable={false} nodesConnectable={false}
snapToGrid={true}
snapGrid={[PARAMETER.ossGridSize, PARAMETER.ossGridSize]}
/> />
); );
} }

View File

@ -1,171 +1,38 @@
import { Node } from 'reactflow'; import dagre from '@dagrejs/dagre';
import { Edge, Node } from 'reactflow';
import { Graph } from '@/models/Graph';
import { TMGraphNode } from '@/models/TMGraph'; import { TMGraphNode } from '@/models/TMGraph';
export function applyLayout(nodes: Node<TMGraphNode>[]) { const NODE_WIDTH = 44;
new LayoutManager(nodes).execute(); const NODE_HEIGHT = 44;
} const HOR_SEPARATION = 40;
const VERT_SEPARATION = 40;
const UNIT_HEIGHT = 100; const BOOLEAN_WEIGHT = 2;
const UNIT_WIDTH = 100; const CARTESIAN_WEIGHT = 1;
const MIN_NODE_DISTANCE = 80;
const LAYOUT_ITERATIONS = 8;
class LayoutManager { export function applyLayout(nodes: Node<TMGraphNode>[], edges: Edge[]) {
nodes: Node<TMGraphNode>[]; const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({
graph = new Graph(); rankdir: 'BT',
ranks = new Map<number, number>(); ranksep: VERT_SEPARATION,
posX = new Map<number, number>(); nodesep: HOR_SEPARATION,
posY = new Map<number, number>(); ranker: 'network-simplex',
align: undefined
maxRank = 0;
virtualCount = 0;
layers: number[][] = [];
constructor(nodes: Node<TMGraphNode>[]) {
this.nodes = nodes;
this.prepareGraph();
}
/** Prepares graph for layout calculations.
*
* Assumes that nodes are already topologically sorted.
* 1. Adds nodes to graph.
* 2. Adds elementary edges to graph.
* 3. Splits non-elementary edges via virtual nodes.
*/
private prepareGraph(): void {
this.nodes.forEach(node => {
if (this.maxRank < node.data.rank) {
this.maxRank = node.data.rank;
}
const nodeID = node.data.id;
this.ranks.set(nodeID, node.data.rank);
this.graph.addNode(nodeID);
if (node.data.parents.length === 0) {
return;
}
const visited = new Set<number>();
node.data.parents.forEach(parent => {
if (!visited.has(parent)) {
visited.add(parent);
let target = nodeID;
let currentRank = node.data.rank;
const parentRank = this.ranks.get(parent)!;
while (currentRank - 1 > parentRank) {
currentRank = currentRank - 1;
this.virtualCount = this.virtualCount + 1;
this.ranks.set(-this.virtualCount, currentRank);
this.graph.addEdge(-this.virtualCount, target);
target = -this.virtualCount;
}
this.graph.addEdge(parent, target);
}
}); });
nodes.forEach(node => {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
edges.forEach(edge => {
dagreGraph.setEdge(edge.source, edge.target, { weight: edge.data ? CARTESIAN_WEIGHT : BOOLEAN_WEIGHT });
});
dagre.layout(dagreGraph);
nodes.forEach(node => {
const nodeWithPosition = dagreGraph.node(node.id);
node.position.x = nodeWithPosition.x - NODE_WIDTH / 2;
node.position.y = nodeWithPosition.y - NODE_HEIGHT / 2;
}); });
} }
execute(): void {
this.calculateLayers();
this.calculatePositions();
this.savePositions();
}
private calculateLayers(): void {
this.initLayers();
// TODO: implement ordering algorithm iterations
}
private initLayers(): void {
this.layers = Array.from({ length: this.maxRank + 1 }, () => []);
const visited = new Set<number>();
const dfs = (nodeID: number) => {
if (visited.has(nodeID)) {
return;
}
visited.add(nodeID);
this.layers[this.ranks.get(nodeID)!].push(nodeID);
this.graph.at(nodeID)!.outputs.forEach(dfs);
};
const simpleNodes = this.nodes
.filter(node => node.data.rank === 0)
.sort((a, b) => a.data.text.localeCompare(b.data.text))
.map(node => node.data.id);
simpleNodes.forEach(dfs);
}
private calculatePositions(): void {
this.initPositions();
for (let i = 0; i < LAYOUT_ITERATIONS; i++) {
this.fixLayersPositions();
}
}
private fixLayersPositions(): void {
for (let rank = 1; rank <= this.maxRank; rank++) {
this.layers[rank].reverse().forEach(nodeID => {
const inputs = this.graph.at(nodeID)!.inputs;
const currentPos = this.posX.get(nodeID)!;
if (inputs.length === 1) {
const parent = inputs[0];
const parentPos = this.posX.get(parent)!;
if (currentPos === parentPos) {
return;
}
if (currentPos > parentPos) {
this.tryMoveNodeX(parent, currentPos);
} else {
this.tryMoveNodeX(nodeID, parentPos);
}
} else if (inputs.length % 2 === 1) {
const median = inputs[Math.floor(inputs.length / 2)];
const medianPos = this.posX.get(median)!;
if (currentPos === medianPos) {
return;
}
this.tryMoveNodeX(nodeID, medianPos);
} else {
const median1 = inputs[Math.floor(inputs.length / 2)];
const median2 = inputs[Math.floor(inputs.length / 2) - 1];
const medianPos = (this.posX.get(median1)! + this.posX.get(median2)!) / 2;
this.tryMoveNodeX(nodeID, medianPos);
}
});
}
}
private tryMoveNodeX(nodeID: number, targetX: number) {
const rank = this.ranks.get(nodeID)!;
if (this.layers[rank].some(id => id !== nodeID && Math.abs(targetX - this.posX.get(id)!) < MIN_NODE_DISTANCE)) {
return;
}
this.posX.set(nodeID, targetX);
}
private initPositions(): void {
this.layers.forEach((layer, rank) => {
layer.forEach((nodeID, index) => {
this.posX.set(nodeID, index * UNIT_WIDTH);
this.posY.set(nodeID, -rank * UNIT_HEIGHT);
});
});
}
private savePositions(): void {
this.nodes.forEach(node => {
const nodeID = node.data.id;
node.position = {
x: this.posX.get(nodeID)!,
y: this.posY.get(nodeID)!
};
});
}
}

View File

@ -4,10 +4,21 @@ import { useMemo } from 'react';
import { Handle, Position } from 'reactflow'; import { Handle, Position } from 'reactflow';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { MGraphNodeInternal } from '@/models/miscellaneous'; import { TMGraphNode } from '@/models/TMGraph';
import { colorBgTMGraphNode } from '@/styling/color'; import { colorBgTMGraphNode } from '@/styling/color';
import { globals } from '@/utils/constants'; import { globals } from '@/utils/constants';
/**
* Represents graph TMGraph node internal data.
*/
interface MGraphNodeInternal {
id: string;
data: TMGraphNode;
dragging: boolean;
xPos: number;
yPos: number;
}
function MGraphNode(node: MGraphNodeInternal) { function MGraphNode(node: MGraphNodeInternal) {
const { colors } = useConceptOptions(); const { colors } = useConceptOptions();

View File

@ -6,7 +6,6 @@ import { EdgeProps, Node } from 'reactflow';
import { LibraryItemType, LocationHead } from './library'; import { LibraryItemType, LocationHead } from './library';
import { IOperation } from './oss'; import { IOperation } from './oss';
import { TMGraphNode } from './TMGraph';
import { UserID } from './user'; import { UserID } from './user';
/** /**
@ -46,17 +45,6 @@ export interface OssNodeInternal {
yPos: number; yPos: number;
} }
/**
* Represents graph TMGraph node internal data.
*/
export interface MGraphNodeInternal {
id: string;
data: TMGraphNode;
dragging: boolean;
xPos: number;
yPos: number;
}
/** /**
* Represents graph TMGraph edge internal data. * Represents graph TMGraph edge internal data.
*/ */

View File

@ -150,8 +150,8 @@ function LibraryPage() {
const viewLocations = useMemo( const viewLocations = useMemo(
() => ( () => (
<ViewSideLocation <ViewSideLocation
active={options.location} activeLocation={options.location}
setActive={options.setLocation} onChangeActiveLocation={options.setLocation}
subfolders={subfolders} subfolders={subfolders}
folderTree={library.folders} folderTree={library.folders}
toggleFolderMode={toggleFolderMode} toggleFolderMode={toggleFolderMode}
@ -200,11 +200,11 @@ function LibraryPage() {
filtered={items.length} filtered={items.length}
hasCustomFilter={hasCustomFilter} hasCustomFilter={hasCustomFilter}
query={query} query={query}
setQuery={setQuery} onChangeQuery={setQuery}
path={path} path={path}
setPath={setPath} onChangePath={setPath}
head={head} head={head}
setHead={setHead} onChangeHead={setHead}
isVisible={isVisible} isVisible={isVisible}
isOwned={isOwned} isOwned={isOwned}
toggleOwned={toggleOwned} toggleOwned={toggleOwned}
@ -212,7 +212,7 @@ function LibraryPage() {
isEditor={isEditor} isEditor={isEditor}
toggleEditor={toggleEditor} toggleEditor={toggleEditor}
filterUser={filterUser} filterUser={filterUser}
setFilterUser={setFilterUser} onChangeFilterUser={setFilterUser}
resetFilter={resetFilter} resetFilter={resetFilter}
folderMode={options.folderMode} folderMode={options.folderMode}
toggleFolderMode={toggleFolderMode} toggleFolderMode={toggleFolderMode}

View File

@ -36,11 +36,11 @@ interface ToolbarSearchProps {
hasCustomFilter: boolean; hasCustomFilter: boolean;
query: string; query: string;
setQuery: React.Dispatch<React.SetStateAction<string>>; onChangeQuery: (newValue: string) => void;
path: string; path: string;
setPath: React.Dispatch<React.SetStateAction<string>>; onChangePath: (newValue: string) => void;
head: LocationHead | undefined; head: LocationHead | undefined;
setHead: React.Dispatch<React.SetStateAction<LocationHead | undefined>>; onChangeHead: (newValue: LocationHead | undefined) => void;
folderMode: boolean; folderMode: boolean;
toggleFolderMode: () => void; toggleFolderMode: () => void;
@ -52,7 +52,7 @@ interface ToolbarSearchProps {
isEditor: boolean | undefined; isEditor: boolean | undefined;
toggleEditor: () => void; toggleEditor: () => void;
filterUser: UserID | undefined; filterUser: UserID | undefined;
setFilterUser: React.Dispatch<React.SetStateAction<UserID | undefined>>; onChangeFilterUser: (newValue: UserID | undefined) => void;
resetFilter: () => void; resetFilter: () => void;
} }
@ -63,11 +63,11 @@ function ToolbarSearch({
hasCustomFilter, hasCustomFilter,
query, query,
setQuery, onChangeQuery,
path, path,
setPath, onChangePath,
head, head,
setHead, onChangeHead,
folderMode, folderMode,
toggleFolderMode, toggleFolderMode,
@ -79,7 +79,7 @@ function ToolbarSearch({
isEditor, isEditor,
toggleEditor, toggleEditor,
filterUser, filterUser,
setFilterUser, onChangeFilterUser,
resetFilter resetFilter
}: ToolbarSearchProps) { }: ToolbarSearchProps) {
@ -95,9 +95,9 @@ function ToolbarSearch({
const handleChange = useCallback( const handleChange = useCallback(
(newValue: LocationHead | undefined) => { (newValue: LocationHead | undefined) => {
headMenu.hide(); headMenu.hide();
setHead(newValue); onChangeHead(newValue);
}, },
[headMenu, setHead] [headMenu, onChangeHead]
); );
const handleToggleFolder = useCallback(() => { const handleToggleFolder = useCallback(() => {
@ -170,7 +170,7 @@ function ToolbarSearch({
className='min-w-[15rem] text-sm' className='min-w-[15rem] text-sm'
items={users} items={users}
value={filterUser} value={filterUser}
onSelectValue={setFilterUser} onSelectValue={onChangeFilterUser}
/> />
</motion.div> </motion.div>
</Dropdown> </Dropdown>
@ -190,8 +190,8 @@ function ToolbarSearch({
placeholder='Поиск' placeholder='Поиск'
noBorder noBorder
className={clsx('min-w-[7rem] sm:min-w-[10rem] max-w-[20rem]', folderMode && 'flex-grow')} className={clsx('min-w-[7rem] sm:min-w-[10rem] max-w-[20rem]', folderMode && 'flex-grow')}
value={query} query={query}
onChange={setQuery} onChangeQuery={onChangeQuery}
/> />
{!folderMode ? ( {!folderMode ? (
<div ref={headMenu.ref} className='flex items-center h-full py-1 select-none'> <div ref={headMenu.ref} className='flex items-center h-full py-1 select-none'>
@ -249,8 +249,8 @@ function ToolbarSearch({
noIcon noIcon
noBorder noBorder
className='w-[4.5rem] sm:w-[5rem] flex-grow' className='w-[4.5rem] sm:w-[5rem] flex-grow'
value={path} query={path}
onChange={setPath} onChangeQuery={onChangePath}
/> />
) : null} ) : null}
</div> </div>

View File

@ -22,8 +22,8 @@ import { information } from '@/utils/labels';
interface ViewSideLocationProps { interface ViewSideLocationProps {
folderTree: FolderTree; folderTree: FolderTree;
subfolders: boolean; subfolders: boolean;
active: string; activeLocation: string;
setActive: React.Dispatch<React.SetStateAction<string>>; onChangeActiveLocation: (newValue: string) => void;
toggleFolderMode: () => void; toggleFolderMode: () => void;
toggleSubfolders: () => void; toggleSubfolders: () => void;
onRenameLocation: () => void; onRenameLocation: () => void;
@ -31,9 +31,9 @@ interface ViewSideLocationProps {
function ViewSideLocation({ function ViewSideLocation({
folderTree, folderTree,
active, activeLocation,
subfolders, subfolders,
setActive: setActive, onChangeActiveLocation,
toggleFolderMode, toggleFolderMode,
toggleSubfolders, toggleSubfolders,
onRenameLocation onRenameLocation
@ -44,16 +44,18 @@ function ViewSideLocation({
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const canRename = useMemo(() => { const canRename = useMemo(() => {
if (active.length <= 3 || !user) { if (activeLocation.length <= 3 || !user) {
return false; return false;
} }
if (user.is_staff) { if (user.is_staff) {
return true; return true;
} }
const owned = items.filter(item => item.owner == user.id); const owned = items.filter(item => item.owner == user.id);
const located = owned.filter(item => item.location == active || item.location.startsWith(`${active}/`)); const located = owned.filter(
item => item.location == activeLocation || item.location.startsWith(`${activeLocation}/`)
);
return located.length !== 0; return located.length !== 0;
}, [active, user, items]); }, [activeLocation, user, items]);
const animations = useMemo(() => animateSideMinWidth(windowSize.isSmall ? '10rem' : '15rem'), [windowSize]); const animations = useMemo(() => animateSideMinWidth(windowSize.isSmall ? '10rem' : '15rem'), [windowSize]);
const maxHeight = useMemo(() => calculateHeight('4.5rem'), [calculateHeight]); const maxHeight = useMemo(() => calculateHeight('4.5rem'), [calculateHeight]);
@ -68,10 +70,10 @@ function ViewSideLocation({
.then(() => toast.success(information.pathReady)) .then(() => toast.success(information.pathReady))
.catch(console.error); .catch(console.error);
} else { } else {
setActive(target.getPath()); onChangeActiveLocation(target.getPath());
} }
}, },
[setActive] [onChangeActiveLocation]
); );
return ( return (
@ -109,7 +111,7 @@ function ViewSideLocation({
</div> </div>
</div> </div>
<SelectLocation <SelectLocation
value={active} value={activeLocation}
folderTree={folderTree} folderTree={folderTree}
prefix={prefixes.folders_list} prefix={prefixes.folders_list}
onClick={handleClickFolder} onClick={handleClickFolder}

View File

@ -69,7 +69,7 @@ function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownProps) {
<SelectTree <SelectTree
items={Object.values(HelpTopic).map(item => item as HelpTopic)} items={Object.values(HelpTopic).map(item => item as HelpTopic)}
value={activeTopic} value={activeTopic}
setValue={handleSelectTopic} onChangeValue={handleSelectTopic}
prefix={prefixes.topic_list} prefix={prefixes.topic_list}
getParent={item => topicParent.get(item) ?? item} getParent={item => topicParent.get(item) ?? item}
getLabel={labelHelpTopic} getLabel={labelHelpTopic}

View File

@ -17,7 +17,7 @@ function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps) {
<SelectTree <SelectTree
items={Object.values(HelpTopic).map(item => item as HelpTopic)} items={Object.values(HelpTopic).map(item => item as HelpTopic)}
value={activeTopic} value={activeTopic}
setValue={onChangeTopic} onChangeValue={onChangeTopic}
prefix={prefixes.topic_list} prefix={prefixes.topic_list}
getParent={item => topicParent.get(item) ?? item} getParent={item => topicParent.get(item) ?? item}
getLabel={labelHelpTopic} getLabel={labelHelpTopic}

View File

@ -28,7 +28,7 @@ function HelpFormulaTree() {
<span style={{ backgroundColor: colors.bgRed }}>присвоение и итерация</span> <span style={{ backgroundColor: colors.bgRed }}>присвоение и итерация</span>
</li> </li>
<li> <li>
<span style={{ backgroundColor: '#7ca0ab' }}>составные выражения</span> <span style={{ backgroundColor: colors.bgDisabled }}>составные выражения</span>
</li> </li>
</div> </div>
); );

View File

@ -15,7 +15,8 @@ import {
IconSave, IconSave,
IconSettings, IconSettings,
IconStatusOK, IconStatusOK,
IconTree IconTree,
IconTypeGraph
} from '@/components/Icons'; } from '@/components/Icons';
import LinkTopic from '@/components/ui/LinkTopic'; import LinkTopic from '@/components/ui/LinkTopic';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
@ -90,6 +91,10 @@ function HelpRSEditor() {
<li> <li>
<IconControls className='inline-icon' /> специальная клавиатура и горячие клавиши <IconControls className='inline-icon' /> специальная клавиатура и горячие клавиши
</li> </li>
<li>
<IconTypeGraph className='inline-icon' /> отображение{' '}
<LinkTopic text='графа ступеней типизации' topic={HelpTopic.UI_TYPE_GRAPH} />
</li>
<li> <li>
<IconTree className='inline-icon' /> отображение{' '} <IconTree className='inline-icon' /> отображение{' '}
<LinkTopic text='дерева разбора' topic={HelpTopic.UI_FORMULA_TREE} /> <LinkTopic text='дерева разбора' topic={HelpTopic.UI_FORMULA_TREE} />

View File

@ -15,7 +15,7 @@ import OssStats from './OssStats';
interface EditorOssCardProps { interface EditorOssCardProps {
isModified: boolean; isModified: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: (newValue: boolean) => void;
onDestroy: () => void; onDestroy: () => void;
} }

View File

@ -18,7 +18,7 @@ import { useOssEdit } from '../OssEditContext';
interface FormOSSProps { interface FormOSSProps {
id?: string; id?: string;
isModified: boolean; isModified: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: (newValue: boolean) => void;
} }
function FormOSS({ id, isModified, setIsModified }: FormOSSProps) { function FormOSS({ id, isModified, setIsModified }: FormOSSProps) {

View File

@ -6,7 +6,7 @@ import OssFlow from './OssFlow';
interface EditorOssGraphProps { interface EditorOssGraphProps {
isModified: boolean; isModified: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: (newValue: boolean) => void;
} }
function EditorOssGraph({ isModified, setIsModified }: EditorOssGraphProps) { function EditorOssGraph({ isModified, setIsModified }: EditorOssGraphProps) {

View File

@ -34,7 +34,7 @@ import ToolbarOssGraph from './ToolbarOssGraph';
interface OssFlowProps { interface OssFlowProps {
isModified: boolean; isModified: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: (newValue: boolean) => void;
} }
function OssFlow({ isModified, setIsModified }: OssFlowProps) { function OssFlow({ isModified, setIsModified }: OssFlowProps) {

View File

@ -55,7 +55,7 @@ export interface IOssEditContext extends ILibraryItemEditor {
isAttachedToOSS: boolean; isAttachedToOSS: boolean;
showTooltip: boolean; showTooltip: boolean;
setShowTooltip: React.Dispatch<React.SetStateAction<boolean>>; setShowTooltip: (newValue: boolean) => void;
setOwner: (newOwner: UserID) => void; setOwner: (newOwner: UserID) => void;
setAccessPolicy: (newPolicy: AccessPolicy) => void; setAccessPolicy: (newPolicy: AccessPolicy) => void;

View File

@ -21,7 +21,7 @@ const SIDELIST_LAYOUT_THRESHOLD = 1000; // px
interface EditorConstituentaProps { interface EditorConstituentaProps {
activeCst?: IConstituenta; activeCst?: IConstituenta;
isModified: boolean; isModified: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: (newValue: boolean) => void;
onOpenEdit: (cstID: ConstituentaID) => void; onOpenEdit: (cstID: ConstituentaID) => void;
} }

View File

@ -31,7 +31,7 @@ interface FormConstituentaProps {
isModified: boolean; isModified: boolean;
toggleReset: boolean; toggleReset: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: (newValue: boolean) => void;
onRename: () => void; onRename: () => void;
onEditTerm: () => void; onEditTerm: () => void;
@ -144,7 +144,7 @@ function FormConstituenta({
cstUpdate(data, () => toast.success(information.changesSaved)); cstUpdate(data, () => toast.success(information.changesSaved));
} }
function handleTypificationClick(event: CProps.EventMouse) { function handleTypeGraph(event: CProps.EventMouse) {
if (!state || (localParse && !localParse.parseResult) || state.parse.status !== ParsingStatus.VERIFIED) { if (!state || (localParse && !localParse.parseResult) || state.parse.status !== ParsingStatus.VERIFIED) {
toast.error(errors.typeStructureFailed); toast.error(errors.typeStructureFailed);
return; return;
@ -196,10 +196,8 @@ function FormConstituenta({
noOutline noOutline
readOnly readOnly
label='Типизация' label='Типизация'
title='Отобразить структуру типизации'
value={typification} value={typification}
colors='clr-app clr-text-default cursor-pointer' colors='clr-app clr-text-default cursor-default'
onClick={event => handleTypificationClick(event)}
/> />
) : null} ) : null}
{state ? ( {state ? (
@ -225,10 +223,11 @@ function FormConstituenta({
activeCst={state} activeCst={state}
disabled={disabled || state.is_inherited} disabled={disabled || state.is_inherited}
toggleReset={toggleReset} toggleReset={toggleReset}
onChange={newValue => setExpression(newValue)} onChangeExpression={newValue => setExpression(newValue)}
setTypification={setTypification} onChangeTypification={setTypification}
setLocalParse={setLocalParse} onChangeLocalParse={setLocalParse}
onOpenEdit={onOpenEdit} onOpenEdit={onOpenEdit}
onShowTypeGraph={handleTypeGraph}
/> />
</AnimateFade> </AnimateFade>
<AnimateFade key='cst_definition_fade' hideContent={!state.definition_raw && isElementary}> <AnimateFade key='cst_definition_fade' hideContent={!state.definition_raw && isElementary}>

View File

@ -2,7 +2,7 @@
import { ReactCodeMirrorRef } from '@uiw/react-codemirror'; import { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import BadgeHelp from '@/components/info/BadgeHelp'; import BadgeHelp from '@/components/info/BadgeHelp';
@ -39,10 +39,11 @@ interface EditorRSExpressionProps {
disabled?: boolean; disabled?: boolean;
toggleReset?: boolean; toggleReset?: boolean;
setTypification: (typification: string) => void; onChangeTypification: (typification: string) => void;
setLocalParse: React.Dispatch<React.SetStateAction<IExpressionParse | undefined>>; onChangeLocalParse: (typification: IExpressionParse | undefined) => void;
onChange: (newValue: string) => void; onChangeExpression: (newValue: string) => void;
onOpenEdit?: (cstID: ConstituentaID) => void; onOpenEdit?: (cstID: ConstituentaID) => void;
onShowTypeGraph: (event: CProps.EventMouse) => void;
} }
function EditorRSExpression({ function EditorRSExpression({
@ -50,10 +51,11 @@ function EditorRSExpression({
disabled, disabled,
value, value,
toggleReset, toggleReset,
setTypification, onChangeTypification,
setLocalParse, onChangeLocalParse,
onChange, onChangeExpression,
onOpenEdit, onOpenEdit,
onShowTypeGraph,
...restProps ...restProps
}: EditorRSExpressionProps) { }: EditorRSExpressionProps) {
const model = useRSForm(); const model = useRSForm();
@ -74,20 +76,20 @@ function EditorRSExpression({
}, [activeCst, resetParse, toggleReset]); }, [activeCst, resetParse, toggleReset]);
function handleChange(newValue: string) { function handleChange(newValue: string) {
onChange(newValue); onChangeExpression(newValue);
setIsModified(newValue !== activeCst.definition_formal); setIsModified(newValue !== activeCst.definition_formal);
} }
function handleCheckExpression(callback?: (parse: IExpressionParse) => void) { function handleCheckExpression(callback?: (parse: IExpressionParse) => void) {
parser.checkConstituenta(value, activeCst, parse => { parser.checkConstituenta(value, activeCst, parse => {
setLocalParse(parse); onChangeLocalParse(parse);
if (parse.errors.length > 0) { if (parse.errors.length > 0) {
onShowError(parse.errors[0], parse.prefixLen); onShowError(parse.errors[0], parse.prefixLen);
} else { } else {
rsInput.current?.view?.focus(); rsInput.current?.view?.focus();
} }
setIsModified(false); setIsModified(false);
setTypification( onChangeTypification(
labelTypification({ labelTypification({
isValid: parse.parseResult, isValid: parse.parseResult,
resultType: parse.typification, resultType: parse.typification,
@ -171,6 +173,7 @@ function EditorRSExpression({
showControls={showControls} showControls={showControls}
showAST={handleShowAST} showAST={handleShowAST}
toggleControls={() => setShowControls(prev => !prev)} toggleControls={() => setShowControls(prev => !prev)}
showTypeGraph={onShowTypeGraph}
/> />
<Overlay <Overlay

View File

@ -54,7 +54,7 @@ function StatusBar({ isModified, processing, activeCst, parseData, onAnalyze }:
onClick={onAnalyze} onClick={onAnalyze}
> >
<AnimatePresence mode='wait'> <AnimatePresence mode='wait'>
{processing ? <Loader key='status-loader' size={3} /> : null} {processing ? <Loader key='status-loader' scale={3} /> : null}
{!processing ? ( {!processing ? (
<> <>
<StatusIcon key='status-icon' size='1rem' value={status} /> <StatusIcon key='status-icon' size='1rem' value={status} />

View File

@ -1,4 +1,4 @@
import { IconControls, IconTree } from '@/components/Icons'; import { IconControls, IconTree, IconTypeGraph } from '@/components/Icons';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay'; import Overlay from '@/components/ui/Overlay';
@ -10,9 +10,16 @@ interface ToolbarRSExpressionProps {
toggleControls: () => void; toggleControls: () => void;
showAST: (event: CProps.EventMouse) => void; showAST: (event: CProps.EventMouse) => void;
showTypeGraph: (event: CProps.EventMouse) => void;
} }
function ToolbarRSExpression({ disabled, showControls, toggleControls, showAST }: ToolbarRSExpressionProps) { function ToolbarRSExpression({
disabled,
showControls,
showTypeGraph,
toggleControls,
showAST
}: ToolbarRSExpressionProps) {
const model = useRSForm(); const model = useRSForm();
return ( return (
@ -24,6 +31,11 @@ function ToolbarRSExpression({ disabled, showControls, toggleControls, showAST }
onClick={toggleControls} onClick={toggleControls}
/> />
) : null} ) : null}
<MiniButton
icon={<IconTypeGraph size='1.25rem' className='icon-primary' />}
title='Граф ступеней типизации'
onClick={showTypeGraph}
/>
<MiniButton <MiniButton
title='Дерево разбора выражения' title='Дерево разбора выражения'
onClick={showAST} onClick={showAST}

View File

@ -15,7 +15,7 @@ import ToolbarRSFormCard from './ToolbarRSFormCard';
interface EditorRSFormCardProps { interface EditorRSFormCardProps {
isModified: boolean; isModified: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: (newValue: boolean) => void;
onDestroy: () => void; onDestroy: () => void;
} }

View File

@ -18,7 +18,7 @@ import ToolbarVersioning from './ToolbarVersioning';
interface FormRSFormProps { interface FormRSFormProps {
id?: string; id?: string;
isModified: boolean; isModified: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: (newValue: boolean) => void;
} }
function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) { function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) {

View File

@ -152,8 +152,8 @@ function EditorRSList({ onOpenEdit }: EditorRSListProps) {
id='constituents_search' id='constituents_search'
noBorder noBorder
className='w-[8rem]' className='w-[8rem]'
value={filterText} query={filterText}
onChange={setFilterText} onChangeQuery={setFilterText}
/> />
</div> </div>
) : null} ) : null}

View File

@ -74,8 +74,8 @@ function ConstituentsSearch({ schema, activeID, activeExpression, dense, setFilt
id='constituents_search' id='constituents_search'
noBorder noBorder
className='min-w-[6rem] pr-2 flex-grow' className='min-w-[6rem] pr-2 flex-grow'
value={filterText} query={filterText}
onChange={setFilterText} onChangeQuery={setFilterText}
/> />
{selectMatchMode} {selectMatchMode}
{selectGraph} {selectGraph}

View File

@ -371,7 +371,7 @@ export function colorBgSyntaxTree(node: ISyntaxTreeNode, colors: IColorTheme): s
case TokenID.NT_FUNC_CALL: case TokenID.NT_FUNC_CALL:
case TokenID.NT_ARGUMENTS: case TokenID.NT_ARGUMENTS:
case TokenID.NT_RECURSIVE_SHORT: case TokenID.NT_RECURSIVE_SHORT:
return ''; return colors.bgDisabled;
case TokenID.ASSIGN: case TokenID.ASSIGN:
case TokenID.ITERATE: case TokenID.ITERATE:

View File

@ -126,7 +126,8 @@
height: 40px; height: 40px;
} }
.react-flow__node-step { .react-flow__node-step,
.react-flow__node-token {
cursor: default; cursor: default;
border-radius: 100%; border-radius: 100%;

View File

@ -581,16 +581,16 @@ export function labelSyntaxTree(node: ISyntaxTreeNode): string {
case TokenID.NT_TUPLE: return 'TUPLE'; case TokenID.NT_TUPLE: return 'TUPLE';
case TokenID.NT_ENUMERATION: return 'ENUM'; case TokenID.NT_ENUMERATION: return 'ENUM';
case TokenID.NT_ENUM_DECL: return 'ENUM_DECLARATION'; case TokenID.NT_ENUM_DECL: return 'ENUM_DECLARE';
case TokenID.NT_TUPLE_DECL: return 'TUPLE_DECLARATION'; case TokenID.NT_TUPLE_DECL: return 'TUPLE_DECLARE';
case TokenID.PUNCTUATION_DEFINE: return 'DEFINITION'; case TokenID.PUNCTUATION_DEFINE: return 'DEFINITION';
case TokenID.PUNCTUATION_STRUCT: return 'STRUCTURE_DEFINITION'; case TokenID.PUNCTUATION_STRUCT: return 'STRUCTURE_DEFINE';
case TokenID.NT_ARG_DECL: return 'ARG'; case TokenID.NT_ARG_DECL: return 'ARG';
case TokenID.NT_FUNC_CALL: return 'CALL'; case TokenID.NT_FUNC_CALL: return 'CALL';
case TokenID.NT_ARGUMENTS: return 'ARGS'; case TokenID.NT_ARGUMENTS: return 'ARGS';
case TokenID.NT_FUNC_DEFINITION: return 'FUNCTION_DEFINITION'; case TokenID.NT_FUNC_DEFINITION: return 'FUNCTION_DEFINE';
case TokenID.NT_RECURSIVE_SHORT: return labelToken(TokenID.NT_RECURSIVE_FULL); case TokenID.NT_RECURSIVE_SHORT: return labelToken(TokenID.NT_RECURSIVE_FULL);

View File

@ -1,11 +1,14 @@
# Run coverage analysis # Run linters
$backend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\backend" $backend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\backend"
$frontend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\frontend"
function RunLinters() { function RunLinters() {
BackendLint LintBackend
LintFrontend
} }
function BackendLint() { function LintBackend() {
$pylint = "$backend\venv\Scripts\pylint.exe" $pylint = "$backend\venv\Scripts\pylint.exe"
$mypy = "$backend\venv\Scripts\mypy.exe" $mypy = "$backend\venv\Scripts\mypy.exe"
@ -15,4 +18,9 @@ function BackendLint() {
& $mypy project apps & $mypy project apps
} }
function LintFrontend() {
Set-Location $frontend
& npm run lint
}
RunLinters RunLinters