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 ci
npm run build --if-present
- name: Test
- name: Run CI
run: |
npm run lint
npm test

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -45,6 +45,7 @@ export interface DomIconProps<RequestData> extends IconProps {
value: RequestData;
}
/** Icon for library item type. */
export function ItemTypeIcon({ value, size = '1.25rem', className }: DomIconProps<LibraryItemType>) {
switch (value) {
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>) {
switch (value) {
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>) {
if (value) {
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>) {
if (value) {
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>) {
switch (value.substring(0, 2) as LocationHead) {
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>) {
switch (value) {
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>) {
switch (value) {
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>) {
switch (value) {
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>) {
switch (value) {
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>) {
if (value) {
return <IconMoveUp size={size} className={className ?? 'clr-text-primary'} />;

View File

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

View File

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

View File

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

View File

@ -3,9 +3,13 @@ import { globals } from '@/utils/constants';
import { LocationIcon } from '../DomainIcons';
interface BadgeLocationProps {
/** Location to display. */
location: string;
}
/**
* Displays location icon with a full text tooltip.
*/
function BadgeLocation({ location }: BadgeLocationProps) {
return (
<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';
interface BadgeWordFormProps {
keyPrefix?: string;
/** Word form to display. */
form: IWordForm;
/** Prefix for grammemes keys. */
keyPrefix?: string;
}
/**
* Displays a badge with grammemes of a word form.
*/
function BadgeWordForm({ keyPrefix, form }: BadgeWordFormProps) {
return (
<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}
className='clr-input rounded-t-md'
noBorder
value={filterText}
onChange={newValue => setFilterText(newValue)}
query={filterText}
onChangeQuery={newValue => setFilterText(newValue)}
/>
<DataTable
id={id}

View File

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

View File

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

View File

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

View File

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

View File

@ -4,12 +4,12 @@ import CheckboxTristate from '@/components/ui/CheckboxTristate';
interface SelectAllProps<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) {
setLastSelected(undefined);
resetLastSelected();
table.toggleAllPageRowsSelected(value !== false);
}

View File

@ -4,12 +4,12 @@ import Checkbox from '@/components/ui/Checkbox';
interface SelectRowProps<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) {
setLastSelected(row.id);
onChangeLastSelected(row.id);
row.toggleSelected(value);
}

View File

@ -14,7 +14,7 @@ interface TableBodyProps<TData> {
conditionalRowStyles?: IConditionalStyle<TData>[];
lastSelected: string | undefined;
setLastSelected: React.Dispatch<React.SetStateAction<string | undefined>>;
onChangeLastSelected: (newValue: string | undefined) => void;
onRowClicked?: (rowData: TData, event: CProps.EventMouse) => void;
onRowDoubleClicked?: (rowData: TData, event: CProps.EventMouse) => void;
@ -27,7 +27,7 @@ function TableBody<TData>({
enableRowSelection,
conditionalRowStyles,
lastSelected,
setLastSelected,
onChangeLastSelected,
onRowClicked,
onRowDoubleClicked
}: TableBodyProps<TData>) {
@ -49,9 +49,9 @@ function TableBody<TData>({
newSelection[row.id] = !target.getIsSelected();
});
table.setRowSelection(prev => ({ ...prev, ...newSelection }));
setLastSelected(undefined);
onChangeLastSelected(undefined);
} else {
setLastSelected(target.id);
onChangeLastSelected(target.id);
target.toggleSelected(!target.getIsSelected());
}
}
@ -83,7 +83,7 @@ function TableBody<TData>({
>
{enableRowSelection ? (
<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>
) : null}
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,9 @@ import clsx from 'clsx';
import { CProps } from '../props';
/**
* Wraps content in a div with a centered text.
*/
function NoData({ className, children, ...restProps }: CProps.Div) {
return (
<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';
interface OverlayProps extends CProps.Styling {
/** Id of the overlay. */
id?: string;
/** Classnames for position of the overlay. */
position?: string;
/** Classname for z-index of the overlay. */
layer?: string;
}
/**
* Displays a transparent overlay over the main content.
*/
function Overlay({
children,
className,

View File

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

View File

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

View File

@ -6,15 +6,37 @@ import Overlay from './Overlay';
import TextInput from './TextInput';
interface SearchBarProps extends CProps.Styling {
value: string;
noIcon?: boolean;
/** Id of the search bar. */
id?: string;
/** Search query. */
query: string;
/** Placeholder text. */
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;
}
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 (
<div {...restProps}>
{!noIcon ? (
@ -29,8 +51,8 @@ function SearchBar({ id, value, noIcon, onChange, noBorder, placeholder = 'По
type='search'
className={clsx('outline-none bg-transparent', !noIcon && 'pl-10')}
noBorder={noBorder}
value={value}
onChange={event => (onChange ? onChange(event.target.value) : undefined)}
value={query}
onChange={event => (onChangeQuery ? onChangeQuery(event.target.value) : undefined)}
/>
</div>
);

View File

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

View File

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

View File

@ -11,22 +11,38 @@ import MiniButton from './MiniButton';
import Overlay from './Overlay';
interface SelectTreeProps<ItemType> extends CProps.Styling {
items: ItemType[];
/** Current value. */
value: ItemType;
setValue: (newItem: ItemType) => void;
getParent: (item: ItemType) => ItemType;
getLabel: (item: ItemType) => string;
getDescription: (item: ItemType) => string;
/** List of available items. */
items: ItemType[];
/** Prefix for the ids of the elements. */
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>({
items,
value,
getParent,
getLabel,
getDescription,
setValue,
onChangeValue,
prefix,
...restProps
}: SelectTreeProps<ItemType>) {
@ -71,9 +87,9 @@ function SelectTree<ItemType>({
(event: CProps.EventMouse, target: ItemType) => {
event.preventDefault();
event.stopPropagation();
setValue(target);
onChangeValue(target);
},
[setValue]
[onChangeValue]
);
return (

View File

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

View File

@ -3,11 +3,19 @@ import clsx from 'clsx';
import { CProps } from '../props';
interface SubmitButtonProps extends CProps.Button {
/** Text to display in the button. */
text?: string;
loading?: boolean;
/** Icon to display in the button. */
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) {
return (
<button

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,11 +4,19 @@ import { CProps } from '../props';
import ValueIcon from './ValueIcon';
interface ValueStatsProps extends CProps.Styling, CProps.Titled {
/** Id of the component. */
id: string;
/** Icon to display. */
icon: React.ReactNode;
/** Value to display. */
value: string | number;
}
/**
* Displays statistics value with an icon.
*/
function ValueStats(props: ValueStatsProps) {
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}`}
layer='z-topmost'
place='right-start'
className='mt-8 max-w-[20rem]'
className='mt-8 max-w-[20rem] break-words'
/>
<Tooltip
float

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -170,7 +170,7 @@ function DlgEditWordForms({ hideWindow, target, onSave }: DlgEditWordFormsProps)
placeholder='Выберите граммемы'
className='min-w-[15rem] h-fit'
value={inputGrams}
setValue={setInputGrams}
onChangeValue={setInputGrams}
/>
</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';
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 Overlay from '@/components/ui/Overlay';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { HelpTopic } from '@/models/miscellaneous';
import { SyntaxTree } from '@/models/rslang';
import { graphDarkT, graphLightT } from '@/styling/color';
import { colorBgSyntaxTree } from '@/styling/color';
import { resources } from '@/utils/constants';
import { labelSyntaxTree } from '@/utils/labels';
import ASTFlow from './ASTFlow';
interface DlgShowASTProps extends Pick<ModalProps, 'hideWindow'> {
syntaxTree: SyntaxTree;
@ -19,36 +18,11 @@ interface DlgShowASTProps extends Pick<ModalProps, 'hideWindow'> {
}
function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
const { darkMode, colors } = useConceptOptions();
const { colors } = useConceptOptions();
const [hoverID, setHoverID] = useState<number | undefined>(undefined);
const hoverNode = useMemo(() => syntaxTree.find(node => node.uid === hoverID), [hoverID, syntaxTree]);
const nodes: GraphNode[] = useMemo(
() =>
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 handleHoverIn = useCallback((node: Node) => setHoverID(Number(node.id)), []);
const handleHoverOut = useCallback(() => setHoverID(undefined), []);
return (
@ -72,18 +46,9 @@ function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
</div>
) : null}
</Overlay>
<div className='flex-grow relative'>
<GraphUI
animated={false}
nodes={nodes}
edges={edges}
layoutType='hierarchicalTd'
labelFontUrl={resources.graph_font}
theme={darkMode ? graphDarkT : graphLightT}
onNodePointerOver={handleHoverIn}
onNodePointerOut={handleHoverOut}
/>
</div>
<ReactFlowProvider>
<ASTFlow data={syntaxTree} onNodeEnter={handleHoverIn} onNodeLeave={handleHoverOut} />
</ReactFlowProvider>
</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 { TMGraph } from '@/models/TMGraph';
import { PARAMETER } from '@/utils/constants';
import { TMGraphEdgeTypes } from './graph/MGraphEdgeTypes';
import { applyLayout } from './graph/MGraphLayout';
@ -15,7 +14,7 @@ interface MGraphFlowProps {
}
function MGraphFlow({ data }: MGraphFlowProps) {
const [nodes, setNodes] = useNodesState([]);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]);
const flow = useReactFlow();
@ -49,11 +48,10 @@ function MGraphFlow({ data }: MGraphFlowProps) {
});
});
applyLayout(newNodes);
applyLayout(newNodes, newEdges);
setNodes(newNodes);
setEdges(newEdges);
flow.fitView({ duration: PARAMETER.zoomDuration });
}, [data, setNodes, setEdges, flow]);
return (
@ -62,14 +60,13 @@ function MGraphFlow({ data }: MGraphFlowProps) {
edges={edges}
edgesFocusable={false}
nodesFocusable={false}
onNodesChange={onNodesChange}
nodeTypes={TMGraphNodeTypes}
edgeTypes={TMGraphEdgeTypes}
fitView
maxZoom={2}
minZoom={0.5}
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';
export function applyLayout(nodes: Node<TMGraphNode>[]) {
new LayoutManager(nodes).execute();
}
const UNIT_HEIGHT = 100;
const UNIT_WIDTH = 100;
const MIN_NODE_DISTANCE = 80;
const LAYOUT_ITERATIONS = 8;
class LayoutManager {
nodes: Node<TMGraphNode>[];
graph = new Graph();
ranks = new Map<number, number>();
posX = new Map<number, number>();
posY = new Map<number, number>();
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);
}
});
});
}
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)!
};
});
}
const NODE_WIDTH = 44;
const NODE_HEIGHT = 44;
const HOR_SEPARATION = 40;
const VERT_SEPARATION = 40;
const BOOLEAN_WEIGHT = 2;
const CARTESIAN_WEIGHT = 1;
export function applyLayout(nodes: Node<TMGraphNode>[], edges: Edge[]) {
const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({
rankdir: 'BT',
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, { 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;
});
}

View File

@ -4,10 +4,21 @@ import { useMemo } from 'react';
import { Handle, Position } from 'reactflow';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { MGraphNodeInternal } from '@/models/miscellaneous';
import { TMGraphNode } from '@/models/TMGraph';
import { colorBgTMGraphNode } from '@/styling/color';
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) {
const { colors } = useConceptOptions();

View File

@ -6,7 +6,6 @@ import { EdgeProps, Node } from 'reactflow';
import { LibraryItemType, LocationHead } from './library';
import { IOperation } from './oss';
import { TMGraphNode } from './TMGraph';
import { UserID } from './user';
/**
@ -46,17 +45,6 @@ export interface OssNodeInternal {
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.
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -54,7 +54,7 @@ function StatusBar({ isModified, processing, activeCst, parseData, onAnalyze }:
onClick={onAnalyze}
>
<AnimatePresence mode='wait'>
{processing ? <Loader key='status-loader' size={3} /> : null}
{processing ? <Loader key='status-loader' scale={3} /> : null}
{!processing ? (
<>
<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 MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
@ -10,9 +10,16 @@ interface ToolbarRSExpressionProps {
toggleControls: () => 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();
return (
@ -24,6 +31,11 @@ function ToolbarRSExpression({ disabled, showControls, toggleControls, showAST }
onClick={toggleControls}
/>
) : null}
<MiniButton
icon={<IconTypeGraph size='1.25rem' className='icon-primary' />}
title='Граф ступеней типизации'
onClick={showTypeGraph}
/>
<MiniButton
title='Дерево разбора выражения'
onClick={showAST}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -581,16 +581,16 @@ export function labelSyntaxTree(node: ISyntaxTreeNode): string {
case TokenID.NT_TUPLE: return 'TUPLE';
case TokenID.NT_ENUMERATION: return 'ENUM';
case TokenID.NT_ENUM_DECL: return 'ENUM_DECLARATION';
case TokenID.NT_TUPLE_DECL: return 'TUPLE_DECLARATION';
case TokenID.NT_ENUM_DECL: return 'ENUM_DECLARE';
case TokenID.NT_TUPLE_DECL: return 'TUPLE_DECLARE';
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_FUNC_CALL: return 'CALL';
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);

View File

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