F: Rework reference editor dialog

This commit is contained in:
Ivan 2025-02-15 15:33:37 +03:00
parent cdcf1a9c43
commit 4b450384c4
10 changed files with 230 additions and 163 deletions

View File

@ -43,7 +43,10 @@ interface ModalFormProps extends ModalProps {
beforeSubmit?: () => boolean;
/** Callback to be called after submit. */
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void | Promise<void>;
/** Callback to be called when modal is canceled. */
onCancel?: () => void;
}
/**
@ -61,25 +64,30 @@ export function ModalForm({
submitInvalidTooltip,
beforeSubmit,
onSubmit,
onCancel,
helpTopic,
hideHelpWhen,
...restProps
}: React.PropsWithChildren<ModalFormProps>) {
const hideDialog = useDialogsStore(state => state.hideDialog);
useEscapeKey(hideDialog);
function handleCancel() {
onCancel?.();
hideDialog();
}
useEscapeKey(handleCancel);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
if (beforeSubmit && !beforeSubmit()) {
return;
}
onSubmit(event);
hideDialog();
void Promise.resolve(onSubmit(event)).then(hideDialog);
}
return (
<div className='fixed top-0 left-0 w-full h-full z-modal cursor-default'>
<ModalBackdrop onHide={hideDialog} />
<ModalBackdrop onHide={handleCancel} />
<form
className={clsx(
'cc-animate-modal',
@ -99,7 +107,7 @@ export function ModalForm({
titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')}
icon={<IconClose size='1.25rem' />}
className='float-right mt-2 mr-2'
onClick={hideDialog}
onClick={handleCancel}
/>
{header ? <h1 className='px-12 py-2 select-none'>{header}</h1> : null}
@ -126,7 +134,7 @@ export function ModalForm({
className='min-w-[7rem]'
disabled={!canSubmit}
/>
<Button text='Отмена' className='min-w-[7rem]' onClick={hideDialog} />
<Button text='Отмена' className='min-w-[7rem]' onClick={handleCancel} />
</div>
</form>
</div>

View File

@ -9,12 +9,15 @@ import clsx from 'clsx';
import { EditorView } from 'codemirror';
import { Label } from '@/components/Input';
import { DialogType, useDialogsStore } from '@/stores/dialogs';
import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { APP_COLORS } from '@/styling/colors';
import { CodeMirrorWrapper } from '@/utils/codemirror';
import { PARAMETER } from '@/utils/constants';
import { IReferenceInputState } from '../../dialogs/DlgEditReference/DlgEditReference';
import { ReferenceType } from '../../models/language';
import { referenceToString } from '../../models/languageAPI';
import { IRSForm } from '../../models/rsform';
import { RefEntity } from './parse/parser.terms';
@ -63,9 +66,9 @@ interface RefsInputInputProps
| 'onBlur'
| 'placeholder'
> {
value?: string;
resolved?: string;
onChange?: (newValue: string) => void;
value: string;
resolved: string;
onChange: (newValue: string) => void;
schema: IRSForm;
onOpenEdit?: (cstID: number) => void;
@ -75,7 +78,7 @@ interface RefsInputInputProps
initialValue?: string;
}
const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
export const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
(
{
id, // prettier: split-lines
@ -98,14 +101,7 @@ const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
const [isFocused, setIsFocused] = useState(false);
const showEditReference = useDialogsStore(state => state.showEditReference);
const activeDialog = useDialogsStore(state => state.active);
const isActive = activeDialog === DialogType.EDIT_REFERENCE; // TODO: reconsider this dependency
const [currentType, setCurrentType] = useState<ReferenceType>(ReferenceType.ENTITY);
const [refText, setRefText] = useState('');
const [hintText, setHintText] = useState('');
const [basePosition, setBasePosition] = useState(0);
const [mainRefs, setMainRefs] = useState<string[]>([]);
const [isEditing, setIsEditing] = useState(false);
const internalRef = useRef<ReactCodeMirrorRef>(null);
const thisRef = !ref || typeof ref === 'function' ? internalRef : ref;
@ -135,11 +131,8 @@ const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
refsHoverTooltip(schema, onOpenEdit !== undefined)
];
function handleChange(newValue: string) {
if (onChange) onChange(newValue);
}
function handleFocusIn(event: React.FocusEvent<HTMLDivElement>) {
setIsEditing(false);
setIsFocused(true);
if (onFocus) onFocus(event);
}
@ -162,45 +155,50 @@ const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
const wrap = new CodeMirrorWrapper(thisRef.current as Required<ReactCodeMirrorRef>);
wrap.fixSelection(ReferenceTokens);
const nodes = wrap.getEnvelopingNodes(ReferenceTokens);
const data: IReferenceInputState = {
type: ReferenceType.ENTITY,
refRaw: '',
text: '',
mainRefs: [],
basePosition: 0
};
if (nodes.length !== 1) {
setCurrentType(ReferenceType.ENTITY);
setRefText('');
setHintText(wrap.getSelectionText());
data.text = wrap.getSelectionText();
} else {
setCurrentType(nodes[0].type.id === RefEntity ? ReferenceType.ENTITY : ReferenceType.SYNTACTIC);
setRefText(wrap.getSelectionText());
data.type = nodes[0].type.id === RefEntity ? ReferenceType.ENTITY : ReferenceType.SYNTACTIC;
data.refRaw = wrap.getSelectionText();
}
const selection = wrap.getSelection();
const mainNodes = wrap
.getAllNodes([RefEntity])
.filter(node => node.from >= selection.to || node.to <= selection.from);
setMainRefs(mainNodes.map(node => wrap.getText(node.from, node.to)));
setBasePosition(mainNodes.filter(node => node.to <= selection.from).length);
data.mainRefs = mainNodes.map(node => wrap.getText(node.from, node.to));
data.basePosition = mainNodes.filter(node => node.to <= selection.from).length;
setIsEditing(true);
showEditReference({
schema: schema,
initial: {
type: currentType,
refRaw: refText,
text: hintText,
basePosition: basePosition,
mainRefs: mainRefs
initial: data,
onCancel: () => {
setIsEditing(false);
setTimeout(() => {
thisRef.current?.view?.focus();
}, PARAMETER.minimalTimeout);
},
onSave: handleInputReference
onSave: ref => {
wrap.replaceWith(referenceToString(ref));
setIsEditing(false);
setTimeout(() => {
thisRef.current?.view?.focus();
}, PARAMETER.minimalTimeout);
}
});
}
}
function handleInputReference(referenceText: string) {
if (!thisRef.current?.view) {
return;
}
thisRef.current.view.focus();
const wrap = new CodeMirrorWrapper(thisRef.current as Required<ReactCodeMirrorRef>);
wrap.replaceWith(referenceText);
}
return (
<div className={clsx('flex flex-col gap-2', cursor)}>
<Label text={label} />
@ -210,10 +208,10 @@ const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
basicSetup={editorSetup}
theme={customTheme}
extensions={editorExtensions}
value={isFocused ? value : value !== initialValue || isActive ? value : resolved}
value={isFocused ? value : value !== initialValue || isEditing ? value : resolved}
indentWithTab={false}
onChange={handleChange}
editable={!disabled && !isActive}
onChange={onChange}
editable={!disabled && !isEditing}
onKeyDown={handleInput}
onFocus={handleFocusIn}
onBlur={handleFocusOut}
@ -223,5 +221,3 @@ const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
);
}
);
export default RefsInput;

View File

@ -1 +1 @@
export { default } from './RefsInput';
export { RefsInput } from './RefsInput';

View File

@ -1,7 +1,10 @@
'use client';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx';
import { z } from 'zod';
import { HelpTopic } from '@/features/help';
@ -10,11 +13,17 @@ import { TabLabel, TabList, TabPanel, Tabs } from '@/components/Tabs';
import { useDialogsStore } from '@/stores/dialogs';
import { labelReferenceType } from '../../labels';
import { ReferenceType } from '../../models/language';
import { IReference, ReferenceType } from '../../models/language';
import {
parseEntityReference,
parseGrammemes,
parseSyntacticReference,
supportedGrammeOptions
} from '../../models/languageAPI';
import { IRSForm } from '../../models/rsform';
import TabEntityReference from './TabEntityReference';
import TabSyntacticReference from './TabSyntacticReference';
import { TabEntityReference } from './TabEntityReference';
import { TabSyntacticReference } from './TabSyntacticReference';
export interface IReferenceInputState {
type: ReferenceType;
@ -24,10 +33,25 @@ export interface IReferenceInputState {
basePosition: number;
}
const schemaEditReferenceState = z
.object({
type: z.nativeEnum(ReferenceType),
entity: z.object({ entity: z.string(), grams: z.array(z.object({ value: z.string(), label: z.string() })) }),
syntactic: z.object({ offset: z.coerce.number(), nominal: z.string() })
})
.refine(
data =>
(data.type !== ReferenceType.SYNTACTIC || (data.syntactic.offset !== 0 && data.syntactic.nominal !== '')) &&
(data.type !== ReferenceType.ENTITY || (data.entity.entity !== '' && data.entity.grams.length > 0))
);
export type IEditReferenceState = z.infer<typeof schemaEditReferenceState>;
export interface DlgEditReferenceProps {
schema: IRSForm;
initial: IReferenceInputState;
onSave: (newRef: string) => void;
onSave: (newRef: IReference) => void;
onCancel: () => void;
}
export enum TabID {
@ -36,22 +60,45 @@ export enum TabID {
}
function DlgEditReference() {
const { initial, onSave } = useDialogsStore(state => state.props as DlgEditReferenceProps);
const { initial, onSave, onCancel } = useDialogsStore(state => state.props as DlgEditReferenceProps);
const [activeTab, setActiveTab] = useState(initial.type === ReferenceType.ENTITY ? TabID.ENTITY : TabID.SYNTACTIC);
const [reference, setReference] = useState('');
const [isValid, setIsValid] = useState(false);
function handleSubmit() {
onSave(reference);
return true;
const methods = useForm<IEditReferenceState>({
resolver: zodResolver(schemaEditReferenceState),
defaultValues: {
type: initial.type,
entity: initEntityReference(initial),
syntactic: initSyntacticReference(initial)
},
mode: 'onChange'
});
function onSubmit(data: IEditReferenceState) {
if (data.type === ReferenceType.ENTITY) {
onSave({
type: data.type,
data: {
entity: data.entity.entity,
form: data.entity.grams.map(gram => gram.value).join(',')
}
});
} else {
onSave({ type: data.type, data: data.syntactic });
}
}
function handleChangeTab(tab: TabID) {
methods.setValue('type', tab === TabID.ENTITY ? ReferenceType.ENTITY : ReferenceType.SYNTACTIC);
setActiveTab(tab);
}
return (
<ModalForm
header='Редактирование ссылки'
submitText='Сохранить ссылку'
canSubmit={isValid}
onSubmit={handleSubmit}
canSubmit={methods.formState.isValid}
onCancel={onCancel}
onSubmit={event => void methods.handleSubmit(onSubmit)(event)}
className='w-[40rem] px-6 h-[32rem]'
helpTopic={HelpTopic.TERM_CONTROL}
>
@ -59,7 +106,7 @@ function DlgEditReference() {
selectedTabClassName='clr-selected'
className='flex flex-col'
selectedIndex={activeTab}
onSelect={setActiveTab}
onSelect={handleChangeTab}
>
<TabList className={clsx('mb-3 self-center', 'flex', 'border divide-x rounded-none', 'bg-prim-200')}>
<TabLabel title='Отсылка на термин в заданной словоформе' label={labelReferenceType(ReferenceType.ENTITY)} />
@ -69,16 +116,47 @@ function DlgEditReference() {
/>
</TabList>
<TabPanel>
<TabEntityReference onChangeReference={setReference} onChangeValid={setIsValid} />
</TabPanel>
<FormProvider {...methods}>
<TabPanel>
<TabEntityReference />
</TabPanel>
<TabPanel>
<TabSyntacticReference onChangeReference={setReference} onChangeValid={setIsValid} />
</TabPanel>
<TabPanel>
<TabSyntacticReference />
</TabPanel>
</FormProvider>
</Tabs>
</ModalForm>
);
}
export default DlgEditReference;
// ====== Internals =========
function initEntityReference(initial: IReferenceInputState) {
if (!initial.refRaw || initial.type === ReferenceType.SYNTACTIC) {
return {
entity: '',
grams: []
};
} else {
const ref = parseEntityReference(initial.refRaw);
const grams = parseGrammemes(ref.form);
const supported = supportedGrammeOptions.filter(data => grams.includes(data.value));
return {
entity: ref.entity,
grams: supported
};
}
}
function initSyntacticReference(initial: IReferenceInputState) {
if (!initial.refRaw || initial.type === ReferenceType.ENTITY) {
return {
offset: 1,
nominal: initial.text ?? ''
};
} else {
return parseSyntacticReference(initial.refRaw);
}
}

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { Label, TextInput } from '@/components/Input';
import { useDialogsStore } from '@/stores/dialogs';
@ -8,51 +8,22 @@ import { useDialogsStore } from '@/stores/dialogs';
import { PickConstituenta } from '../../components/PickConstituenta';
import SelectMultiGrammeme from '../../components/SelectMultiGrammeme';
import SelectWordForm from '../../components/SelectWordForm';
import { IGrammemeOption, ReferenceType } from '../../models/language';
import { parseEntityReference, parseGrammemes, supportedGrammeOptions } from '../../models/languageAPI';
import { IConstituenta } from '../../models/rsform';
import { matchConstituenta } from '../../models/rsformAPI';
import { CstMatchMode } from '../../stores/cstSearch';
import { DlgEditReferenceProps } from './DlgEditReference';
import { DlgEditReferenceProps, IEditReferenceState } from './DlgEditReference';
interface TabEntityReferenceProps {
onChangeValid: (newValue: boolean) => void;
onChangeReference: (newValue: string) => void;
}
function TabEntityReference({ onChangeValid, onChangeReference }: TabEntityReferenceProps) {
export function TabEntityReference() {
const { schema, initial } = useDialogsStore(state => state.props as DlgEditReferenceProps);
const [selectedCst, setSelectedCst] = useState<IConstituenta | undefined>(undefined);
const [alias, setAlias] = useState('');
const [term, setTerm] = useState('');
const [selectedGrams, setSelectedGrams] = useState<IGrammemeOption[]>([]);
const { setValue, control, register } = useFormContext<IEditReferenceState>();
const alias = useWatch({ control, name: 'entity.entity' });
// Initialization
useEffect(() => {
if (!!initial.refRaw && initial.type === ReferenceType.ENTITY) {
const ref = parseEntityReference(initial.refRaw);
setAlias(ref.entity);
const grams = parseGrammemes(ref.form);
setSelectedGrams(supportedGrammeOptions.filter(data => grams.includes(data.value)));
}
}, [initial, schema.items]);
// Produce result
useEffect(() => {
onChangeValid(alias !== '' && selectedGrams.length > 0);
onChangeReference(`@{${alias}|${selectedGrams.map(gram => gram.value).join(',')}}`);
}, [alias, selectedGrams, onChangeValid, onChangeReference]);
// Update term when alias changes
useEffect(() => {
const cst = schema.cstByAlias.get(alias);
setTerm(cst?.term_resolved ?? '');
}, [alias, term, schema]);
const selectedCst = schema.cstByAlias.get(alias);
const term = selectedCst?.term_resolved ?? '';
function handleSelectConstituenta(cst: IConstituenta) {
setAlias(cst.alias);
setSelectedCst(cst);
setValue('entity.entity', cst.alias);
}
return (
@ -76,8 +47,7 @@ function TabEntityReference({ onChangeValid, onChangeReference }: TabEntityRefer
label='Конституента'
placeholder='Имя'
className='w-[11rem]'
value={alias}
onChange={event => setAlias(event.target.value)}
{...register('entity.entity')}
/>
<TextInput
id='dlg_reference_term'
@ -91,21 +61,29 @@ function TabEntityReference({ onChangeValid, onChangeReference }: TabEntityRefer
/>
</div>
<SelectWordForm value={selectedGrams} onChange={setSelectedGrams} />
<Controller
control={control}
name='entity.grams'
render={({ field }) => <SelectWordForm value={field.value} onChange={field.onChange} />}
/>
<div className='flex items-center gap-4'>
<Label text='Словоформа' />
<SelectMultiGrammeme
id='dlg_reference_grammemes'
placeholder='Выберите граммемы'
className='flex-grow'
menuPlacement='top'
value={selectedGrams}
onChange={setSelectedGrams}
<Controller
control={control}
name='entity.grams'
render={({ field }) => (
<SelectMultiGrammeme
id='dlg_reference_grammemes'
placeholder='Выберите граммемы'
className='flex-grow'
menuPlacement='top'
value={field.value}
onChange={field.onChange}
/>
)}
/>
</div>
</div>
);
}
export default TabEntityReference;

View File

@ -1,24 +1,16 @@
'use client';
import { useEffect, useState } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { TextInput } from '@/components/Input';
import { useDialogsStore } from '@/stores/dialogs';
import { ReferenceType } from '../../models/language';
import { parseSyntacticReference } from '../../models/languageAPI';
import { DlgEditReferenceProps, IEditReferenceState } from './DlgEditReference';
import { DlgEditReferenceProps } from './DlgEditReference';
interface TabSyntacticReferenceProps {
onChangeValid: (newValue: boolean) => void;
onChangeReference: (newValue: string) => void;
}
function TabSyntacticReference({ onChangeValid, onChangeReference }: TabSyntacticReferenceProps) {
export function TabSyntacticReference() {
const { initial } = useDialogsStore(state => state.props as DlgEditReferenceProps);
const [nominal, setNominal] = useState('');
const [offset, setOffset] = useState(1);
const { control, register } = useFormContext<IEditReferenceState>();
const offset = useWatch({ control, name: 'syntactic.offset' });
const mainLink = (() => {
const position = offset > 0 ? initial.basePosition + (offset - 1) : initial.basePosition + offset;
@ -29,21 +21,6 @@ function TabSyntacticReference({ onChangeValid, onChangeReference }: TabSyntacti
}
})();
useEffect(() => {
if (initial.refRaw && initial.type === ReferenceType.SYNTACTIC) {
const ref = parseSyntacticReference(initial.refRaw);
setOffset(ref.offset);
setNominal(ref.nominal);
} else {
setNominal(initial.text ?? '');
}
}, [initial]);
useEffect(() => {
onChangeValid(nominal !== '' && offset !== 0);
onChangeReference(`@{${offset}|${nominal}}`);
}, [nominal, offset, onChangeValid, onChangeReference]);
return (
<div className='cc-fade-in flex flex-col gap-2'>
<TextInput
@ -52,8 +29,7 @@ function TabSyntacticReference({ onChangeValid, onChangeReference }: TabSyntacti
dense
label='Смещение'
className='max-w-[10rem]'
value={offset}
onChange={event => setOffset(event.target.valueAsNumber)}
{...register('syntactic.offset')}
/>
<TextInput
id='dlg_main_ref'
@ -68,11 +44,8 @@ function TabSyntacticReference({ onChangeValid, onChangeReference }: TabSyntacti
spellCheck
label='Начальная форма'
placeholder='зависимое слово в начальной форме'
value={nominal}
onChange={event => setNominal(event.target.value)}
{...register('syntactic.nominal')}
/>
</div>
);
}
export default TabSyntacticReference;

View File

@ -2,6 +2,8 @@
* Module: Natural language model declarations.
*/
import { z } from 'zod';
/**
* Represents single unit of language Morphology.
*/
@ -266,10 +268,15 @@ export interface ITextPosition {
finish: number;
}
export const schemaReference = z.object({
type: z.nativeEnum(ReferenceType),
data: z.union([
z.object({ entity: z.string(), form: z.string() }),
z.object({ offset: z.number(), nominal: z.string() })
])
});
/**
* Represents abstract reference data.
*/
export interface IReference {
type: ReferenceType;
data: IEntityReference | ISyntacticReference;
}
export type IReference = z.infer<typeof schemaReference>;

View File

@ -10,9 +10,11 @@ import {
GrammemeGroups,
IEntityReference,
IGrammemeOption,
IReference,
ISyntacticReference,
IWordForm,
NounGrams,
ReferenceType,
supportedGrammemes,
VerbGrams
} from './language';
@ -128,3 +130,19 @@ export const supportedGrammeOptions: IGrammemeOption[] = supportedGrammemes.map(
value: gram,
label: labelGrammeme(gram)
}));
/**
* Transforms {@link IReference} to string representation.
*/
export function referenceToString(ref: IReference): string {
switch (ref.type) {
case ReferenceType.ENTITY: {
const entity = ref.data as IEntityReference;
return `@{${entity.entity}|${entity.form}}`;
}
case ReferenceType.SYNTACTIC: {
const syntactic = ref.data as ISyntacticReference;
return `@{${syntactic.offset}|${syntactic.nominal}}`;
}
}
}

View File

@ -19,7 +19,7 @@ import { errorMsg } from '@/utils/labels';
import { ICstUpdateDTO, schemaCstUpdate } from '../../../backend/types';
import { useCstUpdate } from '../../../backend/useCstUpdate';
import { useMutatingRSForm } from '../../../backend/useMutatingRSForm';
import RefsInput from '../../../components/RefsInput';
import { RefsInput } from '../../../components/RefsInput';
import { labelCstTypification, labelTypification } from '../../../labels';
import { CstType, IConstituenta, IRSForm } from '../../../models/rsform';
import { isBaseSet, isBasicConcept, isFunctional } from '../../../models/rsformAPI';
@ -125,7 +125,7 @@ function FormConstituenta({ disabled, id, toggleReset, schema, activeCst, onOpen
placeholder='Обозначение для текстовых определений'
schema={schema}
onOpenEdit={onOpenEdit}
value={field.value}
value={field.value ?? ''}
initialValue={activeCst.term_raw}
resolved={activeCst.term_resolved}
disabled={disabled}
@ -189,7 +189,7 @@ function FormConstituenta({ disabled, id, toggleReset, schema, activeCst, onOpen
maxHeight='8rem'
schema={schema}
onOpenEdit={onOpenEdit}
value={field.value}
value={field.value ?? ''}
initialValue={activeCst.definition_raw}
resolved={activeCst.definition_resolved}
disabled={disabled}

View File

@ -52,6 +52,10 @@ export enum DialogType {
UPLOAD_RSFORM
}
export interface GenericDialogProps {
onHide?: () => void;
}
interface DialogsStore {
active: DialogType | undefined;
props: unknown;
@ -85,7 +89,12 @@ interface DialogsStore {
export const useDialogsStore = create<DialogsStore>()(set => ({
active: undefined,
props: undefined,
hideDialog: () => set({ active: undefined, props: undefined }),
hideDialog: () => {
set(state => {
(state.props as GenericDialogProps | undefined)?.onHide?.();
return { active: undefined, props: undefined };
});
},
showCstTemplate: props => set({ active: DialogType.CONSTITUENTA_TEMPLATE, props: props }),
showCreateCst: props => set({ active: DialogType.CREATE_CONSTITUENTA, props: props }),