Refactor UI elements

This commit is contained in:
IRBorisov 2023-12-08 19:24:08 +03:00
parent 286bb4f29d
commit da05bd6a12
37 changed files with 901 additions and 758 deletions

View File

@ -1,23 +1,29 @@
import { MagnifyingGlassIcon } from '../Icons';
import Overlay from './Overlay';
import TextInput from './TextInput';
interface ConceptSearchProps {
value: string
onChange?: (newValue: string) => void
dense?: boolean
noBorder?: boolean
dimensions?: string
}
function ConceptSearch({ value, onChange, dense }: ConceptSearchProps) {
const borderClass = dense ? 'border-t border-x': '';
function ConceptSearch({ value, onChange, noBorder, dimensions, dense }: ConceptSearchProps) {
const borderClass = dense && !noBorder ? 'border-t border-x': '';
return (
<div className='relative'>
<div className='absolute inset-y-0 flex items-center pl-3 pointer-events-none text-controls'>
<MagnifyingGlassIcon />
</div>
<div className={dimensions}>
<Overlay
position='top-0 left-3 translate-y-1/2'
className='flex items-center pointer-events-none text-controls'
>
<MagnifyingGlassIcon size={5} />
</Overlay>
<TextInput noOutline
placeholder='Поиск'
dimensions={`w-full pl-10 ${borderClass}`}
noBorder={dense}
dimensions={`w-full pl-10 hover:text-clip outline-none ${borderClass}`}
noBorder={dense || noBorder}
value={value}
onChange={event => (onChange ? onChange(event.target.value) : undefined)}
/>

View File

@ -4,7 +4,7 @@ import Select, { GroupBase, Props, StylesConfig } from 'react-select';
import { useConceptTheme } from '../../context/ThemeContext';
import { selectDarkT, selectLightT } from '../../utils/color';
interface SelectMultiProps<
export interface SelectMultiProps<
Option,
Group extends GroupBase<Option> = GroupBase<Option>
>

View File

@ -19,7 +19,7 @@ function TextInput({
colors = 'clr-input',
...restProps
}: TextInputProps) {
const borderClass = noBorder ? '' : 'border';
const borderClass = noBorder ? '' : 'border px-3';
const outlineClass = noOutline ? '' : 'clr-outline';
return (
<div className={`flex ${dense ? 'items-center gap-4 ' + dimensions : 'flex-col items-start gap-2'}`}>
@ -31,7 +31,7 @@ function TextInput({
<input id={id}
title={tooltip}
onKeyDown={!allowEnter && !onKeyDown ? preventEnterCapture : onKeyDown}
className={`px-3 py-2 leading-tight truncate hover:text-clip ${colors} ${outlineClass} ${borderClass} ${dense ? 'w-full' : dimensions}`}
className={`py-2 leading-tight truncate hover:text-clip ${colors} ${outlineClass} ${borderClass} ${dense ? 'w-full' : dimensions}`}
{...restProps}
/>
</div>);

View File

@ -4,16 +4,19 @@ interface TextURLProps {
text: string
tooltip?: string
href?: string
color?: string
onClick?: () => void
}
function TextURL({ text, href, tooltip, onClick }: TextURLProps) {
function TextURL({ text, href, tooltip, color='text-url', onClick }: TextURLProps) {
const design = `cursor-pointer hover:underline ${color}`;
if (href) {
return (
<Link
className='cursor-pointer hover:underline text-url'
className={design}
title={tooltip}
to={href}
tabIndex={-1}
>
{text}
</Link>
@ -21,8 +24,9 @@ function TextURL({ text, href, tooltip, onClick }: TextURLProps) {
} else if (onClick) {
return (
<span
className='cursor-pointer hover:underline text-url'
className={design}
onClick={onClick}
tabIndex={-1}
>
{text}
</span>);

View File

@ -1,23 +1,19 @@
import { Link } from 'react-router-dom';
import { urls } from '../utils/constants';
import TextURL from './Common/TextURL';
function Footer() {
return (
<footer className='px-4 py-2 text-sm select-none z-navigation whitespace-nowrap clr-footer'>
<div className='justify-center w-full mx-auto'>
<div className='mb-2 text-center'>
<Link className='mx-2 hover:underline' to='/library' tabIndex={-1}>Библиотека</Link>
<Link className='mx-2 hover:underline' to='/manuals' tabIndex={-1}>Справка</Link>
<Link className='mx-2 hover:underline' to={urls.concept} tabIndex={-1}>Центр Концепт</Link>
<Link className='mx-2 hover:underline' to='/manuals?topic=exteor' tabIndex={-1}>Экстеор</Link>
</div>
<div>
<p className='mt-0.5 text-center'>© 2023 ЦИВТ КОНЦЕПТ</p>
</div>
<footer tabIndex={-1} className='flex flex-col items-center w-full gap-1 px-4 py-2 text-sm select-none z-navigation whitespace-nowrap'>
<div className='flex gap-3 text-center'>
<TextURL text='Библиотека' href='/library' color='clr-footer'/>
<TextURL text='Справка' href='/manuals' color='clr-footer'/>
<TextURL text='Центр Концепт' href={urls.concept} color='clr-footer'/>
<TextURL text='Экстеор' href='/manuals?topic=exteor' color='clr-footer'/>
</div>
<div>
<p className='mt-0.5 text-center clr-footer'>© 2023 ЦИВТ КОНЦЕПТ</p>
</div>
</footer>);
}
export default Footer;
export default Footer;

View File

@ -28,9 +28,9 @@ function IconSVG({ viewbox, size = 6, color, props, children }: IconSVGProps) {
</svg>);
}
export function MagnifyingGlassIcon({ size, ...props }: IconProps) {
export function MagnifyingGlassIcon(props: IconProps) {
return (
<IconSVG viewbox='0 0 20 20' size={size ?? 5} {...props} >
<IconSVG viewbox='0 0 20 20' {...props} >
<path d='M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z'/>
</IconSVG>
);

View File

@ -8,7 +8,7 @@ import UserMenu from './UserMenu';
function Navigation () {
const { navigateTo } = useConceptNavigation();
const { noNavigation, toggleNoNavigation } = useConceptTheme();
const { noNavigation } = useConceptTheme();
const navigateLibrary = () => navigateTo('/library');
const navigateHelp = () => navigateTo('/manuals');
@ -16,7 +16,7 @@ function Navigation () {
return (
<nav className='sticky top-0 left-0 right-0 select-none clr-app z-navigation h-fit'>
<ToggleNavigationButton noNavigation={noNavigation} toggleNoNavigation={toggleNoNavigation} />
<ToggleNavigationButton />
{!noNavigation ?
<div className='flex items-stretch justify-between pl-2 pr-[0.8rem] border-b-2 rounded-none h-[3rem]'>
<div className='flex items-center justify-start'>

View File

@ -1,28 +1,23 @@
interface ToggleNavigationButtonProps {
noNavigation?: boolean
toggleNoNavigation: () => void
}
import { useMemo } from 'react';
function ToggleNavigationButton({ noNavigation, toggleNoNavigation }: ToggleNavigationButtonProps) {
if (noNavigation) {
return (
<button type='button' tabIndex={-1}
title='Показать навигацию'
className='absolute top-0 right-0 z-navigation px-1 h-[1.6rem] border-b-2 border-l-2 clr-btn-nav rounded-none'
onClick={toggleNoNavigation}
>
{''}
</button>);
} else {
return (
<button type='button' tabIndex={-1}
title='Скрыть навигацию'
className='absolute top-0 right-0 z-navigation w-[1.2rem] h-[3rem] border-b-2 border-l-2 clr-btn-nav rounded-none'
onClick={toggleNoNavigation}
>
<p>{'>'}</p><p>{'>'}</p>
</button>);
}
import { useConceptTheme } from '../../context/ThemeContext';
function ToggleNavigationButton() {
const { noNavigation, toggleNoNavigation } = useConceptTheme();
const dimensions = useMemo(() => (noNavigation ? 'px-1 h-[1.6rem]' : 'w-[1.2rem] h-[3rem]'), [noNavigation]);
const text = useMemo(() => (
noNavigation ? '' : <><p>{'>'}</p><p>{'>'}</p></>), [noNavigation]
);
const tooltip = useMemo(() => (noNavigation ? 'Показать навигацию' : 'Скрыть навигацию'), [noNavigation]);
return (
<button type='button' tabIndex={-1}
title={tooltip}
className={`absolute top-0 right-0 border-b-2 border-l-2 rounded-none z-navigation clr-btn-nav ${dimensions}`}
onClick={toggleNoNavigation}
>
{text}
</button>);
}
export default ToggleNavigationButton;

View File

@ -6,7 +6,6 @@ import { TokenID } from '../../models/rslang';
import { CodeMirrorWrapper } from '../../utils/codemirror';
export function getSymbolSubstitute(keyCode: string, shiftPressed: boolean): string | undefined {
console.log(keyCode);
if (shiftPressed) {
switch (keyCode) {
case 'Backquote': return '∃';

View File

@ -81,7 +81,7 @@ function ConstituentaPicker({
}], [value, colors]);
return (
<div>
<>
<ConceptSearch dense
value={filterText}
onChange={newValue => setFilterText(newValue)}
@ -103,7 +103,7 @@ function ConstituentaPicker({
onRowClicked={onSelectValue}
/>
</div>
</div>);
</>);
}
export default ConstituentaPicker;

View File

@ -0,0 +1,29 @@
import { useConceptTheme } from '../../context/ThemeContext';
import { GramData } from '../../models/language';
import { colorfgGrammeme } from '../../utils/color';
import { labelGrammeme } from '../../utils/labels';
interface GrammemeBadgeProps {
key?: string
grammeme: GramData
}
function GrammemeBadge({ key, grammeme }: GrammemeBadgeProps) {
const { colors } = useConceptTheme();
return (
<div
key={key}
className='min-w-[3rem] px-1 text-sm text-center rounded-md whitespace-nowrap'
style={{
borderWidth: '1px',
borderColor: colorfgGrammeme(grammeme, colors),
color: colorfgGrammeme(grammeme, colors),
fontWeight: 600,
backgroundColor: colors.bgInput
}}
>
{labelGrammeme(grammeme)}
</div>);
}
export default GrammemeBadge;

View File

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { Grammeme } from '../../models/language';
import { getCompatibleGrams } from '../../models/languageAPI';
import { compareGrammemeOptions,IGrammemeOption, SelectorGrammems } from '../../utils/selectors';
import SelectMulti, { SelectMultiProps } from '../Common/SelectMulti';
interface SelectGrammemeProps extends
Omit<SelectMultiProps<IGrammemeOption>, 'value' | 'onChange'> {
value: IGrammemeOption[]
setValue: React.Dispatch<React.SetStateAction<IGrammemeOption[]>>
dimensions?: string
className?: string
placeholder?: string
}
function SelectGrammeme({
value, setValue,
dimensions, className, placeholder
}: SelectGrammemeProps) {
const [options, setOptions] = useState<IGrammemeOption[]>([]);
useEffect(
() => {
const compatible = getCompatibleGrams(
value
.filter(data => Object.values(Grammeme).includes(data.value as Grammeme))
.map(data => data.value as Grammeme)
);
setOptions(SelectorGrammems.filter(({value}) => compatible.includes(value as Grammeme)));
}, [value]);
return (
<SelectMulti
className={`${dimensions} ${className}`}
options={options}
placeholder={placeholder}
value={value}
onChange={newValue => setValue([...newValue].sort(compareGrammemeOptions))}
/>);
}
export default SelectGrammeme;

View File

@ -0,0 +1,22 @@
import { IWordForm } from '../../models/language';
import GrammemeBadge from './GrammemeBadge';
interface WordFormBadgeProps {
keyPrefix?: string
form: IWordForm
}
function WordFormBadge({ keyPrefix, form }: WordFormBadgeProps) {
return (
<div className='flex flex-wrap justify-start gap-1 select-none'>
{form.grams.map(
(gram) =>
<GrammemeBadge
key={`${keyPrefix}-${gram}`}
grammeme={gram}
/>
)}
</div>);
}
export default WordFormBadge;

View File

@ -1,18 +1,18 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import Label from '../../components/Common/Label';
import SelectMulti from '../../components/Common/SelectMulti';
import TextInput from '../../components/Common/TextInput';
import ConstituentaPicker from '../../components/Shared/ConstituentaPicker';
import { Grammeme, ReferenceType } from '../../models/language';
import { getCompatibleGrams, parseEntityReference, parseGrammemes } from '../../models/languageAPI';
import SelectGrammeme from '../../components/Shared/SelectGrammeme';
import { ReferenceType } from '../../models/language';
import { parseEntityReference, parseGrammemes } from '../../models/languageAPI';
import { CstMatchMode } from '../../models/miscelanious';
import { IConstituenta } from '../../models/rsform';
import { matchConstituenta } from '../../models/rsformAPI';
import { prefixes } from '../../utils/constants';
import { compareGrammemeOptions,IGrammemeOption, SelectorGrammems } from '../../utils/selectors';
import { IGrammemeOption, SelectorGrammems } from '../../utils/selectors';
import { IReferenceInputState } from './DlgEditReference';
import SelectTermform from './SelectTermform';
import SelectWordForm from './SelectWordForm';
interface EntityTabProps {
initial: IReferenceInputState
@ -25,9 +25,7 @@ function EntityTab({ initial, items, setIsValid, setReference }: EntityTabProps)
const [selectedCst, setSelectedCst] = useState<IConstituenta | undefined>(undefined);
const [alias, setAlias] = useState('');
const [term, setTerm] = useState('');
const [selectedGrams, setSelectedGrams] = useState<IGrammemeOption[]>([]);
const [gramOptions, setGramOptions] = useState<IGrammemeOption[]>([]);
// Initialization
useLayoutEffect(
@ -46,17 +44,6 @@ function EntityTab({ initial, items, setIsValid, setReference }: EntityTabProps)
setIsValid(alias !== '' && selectedGrams.length > 0);
setReference(`@{${alias}|${selectedGrams.map(gram => gram.value).join(',')}}`);
}, [alias, selectedGrams, setIsValid, setReference]);
// Filter grammemes when input changes
useEffect(
() => {
const compatible = getCompatibleGrams(
selectedGrams
.filter(data => Object.values(Grammeme).includes(data.value as Grammeme))
.map(data => data.value as Grammeme)
);
setGramOptions(SelectorGrammems.filter(({value}) => compatible.includes(value as Grammeme)));
}, [selectedGrams]);
// Update term when alias changes
useEffect(
@ -91,30 +78,28 @@ function EntityTab({ initial, items, setIsValid, setReference }: EntityTabProps)
value={alias}
onChange={event => setAlias(event.target.value)}
/>
<div className='flex items-center w-full flex-start'>
<Label text='Термин' />
<TextInput disabled dense noBorder
value={term}
tooltip={term}
dimensions='w-full text-sm'
/>
</div>
<TextInput disabled dense noBorder
label='Термин'
value={term}
tooltip={term}
dimensions='w-full text-sm'
/>
</div>
<SelectTermform
<SelectWordForm
selected={selectedGrams}
setSelected={setSelectedGrams}
/>
<div className='flex items-center gap-4 flex-start'>
<Label text='Отсылаемая словоформа'/>
<SelectMulti
<SelectGrammeme
placeholder='Выберите граммемы'
className='flex-grow h-full'
dimensions='h-full '
className='flex-grow'
menuPlacement='top'
options={gramOptions}
value={selectedGrams}
onChange={newValue => setSelectedGrams([...newValue].sort(compareGrammemeOptions))}
setValue={setSelectedGrams}
/>
</div>
</div>);

View File

@ -5,12 +5,12 @@ import { prefixes } from '../../utils/constants';
import { IGrammemeOption, PremadeWordForms, SelectorGrammems } from '../../utils/selectors';
import WordformButton from './WordformButton';
interface SelectTermformProps {
interface SelectWordFormProps {
selected: IGrammemeOption[]
setSelected: React.Dispatch<React.SetStateAction<IGrammemeOption[]>>
}
function SelectTermform({ selected, setSelected }: SelectTermformProps) {
function SelectWordForm({ selected, setSelected }: SelectWordFormProps) {
const handleSelect = useCallback(
(grams: Grammeme[]) => {
setSelected(SelectorGrammems.filter(({value}) => grams.includes(value as Grammeme)));
@ -42,4 +42,4 @@ function SelectTermform({ selected, setSelected }: SelectTermformProps) {
</div>);
}
export default SelectTermform;
export default SelectWordForm;

View File

@ -1,316 +0,0 @@
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import Label from '../components/Common/Label';
import MiniButton from '../components/Common/MiniButton';
import Modal from '../components/Common/Modal';
import Overlay from '../components/Common/Overlay';
import SelectMulti from '../components/Common/SelectMulti';
import TextArea from '../components/Common/TextArea';
import DataTable, { createColumnHelper } from '../components/DataTable';
import HelpButton from '../components/Help/HelpButton';
import { ArrowLeftIcon, ArrowRightIcon, CheckIcon, ChevronDoubleDownIcon, CrossIcon } from '../components/Icons';
import { useConceptTheme } from '../context/ThemeContext';
import useConceptText from '../hooks/useConceptText';
import { Grammeme, ITextRequest, IWordForm, IWordFormPlain } from '../models/language';
import { getCompatibleGrams, parseGrammemes,wordFormEquals } from '../models/languageAPI';
import { HelpTopic } from '../models/miscelanious';
import { IConstituenta, TermForm } from '../models/rsform';
import { colorfgGrammeme } from '../utils/color';
import { labelGrammeme } from '../utils/labels';
import { compareGrammemeOptions,IGrammemeOption, SelectorGrammemesList, SelectorGrammems } from '../utils/selectors';
interface DlgEditWordFormsProps {
hideWindow: () => void
target: IConstituenta
onSave: (data: TermForm[]) => void
}
const columnHelper = createColumnHelper<IWordForm>();
function DlgEditWordForms({ hideWindow, target, onSave }: DlgEditWordFormsProps) {
const textProcessor = useConceptText();
const { colors } = useConceptTheme();
const [term, setTerm] = useState('');
const [inputText, setInputText] = useState('');
const [inputGrams, setInputGrams] = useState<IGrammemeOption[]>([]);
const [options, setOptions] = useState<IGrammemeOption[]>([]);
const [forms, setForms] = useState<IWordForm[]>([]);
function getData(): TermForm[] {
const result: TermForm[] = [];
forms.forEach(
({text, grams}) => result.push({
text: text,
tags: grams.join(',')
}));
return result;
}
// Initialization
useLayoutEffect(
() => {
const initForms: IWordForm[] = [];
target.term_forms.forEach(
term => initForms.push({
text: term.text,
grams: parseGrammemes(term.tags),
}));
setForms(initForms);
setTerm(target.term_resolved);
setInputText(target.term_resolved);
setInputGrams([]);
}, [target]);
// Filter grammemes when input changes
useEffect(
() => {
const compatible = getCompatibleGrams(
inputGrams
.filter(data => Object.values(Grammeme).includes(data.value as Grammeme))
.map(data => data.value as Grammeme)
);
setOptions(SelectorGrammems.filter(({value}) => compatible.includes(value as Grammeme)));
}, [inputGrams]);
const handleSubmit = () => onSave(getData());
function handleAddForm() {
const newForm: IWordForm = {
text: inputText,
grams: inputGrams.map(item => item.value)
};
setForms(forms => [
newForm,
...forms.filter(value => !wordFormEquals(value, newForm))
]);
}
function handleDeleteRow(row: number) {
setForms(
(prev) => {
const newForms: IWordForm[] = [];
prev.forEach(
(form, index) => {
if (index !== row) {
newForms.push(form);
}
});
return newForms;
});
}
function handleRowClicked(form: IWordForm) {
setInputText(form.text);
setInputGrams(SelectorGrammems.filter(gram => form.grams.find(test => test === gram.value)));
}
function handleResetAll() {
setForms([]);
}
function handleInflect() {
const data: IWordFormPlain = {
text: term,
grams: inputGrams.map(gram => gram.value).join(',')
}
textProcessor.inflect(data, response => setInputText(response.result));
}
function handleParse() {
const data: ITextRequest = {
text: inputText
}
textProcessor.parse(data, response => {
const grams = parseGrammemes(response.result);
setInputGrams(SelectorGrammems.filter(gram => grams.find(test => test === gram.value)));
});
}
function handleGenerateLexeme() {
if (forms.length > 0) {
if (!window.confirm('Данное действие приведет к перезаписи словоформ при совпадении граммем. Продолжить?')) {
return;
}
}
const data: ITextRequest = {
text: inputText
}
textProcessor.generateLexeme(data, response => {
const lexeme: IWordForm[] = [];
response.items.forEach(
form => {
const newForm: IWordForm = {
text: form.text,
grams: parseGrammemes(form.grams).filter(gram => SelectorGrammemesList.find(item => item === gram as Grammeme))
}
if (newForm.grams.length === 2 && !lexeme.some(test => wordFormEquals(test, newForm))) {
lexeme.push(newForm);
}
});
setForms(lexeme);
});
}
const columns = useMemo(
() => [
columnHelper.accessor('text', {
id: 'text',
header: 'Текст',
size: 350,
minSize: 350,
maxSize: 350,
cell: props => <div className='min-w-[20rem]'>{props.getValue()}</div>
}),
columnHelper.accessor('grams', {
id: 'grams',
header: 'Граммемы',
size: 250,
minSize: 250,
maxSize: 250,
cell: props =>
<div className='flex flex-wrap justify-start gap-1 select-none'>
{props.getValue().map(
(gram) =>
<div
key={`${props.cell.id}-${gram}`}
className='min-w-[3rem] px-1 text-sm text-center rounded-md whitespace-nowrap'
style={{
borderWidth: '1px',
borderColor: colorfgGrammeme(gram, colors),
color: colorfgGrammeme(gram, colors),
fontWeight: 600,
backgroundColor: colors.bgInput
}}
>
{labelGrammeme(gram)}
</div>
)}
</div>
}),
columnHelper.display({
id: 'actions',
size: 50,
minSize: 50,
maxSize: 50,
cell: props =>
<div>
<MiniButton noHover
tooltip='Удалить словоформу'
icon={<CrossIcon size={4} color='text-warning'/>}
onClick={() => handleDeleteRow(props.row.index)}
/>
</div>
})
], [colors]);
return (
<Modal canSubmit
title='Редактирование словоформ'
hideWindow={hideWindow}
submitText='Сохранить'
onSubmit={handleSubmit}
className='min-w-[40rem] max-w-[40rem] px-6'
>
<Overlay position='top-[-0.2rem] left-[7.5rem]'>
<HelpButton topic={HelpTopic.TERM_CONTROL} dimensions='max-w-[38rem]' offset={3} />
</Overlay>
<TextArea disabled spellCheck
label='Начальная форма'
placeholder='Термин в начальной форме'
rows={1}
value={term}
/>
<div className='mt-3 mb-2'>
<Label text='Параметры словоформы' />
</div>
<div className='flex items-start justify-between w-full'>
<div className='flex items-center'>
<TextArea
placeholder='Введите текст'
dimensions='min-w-[18rem] w-full min-h-[5rem]'
rows={2}
value={inputText}
onChange={event => setInputText(event.target.value)}
/>
<div className='max-w-min'>
<MiniButton
tooltip='Генерировать словоформу'
icon={<ArrowLeftIcon size={5} color={inputGrams.length == 0 ? 'text-disabled' : 'text-primary'} />}
disabled={textProcessor.loading || inputGrams.length == 0}
onClick={handleInflect}
/>
<MiniButton
tooltip='Определить граммемы'
icon={<ArrowRightIcon
size={5}
color={!inputText ? 'text-disabled' : 'text-primary'}
/>}
disabled={textProcessor.loading || !inputText}
onClick={handleParse}
/>
</div>
</div>
<SelectMulti
className='min-w-[20rem] max-w-[20rem] h-full flex-grow'
options={options}
placeholder='Выберите граммемы'
value={inputGrams}
onChange={newValue => setInputGrams([...newValue].sort(compareGrammemeOptions))}
/>
</div>
<div className='flex items-center justify-between mt-2 mb-1 flex-start'>
<div className='flex items-center justify-start'>
<MiniButton
tooltip='Внести словоформу'
icon={<CheckIcon
size={5}
color={!inputText || inputGrams.length == 0 ? 'text-disabled' : 'text-success'}
/>}
disabled={textProcessor.loading || !inputText || inputGrams.length == 0}
onClick={handleAddForm}
/>
<MiniButton
tooltip='Генерировать стандартные словоформы'
icon={<ChevronDoubleDownIcon
size={5}
color={!inputText ? 'text-disabled' : 'text-primary'}
/>}
disabled={textProcessor.loading || !inputText}
onClick={handleGenerateLexeme}
/>
</div>
<div className='w-full text-sm font-semibold text-center'>
Заданные вручную словоформы [{forms.length}]
</div>
<MiniButton
tooltip='Сбросить все словоформы'
icon={<CrossIcon size={5} color={forms.length === 0 ? 'text-disabled' : 'text-warning'} />}
disabled={textProcessor.loading || forms.length === 0}
onClick={handleResetAll}
/>
</div>
<div className='border overflow-y-auto max-h-[17.4rem] min-h-[17.4rem] mb-2'>
<DataTable dense noFooter
data={forms}
columns={columns}
headPosition='0'
noDataComponent={
<span className='flex flex-col justify-center p-2 text-center min-h-[2rem]'>
<p>Список пуст</p>
<p>Добавьте словоформу</p>
</span>
}
onRowClicked={handleRowClicked}
/>
</div>
</Modal>);
}
export default DlgEditWordForms;

View File

@ -0,0 +1,209 @@
import { useLayoutEffect, useState } from 'react';
import Label from '../../components/Common/Label';
import MiniButton from '../../components/Common/MiniButton';
import Modal from '../../components/Common/Modal';
import Overlay from '../../components/Common/Overlay';
import TextArea from '../../components/Common/TextArea';
import HelpButton from '../../components/Help/HelpButton';
import { ArrowLeftIcon, ArrowRightIcon, CheckIcon, ChevronDoubleDownIcon } from '../../components/Icons';
import SelectGrammeme from '../../components/Shared/SelectGrammeme';
import useConceptText from '../../hooks/useConceptText';
import { Grammeme, ITextRequest, IWordForm, IWordFormPlain } from '../../models/language';
import { parseGrammemes, wordFormEquals } from '../../models/languageAPI';
import { HelpTopic } from '../../models/miscelanious';
import { IConstituenta, TermForm } from '../../models/rsform';
import { IGrammemeOption, SelectorGrammemesList, SelectorGrammems } from '../../utils/selectors';
import WordFormsTable from './WordFormsTable';
interface DlgEditWordFormsProps {
hideWindow: () => void
target: IConstituenta
onSave: (data: TermForm[]) => void
}
function DlgEditWordForms({ hideWindow, target, onSave }: DlgEditWordFormsProps) {
const textProcessor = useConceptText();
const [term, setTerm] = useState('');
const [inputText, setInputText] = useState('');
const [inputGrams, setInputGrams] = useState<IGrammemeOption[]>([]);
const [forms, setForms] = useState<IWordForm[]>([]);
useLayoutEffect(
() => {
const initForms: IWordForm[] = [];
target.term_forms.forEach(
term => initForms.push({
text: term.text,
grams: parseGrammemes(term.tags),
}));
setForms(initForms);
setTerm(target.term_resolved);
setInputText(target.term_resolved);
setInputGrams([]);
}, [target]);
function handleSubmit() {
const result: TermForm[] = [];
forms.forEach(
({text, grams}) => result.push({
text: text,
tags: grams.join(',')
}));
onSave(result);
}
function handleAddForm() {
const newForm: IWordForm = {
text: inputText,
grams: inputGrams.map(item => item.value)
};
setForms(forms => [
newForm,
...forms.filter(value => !wordFormEquals(value, newForm))
]);
}
function handleSelectForm(form: IWordForm) {
setInputText(form.text);
setInputGrams(SelectorGrammems.filter(gram => form.grams.find(test => test === gram.value)));
}
function handleInflect() {
const data: IWordFormPlain = {
text: term,
grams: inputGrams.map(gram => gram.value).join(',')
}
textProcessor.inflect(data, response => setInputText(response.result));
}
function handleParse() {
const data: ITextRequest = {
text: inputText
}
textProcessor.parse(data, response => {
const grams = parseGrammemes(response.result);
setInputGrams(SelectorGrammems.filter(gram => grams.find(test => test === gram.value)));
});
}
function handleGenerateLexeme() {
if (forms.length > 0) {
if (!window.confirm('Данное действие приведет к перезаписи словоформ при совпадении граммем. Продолжить?')) {
return;
}
}
const data: ITextRequest = {
text: inputText
}
textProcessor.generateLexeme(data, response => {
const lexeme: IWordForm[] = [];
response.items.forEach(
form => {
const newForm: IWordForm = {
text: form.text,
grams: parseGrammemes(form.grams).filter(gram => SelectorGrammemesList.find(item => item === gram as Grammeme))
}
if (newForm.grams.length === 2 && !lexeme.some(test => wordFormEquals(test, newForm))) {
lexeme.push(newForm);
}
});
setForms(lexeme);
});
}
return (
<Modal canSubmit
title='Редактирование словоформ'
hideWindow={hideWindow}
submitText='Сохранить'
onSubmit={handleSubmit}
className='min-w-[40rem] max-w-[40rem] px-6'
>
<Overlay position='top-[-0.2rem] left-[7.5rem]'>
<HelpButton topic={HelpTopic.TERM_CONTROL} dimensions='max-w-[38rem]' offset={3} />
</Overlay>
<TextArea disabled spellCheck
label='Начальная форма'
placeholder='Термин в начальной форме'
rows={1}
value={term}
/>
<div className='mt-3 mb-2'>
<Label text='Параметры словоформы' />
</div>
<div className='flex items-start justify-between w-full'>
<div className='flex items-center'>
<TextArea
placeholder='Введите текст'
dimensions='min-w-[20rem] w-full min-h-[5rem]'
rows={2}
value={inputText}
onChange={event => setInputText(event.target.value)}
/>
<div className='max-w-min'>
<MiniButton
tooltip='Генерировать словоформу'
icon={<ArrowLeftIcon size={5} color={inputGrams.length == 0 ? 'text-disabled' : 'text-primary'} />}
disabled={textProcessor.loading || inputGrams.length == 0}
onClick={handleInflect}
/>
<MiniButton
tooltip='Определить граммемы'
icon={<ArrowRightIcon
size={5}
color={!inputText ? 'text-disabled' : 'text-primary'}
/>}
disabled={textProcessor.loading || !inputText}
onClick={handleParse}
/>
</div>
</div>
<SelectGrammeme
placeholder='Выберите граммемы'
dimensions='min-w-[15rem] max-w-[15rem] h-full '
className='flex-grow'
value={inputGrams}
setValue={setInputGrams}
/>
</div>
<Overlay position='top-2 left-0'>
<MiniButton
tooltip='Внести словоформу'
icon={<CheckIcon
size={5}
color={!inputText || inputGrams.length == 0 ? 'text-disabled' : 'text-success'}
/>}
disabled={textProcessor.loading || !inputText || inputGrams.length == 0}
onClick={handleAddForm}
/>
<MiniButton
tooltip='Генерировать стандартные словоформы'
icon={<ChevronDoubleDownIcon
size={5}
color={!inputText ? 'text-disabled' : 'text-primary'}
/>}
disabled={textProcessor.loading || !inputText}
onClick={handleGenerateLexeme}
/>
</Overlay>
<h1 className='mt-3 mb-2 text-sm'>Заданные вручную словоформы [{forms.length}]</h1>
<div className='border overflow-y-auto max-h-[17.4rem] min-h-[17.4rem] mb-2'>
<WordFormsTable
forms={forms}
setForms={setForms}
onFormSelect={handleSelectForm}
loading={textProcessor.loading}
/>
</div>
</Modal>);
}
export default DlgEditWordForms;

View File

@ -0,0 +1,100 @@
import { useCallback, useMemo } from 'react';
import MiniButton from '../../components/Common/MiniButton';
import Overlay from '../../components/Common/Overlay';
import DataTable, { createColumnHelper } from '../../components/DataTable';
import { CrossIcon } from '../../components/Icons';
import WordFormBadge from '../../components/Shared/WordFormBadge';
import { IWordForm } from '../../models/language';
interface WordFormsTableProps {
forms: IWordForm[]
setForms: React.Dispatch<React.SetStateAction<IWordForm[]>>
onFormSelect?: (form: IWordForm) => void
loading?: boolean
}
const columnHelper = createColumnHelper<IWordForm>();
function WordFormsTable({ forms, setForms, onFormSelect, loading }: WordFormsTableProps) {
const handleDeleteRow = useCallback(
(row: number) => {
setForms(
(prev) => {
const newForms: IWordForm[] = [];
prev.forEach(
(form, index) => {
if (index !== row) {
newForms.push(form);
}
});
return newForms;
});
}, [setForms]);
function handleResetAll() {
setForms([]);
}
const columns = useMemo(
() => [
columnHelper.accessor('text', {
id: 'text',
header: 'Текст',
size: 350,
minSize: 350,
maxSize: 350,
cell: props => <div className='min-w-[20rem]'>{props.getValue()}</div>
}),
columnHelper.accessor('grams', {
id: 'grams',
header: 'Граммемы',
size: 250,
minSize: 250,
maxSize: 250,
cell: props =>
<WordFormBadge
keyPrefix={props.cell.id}
form={props.row.original}
/>
}),
columnHelper.display({
id: 'actions',
size: 50,
minSize: 50,
maxSize: 50,
cell: props =>
<MiniButton noHover
tooltip='Удалить словоформу'
icon={<CrossIcon size={4} color='text-warning'/>}
onClick={() => handleDeleteRow(props.row.index)}
/>
})
], [handleDeleteRow]);
return (
<>
<Overlay position='top-1 right-4'>
<MiniButton
tooltip='Сбросить все словоформы'
icon={<CrossIcon size={4} color={forms.length === 0 ? 'text-disabled' : 'text-warning'} />}
disabled={loading || forms.length === 0}
onClick={handleResetAll}
/>
</Overlay>
<DataTable dense noFooter
data={forms}
columns={columns}
headPosition='0'
noDataComponent={
<span className='flex flex-col justify-center p-2 text-center min-h-[2rem]'>
<p>Список пуст</p>
<p>Добавьте словоформу</p>
</span>
}
onRowClicked={onFormSelect}
/>
</>);
}
export default WordFormsTable;

View File

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

View File

@ -91,11 +91,6 @@ export function substituteTemplateArgs(expression: string, args: IArgumentValue[
.every(local => local.every(match => !(match in mapping)))
).join(', ');
console.log(body);
console.log(head);
console.log(args);
console.log(mapping);
if (!head) {
return body;
} else {

View File

@ -1,7 +1,7 @@
import { useCallback, useLayoutEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { MagnifyingGlassIcon } from '../../components/Icons';
import ConceptSearch from '../../components/Common/ConceptSearch';
import { useAuth } from '../../context/AuthContext';
import { useConceptNavigation } from '../../context/NagivationContext';
import { ILibraryFilter } from '../../models/miscelanious';
@ -35,8 +35,7 @@ function SearchPanel({ total, filtered, query, setQuery, strategy, setStrategy,
const search = useLocation().search;
const { user } = useAuth();
function handleChangeQuery(event: React.ChangeEvent<HTMLInputElement>) {
const newQuery = event.target.value;
function handleChangeQuery(newQuery: string) {
setQuery(newQuery);
setFilter(prev => ({
query: newQuery,
@ -77,17 +76,11 @@ function SearchPanel({ total, filtered, query, setQuery, strategy, setStrategy,
</span>
</div>
<div className='flex items-center justify-center w-full gap-1'>
<div className='relative min-w-[10rem] select-none'>
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-controls'>
<MagnifyingGlassIcon />
</div>
<input
placeholder='Поиск'
value={query}
className='w-full p-2 pl-10 text-sm outline-none clr-input'
onChange={handleChangeQuery}
/>
</div>
<ConceptSearch noBorder
value={query}
onChange={handleChangeQuery}
dimensions='min-w-[10rem] '
/>
<PickerStrategy
value={strategy}
onChange={handleChangeStrategy}

View File

@ -5,9 +5,9 @@ import useWindowSize from '../../../hooks/useWindowSize';
import { CstType, IConstituenta, ICstCreateData, ICstRenameData } from '../../../models/rsform';
import { SyntaxTree } from '../../../models/rslang';
import { globalIDs } from '../../../utils/constants';
import ViewConstituents from '../ViewConstituents';
import ConstituentaToolbar from './ConstituentaToolbar';
import FormConstituenta from './FormConstituenta';
import ViewSideConstituents from './ViewSideConstituents';
// Max height of content for left enditor pane.
const UNFOLDED_HEIGHT = '59.1rem';
@ -117,11 +117,7 @@ function EditorConstituenta({
return false;
}
return (
<div tabIndex={-1}
className='max-w-[1500px]'
onKeyDown={handleInput}
>
return (<>
<ConstituentaToolbar
isMutable={readyForEdit}
isModified={isModified}
@ -134,7 +130,10 @@ function EditorConstituenta({
onCreate={handleCreate}
onTemplates={() => onTemplates(activeID)}
/>
<div className='flex justify-start'>
<div tabIndex={-1}
className='max-w-[1500px] flex justify-start w-full'
onKeyDown={handleInput}
>
<div className='min-w-[47.8rem] max-w-[47.8rem] px-4 py-1'>
<FormConstituenta id={globalIDs.constituenta_editor}
constituenta={activeCst}
@ -149,7 +148,8 @@ function EditorConstituenta({
</div>
{(windowSize.width && windowSize.width >= SIDELIST_HIDE_THRESHOLD) ?
<div className='w-full mt-[2.25rem] border h-fit'>
<ViewSideConstituents
<ViewConstituents
schema={schema}
expression={activeCst?.definition_formal ?? ''}
baseHeight={UNFOLDED_HEIGHT}
activeID={activeID}
@ -157,7 +157,7 @@ function EditorConstituenta({
/>
</div> : null}
</div>
</div>);
</>);
}
export default EditorConstituenta;

View File

@ -11,7 +11,7 @@ import { useRSForm } from '../../../context/RSFormContext';
import { IConstituenta, ICstRenameData, ICstUpdateData } from '../../../models/rsform';
import { SyntaxTree } from '../../../models/rslang';
import { labelCstTypification } from '../../../utils/labels';
import EditorRSExpression from './EditorRSExpression';
import EditorRSExpression from '../EditorRSExpression';
interface FormConstituentaProps {
id?: string

View File

@ -1,282 +0,0 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import Dropdown from '../../../components/Common/Dropdown';
import DropdownButton from '../../../components/Common/DropdownButton';
import SelectorButton from '../../../components/Common/SelectorButton';
import DataTable, { createColumnHelper, IConditionalStyle, VisibilityState } from '../../../components/DataTable';
import { CogIcon, FilterIcon, MagnifyingGlassIcon } from '../../../components/Icons';
import ConstituentaBadge from '../../../components/Shared/ConstituentaBadge';
import { useRSForm } from '../../../context/RSFormContext';
import { useConceptTheme } from '../../../context/ThemeContext';
import useDropdown from '../../../hooks/useDropdown';
import useLocalStorage from '../../../hooks/useLocalStorage';
import useWindowSize from '../../../hooks/useWindowSize';
import { DependencyMode as CstSource } from '../../../models/miscelanious';
import { CstMatchMode } from '../../../models/miscelanious';
import { applyGraphFilter } from '../../../models/miscelaniousAPI';
import { CstType, IConstituenta } from '../../../models/rsform';
import { createMockConstituenta, isMockCst, matchConstituenta } from '../../../models/rsformAPI';
import { extractGlobals } from '../../../models/rslangAPI';
import { prefixes } from '../../../utils/constants';
import {
describeConstituenta, describeCstMathchMode,
describeCstSource, labelCstMathchMode,
labelCstSource
} from '../../../utils/labels';
// Height that should be left to accomodate navigation panel + bottom margin
const LOCAL_NAVIGATION_H = '2.1rem';
// Window width cutoff for expression show
const COLUMN_EXPRESSION_HIDE_THRESHOLD = 1500;
interface ViewSideConstituentsProps {
expression: string
baseHeight: string
activeID?: number
onOpenEdit: (cstID: number) => void
}
const columnHelper = createColumnHelper<IConstituenta>();
function ViewSideConstituents({ expression, baseHeight, activeID, onOpenEdit }: ViewSideConstituentsProps) {
const windowSize = useWindowSize();
const { noNavigation, colors } = useConceptTheme();
const { schema } = useRSForm();
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({'expression': true})
const [filterMatch, setFilterMatch] = useLocalStorage('side-filter-match', CstMatchMode.ALL);
const [filterText, setFilterText] = useLocalStorage('side-filter-text', '');
const [filterSource, setFilterSource] = useLocalStorage('side-filter-dependency', CstSource.ALL);
const [filteredData, setFilteredData] = useState<IConstituenta[]>(schema?.items ?? []);
const matchModeMenu = useDropdown();
const sourceMenu = useDropdown();
useLayoutEffect(
() => {
setColumnVisibility(prev => {
const newValue = (windowSize.width ?? 0) >= COLUMN_EXPRESSION_HIDE_THRESHOLD;
if (newValue === prev['expression']) {
return prev;
} else {
return {'expression': newValue}
}
});
}, [windowSize]);
useLayoutEffect(
() => {
if (!schema?.items) {
setFilteredData([]);
return;
}
let filtered: IConstituenta[] = [];
if (filterSource === CstSource.EXPRESSION) {
const aliases = extractGlobals(expression);
filtered = schema.items.filter((cst) => aliases.has(cst.alias));
const names = filtered.map(cst => cst.alias)
const diff = Array.from(aliases).filter(name => !names.includes(name));
if (diff.length > 0) {
diff.forEach(
(alias, index) => filtered.push(
createMockConstituenta(
schema.id,
-index,
alias,
CstType.BASE,
'Конституента отсутствует'
)
)
);
}
} else if (!activeID) {
filtered = schema.items
} else {
filtered = applyGraphFilter(schema, activeID, filterSource);
}
if (filterText) {
filtered = filtered.filter(cst => matchConstituenta(cst, filterText, filterMatch));
}
setFilteredData(filtered);
}, [filterText, setFilteredData, filterSource, expression, schema?.items, schema, filterMatch, activeID]);
const handleRowClicked = useCallback(
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
if (event.altKey && !isMockCst(cst)) {
onOpenEdit(cst.id);
}
}, [onOpenEdit]);
const handleDoubleClick = useCallback(
(cst: IConstituenta) => {
if (!isMockCst(cst)) {
onOpenEdit(cst.id);
}
}, [onOpenEdit]);
const handleMatchModeChange = useCallback(
(newValue: CstMatchMode) => {
matchModeMenu.hide();
setFilterMatch(newValue);
}, [matchModeMenu, setFilterMatch]);
const handleSourceChange = useCallback(
(newValue: CstSource) => {
sourceMenu.hide();
setFilterSource(newValue);
}, [sourceMenu, setFilterSource]);
const columns = useMemo(
() => [
columnHelper.accessor('alias', {
id: 'alias',
header: 'Имя',
size: 65,
minSize: 65,
footer: undefined,
cell: props =>
<ConstituentaBadge
theme={colors}
value={props.row.original}
prefixID={prefixes.cst_list}
/>
}),
columnHelper.accessor(cst => describeConstituenta(cst), {
id: 'description',
header: 'Описание',
size: 1000,
minSize: 250,
maxSize: 1000,
cell: props =>
<div style={{
fontSize: 12,
color: isMockCst(props.row.original) ? colors.fgWarning : undefined
}}>
{props.getValue()}
</div>
}),
columnHelper.accessor('definition_formal', {
id: 'expression',
header: 'Выражение',
size: 2000,
minSize: 0,
maxSize: 2000,
enableHiding: true,
cell: props =>
<div style={{
fontSize: 12,
color: isMockCst(props.row.original) ? colors.fgWarning : undefined
}}>
{props.getValue()}
</div>
})
], [colors]);
const conditionalRowStyles = useMemo(
(): IConditionalStyle<IConstituenta>[] => [
{
when: (cst: IConstituenta) => cst.id === activeID,
style: {
backgroundColor: colors.bgSelected
},
}
], [activeID, colors]);
const maxHeight = useMemo(
() => {
const siblingHeight = `${baseHeight} - ${LOCAL_NAVIGATION_H}`
return (noNavigation ?
`calc(min(100vh - 8.2rem, ${siblingHeight}))`
: `calc(min(100vh - 11.7rem, ${siblingHeight}))`);
}, [noNavigation, baseHeight]);
return (<>
<div className='relative top-0 left-0 right-0 flex items-stretch justify-between gap-1 pl-2 border-b clr-input'>
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-controls'>
<MagnifyingGlassIcon />
</div>
<input type='text'
className='w-full min-w-[6rem] pr-2 pl-8 py-1 outline-none select-none hover:text-clip clr-input'
placeholder='Поиск'
value={filterText}
onChange={event => setFilterText(event.target.value)}
/>
<div className='flex'>
<div ref={matchModeMenu.ref}>
<SelectorButton transparent tabIndex={-1}
tooltip='Настройка атрибутов для фильтрации'
dimensions='w-fit h-full'
icon={<FilterIcon size={5} />}
text={labelCstMathchMode(filterMatch)}
onClick={matchModeMenu.toggle}
/>
{ matchModeMenu.isActive &&
<Dropdown stretchLeft>
{Object.values(CstMatchMode).filter(value => !isNaN(Number(value))).map(
(value, index) => {
const matchMode = value as CstMatchMode;
return (
<DropdownButton
key={`${prefixes.cst_match_mode_list}${index}`}
onClick={() => handleMatchModeChange(matchMode)}
>
<p><span className='font-semibold'>{labelCstMathchMode(matchMode)}:</span> {describeCstMathchMode(matchMode)}</p>
</DropdownButton>);
})}
</Dropdown>}
</div>
<div ref={sourceMenu.ref}>
<SelectorButton transparent tabIndex={-1}
tooltip='Настройка фильтрации по графу термов'
dimensions='w-fit h-full'
icon={<CogIcon size={4} />}
text={labelCstSource(filterSource)}
onClick={sourceMenu.toggle}
/>
{sourceMenu.isActive ?
<Dropdown stretchLeft>
{Object.values(CstSource).filter(value => !isNaN(Number(value))).map(
(value, index) => {
const source = value as CstSource;
return (
<DropdownButton
key={`${prefixes.cst_source_list}${index}`}
onClick={() => handleSourceChange(source)}
>
<p><span className='font-semibold'>{labelCstSource(source)}:</span> {describeCstSource(source)}</p>
</DropdownButton>);
})}
</Dropdown> : null}
</div>
</div>
</div>
<div className='overflow-y-auto text-sm overscroll-none' style={{maxHeight : `${maxHeight}`}}>
<DataTable dense noFooter
data={filteredData}
columns={columns}
conditionalRowStyles={conditionalRowStyles}
headPosition='0'
enableHiding
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
noDataComponent={
<span className='flex flex-col justify-center p-2 text-center min-h-[5rem] select-none'>
<p>Список конституент пуст</p>
<p>Измените параметры фильтра</p>
</span>
}
onRowDoubleClicked={handleDoubleClick}
onRowClicked={handleRowClicked}
/>
</div>
</>);
}
export default ViewSideConstituents;

View File

@ -2,8 +2,6 @@ import { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import Button from '../../../components/Common/Button';
import { ConceptLoader } from '../../../components/Common/ConceptLoader';
import MiniButton from '../../../components/Common/MiniButton';
import Overlay from '../../../components/Common/Overlay';
import { ASTNetworkIcon } from '../../../components/Icons';
@ -16,9 +14,8 @@ import { IExpressionParse, IRSErrorDescription, SyntaxTree } from '../../../mode
import { TokenID } from '../../../models/rslang';
import { labelTypification } from '../../../utils/labels';
import { getCstExpressionPrefix } from '../../../utils/misc';
import ParsingResult from './ParsingResult';
import RSAnalyzer from './RSAnalyzer';
import RSEditorControls from './RSEditControls';
import StatusBar from './StatusBar';
interface EditorRSExpressionProps {
id?: string
@ -126,6 +123,7 @@ function EditorRSExpression({
icon={<ASTNetworkIcon size={5} color='text-primary' />}
/>
</Overlay>
<RSInput innerref={rsInput}
value={value}
minHeight='3.8rem'
@ -133,40 +131,20 @@ function EditorRSExpression({
onChange={handleChange}
{...restProps}
/>
<RSEditorControls
disabled={disabled}
onEdit={handleEdit}
/>
<div className='w-full max-h-[4.5rem] min-h-[4.5rem] flex'>
<div className='flex flex-col text-sm'>
<Button noOutline
text='Проверить'
tooltip='Проверить формальное определение'
dimensions='w-[6.75rem] min-h-[3rem] z-pop rounded-none'
colors='clr-btn-default'
onClick={() => handleCheckExpression()}
/>
<StatusBar
isModified={isModified}
constituenta={activeCst}
parseData={parseData}
/>
</div>
<div className='w-full overflow-y-auto text-sm border rounded-none'>
{loading ? <ConceptLoader size={6} /> : null}
{(!loading && parseData) ?
<ParsingResult
data={parseData}
disabled={disabled}
onShowError={onShowError}
/> : null}
{(!loading && !parseData) ?
<input disabled
className='w-full px-2 py-1 text-base select-none h-fit clr-app'
placeholder='Результаты проверки выражения'
/> : null}
</div>
</div>
<RSAnalyzer
parseData={parseData}
processing={loading}
isModified={isModified}
activeCst={activeCst}
onCheckExpression={handleCheckExpression}
onShowError={onShowError}
/>
</div>);
}

View File

@ -32,4 +32,4 @@ function ParsingResult({ data, disabled, onShowError }: ParsingResultProps) {
</div>);
}
export default ParsingResult;
export default ParsingResult;

View File

@ -0,0 +1,58 @@
import Button from '../../../components/Common/Button';
import { ConceptLoader } from '../../../components/Common/ConceptLoader';
import { IConstituenta } from '../../../models/rsform';
import { IExpressionParse, IRSErrorDescription } from '../../../models/rslang';
import ParsingResult from './ParsingResult';
import StatusBar from './StatusBar';
interface RSAnalyzerProps {
parseData?: IExpressionParse
processing?: boolean
activeCst?: IConstituenta
isModified: boolean
disabled?: boolean
onCheckExpression: () => void
onShowError: (error: IRSErrorDescription) => void
}
function RSAnalyzer({
parseData, processing,
activeCst, disabled, isModified,
onCheckExpression, onShowError,
}: RSAnalyzerProps) {
return (
<div className='w-full max-h-[4.5rem] min-h-[4.5rem] flex'>
<div className='flex flex-col text-sm'>
<Button noOutline
text='Проверить'
tooltip='Проверить формальное определение'
dimensions='w-[6.75rem] min-h-[3rem] z-pop rounded-none'
colors='clr-btn-default'
onClick={() => onCheckExpression()}
/>
<StatusBar
isModified={isModified}
constituenta={activeCst}
parseData={parseData}
/>
</div>
<div className='w-full overflow-y-auto text-sm border rounded-none'>
{processing ? <ConceptLoader size={6} /> : null}
{(!processing && parseData) ?
<ParsingResult
data={parseData}
disabled={disabled}
onShowError={onShowError}
/> : null}
{(!processing && !parseData) ?
<input disabled
className='w-full px-2 py-1 text-base select-none h-fit clr-app'
placeholder='Результаты проверки выражения'
/> : null}
</div>
</div>);
}
export default RSAnalyzer;

View File

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

View File

@ -0,0 +1,140 @@
import { useCallback, useLayoutEffect } from 'react';
import ConceptSearch from '../../../components/Common/ConceptSearch';
import Dropdown from '../../../components/Common/Dropdown';
import DropdownButton from '../../../components/Common/DropdownButton';
import SelectorButton from '../../../components/Common/SelectorButton';
import { CogIcon, FilterIcon } from '../../../components/Icons';
import useDropdown from '../../../hooks/useDropdown';
import useLocalStorage from '../../../hooks/useLocalStorage';
import { CstMatchMode, DependencyMode } from '../../../models/miscelanious';
import { applyGraphFilter } from '../../../models/miscelaniousAPI';
import { CstType, IConstituenta, IRSForm } from '../../../models/rsform';
import { createMockConstituenta, matchConstituenta } from '../../../models/rsformAPI';
import { extractGlobals } from '../../../models/rslangAPI';
import { prefixes } from '../../../utils/constants';
import { describeCstMathchMode, describeCstSource, labelCstMathchMode, labelCstSource } from '../../../utils/labels';
interface ConstituentsSearchProps {
schema?: IRSForm
activeID?: number
activeExpression: string
setFiltered: React.Dispatch<React.SetStateAction<IConstituenta[]>>
}
function ConstituentsSearch({ schema, activeID, activeExpression, setFiltered }: ConstituentsSearchProps) {
const [filterMatch, setFilterMatch] = useLocalStorage('side-filter-match', CstMatchMode.ALL);
const [filterText, setFilterText] = useLocalStorage('side-filter-text', '');
const [filterSource, setFilterSource] = useLocalStorage('side-filter-dependency', DependencyMode.ALL);
const matchModeMenu = useDropdown();
const sourceMenu = useDropdown();
useLayoutEffect(
() => {
if (!schema || schema.items.length === 0) {
setFiltered([]);
return;
}
let result: IConstituenta[] = [];
if (filterSource === DependencyMode.EXPRESSION) {
const aliases = extractGlobals(activeExpression);
console.log(aliases);
result = schema.items.filter((cst) => aliases.has(cst.alias));
const names = result.map(cst => cst.alias)
const diff = Array.from(aliases).filter(name => !names.includes(name));
if (diff.length > 0) {
diff.forEach(
(alias, index) => result.push(
createMockConstituenta(
-1,
-index,
alias,
CstType.BASE,
'Конституента отсутствует'
)
)
);
}
} else if (!activeID) {
result = schema.items;
} else {
result = applyGraphFilter(schema, activeID, filterSource);
}
if (filterText) {
result = result.filter(cst => matchConstituenta(cst, filterText, filterMatch));
}
setFiltered(result);
}, [filterText, setFiltered, filterSource, activeExpression, schema?.items, schema, filterMatch, activeID]);
const handleMatchModeChange = useCallback(
(newValue: CstMatchMode) => {
matchModeMenu.hide();
setFilterMatch(newValue);
}, [matchModeMenu, setFilterMatch]);
const handleSourceChange = useCallback(
(newValue: DependencyMode) => {
sourceMenu.hide();
setFilterSource(newValue);
}, [sourceMenu, setFilterSource]);
return (
<div className='flex items-stretch border-b clr-input'>
<ConceptSearch noBorder
value={filterText}
onChange={setFilterText}
dimensions='min-w-[6rem] pr-2 w-full'
/>
<div ref={matchModeMenu.ref}>
<SelectorButton transparent tabIndex={-1}
tooltip='Настройка атрибутов для фильтрации'
dimensions='w-fit h-full'
icon={<FilterIcon size={5} />}
text={labelCstMathchMode(filterMatch)}
onClick={matchModeMenu.toggle}
/>
{matchModeMenu.isActive ?
<Dropdown stretchLeft>
{Object.values(CstMatchMode).filter(value => !isNaN(Number(value))).map(
(value, index) => {
const matchMode = value as CstMatchMode;
return (
<DropdownButton
key={`${prefixes.cst_match_mode_list}${index}`}
onClick={() => handleMatchModeChange(matchMode)}
>
<p><span className='font-semibold'>{labelCstMathchMode(matchMode)}:</span> {describeCstMathchMode(matchMode)}</p>
</DropdownButton>);
})}
</Dropdown> : null}
</div>
<div ref={sourceMenu.ref}>
<SelectorButton transparent tabIndex={-1}
tooltip='Настройка фильтрации по графу термов'
dimensions='w-fit h-full pr-2'
icon={<CogIcon size={4} />}
text={labelCstSource(filterSource)}
onClick={sourceMenu.toggle}
/>
{sourceMenu.isActive ?
<Dropdown stretchLeft>
{Object.values(DependencyMode).filter(value => !isNaN(Number(value))).map(
(value, index) => {
const source = value as DependencyMode;
return (
<DropdownButton
key={`${prefixes.cst_source_list}${index}`}
onClick={() => handleSourceChange(source)}
>
<p><span className='font-semibold'>{labelCstSource(source)}:</span> {describeCstSource(source)}</p>
</DropdownButton>);
})}
</Dropdown> : null}
</div>
</div>);
}
export default ConstituentsSearch;

View File

@ -0,0 +1,135 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import DataTable, { createColumnHelper,IConditionalStyle, VisibilityState } from '../../../components/DataTable';
import ConstituentaBadge from '../../../components/Shared/ConstituentaBadge';
import { useConceptTheme } from '../../../context/ThemeContext';
import useWindowSize from '../../../hooks/useWindowSize';
import { IConstituenta } from '../../../models/rsform';
import { isMockCst } from '../../../models/rsformAPI';
import { prefixes } from '../../../utils/constants';
import { describeConstituenta } from '../../../utils/labels';
interface ConstituentsTableProps {
items: IConstituenta[]
activeID?: number
onOpenEdit: (cstID: number) => void
denseThreshold?: number
}
const columnHelper = createColumnHelper<IConstituenta>();
function ConstituentsTable({
items, activeID, onOpenEdit,
denseThreshold = 9999
}: ConstituentsTableProps) {
const { colors } = useConceptTheme();
const windowSize = useWindowSize();
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({'expression': true});
useLayoutEffect(
() => {
setColumnVisibility(prev => {
const newValue = (windowSize.width ?? 0) >= denseThreshold;
if (newValue === prev['expression']) {
return prev;
} else {
return {'expression': newValue}
}
});
}, [windowSize, denseThreshold]);
const handleRowClicked = useCallback(
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
if (event.altKey && !isMockCst(cst)) {
onOpenEdit(cst.id);
}
}, [onOpenEdit]);
const handleDoubleClick = useCallback(
(cst: IConstituenta) => {
if (!isMockCst(cst)) {
onOpenEdit(cst.id);
}
}, [onOpenEdit]);
const columns = useMemo(
() => [
columnHelper.accessor('alias', {
id: 'alias',
header: 'Имя',
size: 65,
minSize: 65,
footer: undefined,
cell: props =>
<ConstituentaBadge
theme={colors}
value={props.row.original}
prefixID={prefixes.cst_list}
/>
}),
columnHelper.accessor(cst => describeConstituenta(cst), {
id: 'description',
header: 'Описание',
size: 1000,
minSize: 250,
maxSize: 1000,
cell: props =>
<div style={{
fontSize: 12,
color: isMockCst(props.row.original) ? colors.fgWarning : undefined
}}>
{props.getValue()}
</div>
}),
columnHelper.accessor('definition_formal', {
id: 'expression',
header: 'Выражение',
size: 2000,
minSize: 0,
maxSize: 2000,
enableHiding: true,
cell: props =>
<div style={{
fontSize: 12,
color: isMockCst(props.row.original) ? colors.fgWarning : undefined
}}>
{props.getValue()}
</div>
})
], [colors]);
const conditionalRowStyles = useMemo(
(): IConditionalStyle<IConstituenta>[] => [
{
when: (cst: IConstituenta) => cst.id === activeID,
style: {
backgroundColor: colors.bgSelected
},
}
], [activeID, colors]);
return (
<DataTable dense noFooter
data={items}
columns={columns}
conditionalRowStyles={conditionalRowStyles}
headPosition='0'
enableHiding
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
noDataComponent={
<span className='flex flex-col justify-center p-2 text-center min-h-[5rem] select-none'>
<p>Список конституент пуст</p>
<p>Измените параметры фильтра</p>
</span>
}
onRowDoubleClicked={handleDoubleClick}
onRowClicked={handleRowClicked}
/>);
}
export default ConstituentsTable;

View File

@ -0,0 +1,53 @@
import { useMemo, useState } from 'react';
import { useConceptTheme } from '../../../context/ThemeContext';
import { IConstituenta, IRSForm } from '../../../models/rsform';
import ConstituentsSearch from './ConstituentsSearch';
import ConstituentsTable from './ConstituentsTable';
// Height that should be left to accomodate navigation panel + bottom margin
const LOCAL_NAVIGATION_H = '2.1rem';
// Window width cutoff for expression show
const COLUMN_EXPRESSION_HIDE_THRESHOLD = 1500;
interface ViewConstituentsProps {
expression: string
baseHeight: string
activeID?: number
schema?: IRSForm
onOpenEdit: (cstID: number) => void
}
function ViewConstituents({ expression, baseHeight, schema, activeID, onOpenEdit }: ViewConstituentsProps) {
const { noNavigation } = useConceptTheme();
const [filteredData, setFilteredData] = useState<IConstituenta[]>(schema?.items ?? []);
const maxHeight = useMemo(
() => {
const siblingHeight = `${baseHeight} - ${LOCAL_NAVIGATION_H}`
return (noNavigation ?
`calc(min(100vh - 8.2rem, ${siblingHeight}))`
: `calc(min(100vh - 11.7rem, ${siblingHeight}))`);
}, [noNavigation, baseHeight]);
return (<>
<ConstituentsSearch
schema={schema}
activeID={activeID}
activeExpression={expression}
setFiltered={setFilteredData}
/>
<div className='overflow-y-auto text-sm overscroll-none' style={{maxHeight : `${maxHeight}`}}>
<ConstituentsTable
items={filteredData}
activeID={activeID}
onOpenEdit={onOpenEdit}
denseThreshold={COLUMN_EXPRESSION_HIDE_THRESHOLD}
/>
</div>
</>);
}
export default ViewConstituents;

View File

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

View File

@ -1,7 +1,6 @@
/**
* Module: CodeMirror customizations.
*/
import { syntaxTree } from '@codemirror/language';
import { NodeType, Tree, TreeCursor } from '@lezer/common';
import { ReactCodeMirrorRef, SelectionRange } from '@uiw/react-codemirror';
@ -200,7 +199,6 @@ export function domTooltipEntityReference(ref: IEntityReference, cst: IConstitue
grams.appendChild(gram);
});
dom.appendChild(grams);
return { dom: dom };
}