CB-4134 adjust cursor position (#2299)

* CB-4134 adjust cursor position

* CB-4134 pass controlled cursor to the editor

* CB-4134 do not trim value

* CB-4134 revert const history

* CB-4134 review fixes

* CB-4134 remove onUpdate method

---------

Co-authored-by: Evgenia Bezborodova <139753579+EvgeniaBzzz@users.noreply.github.com>
This commit is contained in:
alex
2024-01-25 10:47:47 +03:00
committed by GitHub
co-authored by Evgenia Bezborodova
parent 8df248bfde
commit 3e93752d53
6 changed files with 68 additions and 28 deletions
@@ -6,7 +6,7 @@
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { useCallback, useContext } from 'react';
import { useCallback, useContext, useLayoutEffect, useRef } from 'react';
import { filterLayoutFakeProps, getLayoutProps } from '../Containers/filterLayoutFakeProps';
import type { ILayoutSizeProps } from '../Containers/ILayoutSizeProps';
@@ -23,6 +23,7 @@ type BaseProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChan
description?: string;
labelTooltip?: string;
embedded?: boolean;
cursorInitiallyAtEnd?: boolean;
};
type ControlledProps = BaseProps & {
@@ -54,9 +55,11 @@ export const Textarea: TextareaType = observer(function Textarea({
description,
labelTooltip,
embedded,
cursorInitiallyAtEnd,
onChange = () => {},
...rest
}: ControlledProps | ObjectProps<any, any>) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const layoutProps = getLayoutProps(rest);
rest = filterLayoutFakeProps(rest);
const styles = useS(textareaStyle);
@@ -79,13 +82,21 @@ export const Textarea: TextareaType = observer(function Textarea({
const value = state ? state[name] : controlledValue;
useLayoutEffect(() => {
if (cursorInitiallyAtEnd && typeof value === 'string') {
const position = value.length;
textareaRef.current?.setSelectionRange(position, position);
}
}, [cursorInitiallyAtEnd]);
return (
<Field {...layoutProps} className={s(styles, { field: true, embedded }, className)}>
<FieldLabel className={s(styles, { fieldLabel: true })} title={labelTooltip || rest.title} required={required}>
<FieldLabel className={s(styles, { fieldLabel: true })} title={labelTooltip || rest.title} required={required}>
{children}
</FieldLabel>
<textarea
{...rest}
ref={textareaRef}
required={required}
className={s(styles, { textarea: true })}
value={value ?? ''}
@@ -5,17 +5,25 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import type { Compartment, Extension } from '@codemirror/state';
import type { Compartment, Extension, SelectionRange } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
/** Currently we support only main selection range */
interface ISelection {
anchor: number;
head?: number;
}
export interface IReactCodeMirrorProps extends React.PropsWithChildren {
/** in case of using editor in editing mode its better for performance to use getValue instead */
value?: string;
cursor?: ISelection;
incomingValue?: string;
getValue?: () => string;
extensions?: Map<Compartment, Extension>;
readonly?: boolean;
autoFocus?: boolean;
onChange?: (value: string, update: ViewUpdate) => void;
onCursorChange?: (selection: SelectionRange, update: ViewUpdate) => void;
onUpdate?: (update: ViewUpdate) => void;
}
@@ -6,7 +6,7 @@
* you may not use this file except in compliance with the License.
*/
import { MergeView } from '@codemirror/merge';
import { Annotation, Compartment, Extension, StateEffect } from '@codemirror/state';
import { Annotation, Compartment, Extension, StateEffect, TransactionSpec } from '@codemirror/state';
import { EditorView, ViewUpdate } from '@codemirror/view';
import { observer } from 'mobx-react-lite';
import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
@@ -22,7 +22,19 @@ const External = Annotation.define<boolean>();
export const ReactCodemirror = observer<IReactCodeMirrorProps, IEditorRef>(
forwardRef(function ReactCodemirror(
{ children, getValue, value, incomingValue, extensions = new Map<Compartment, Extension>(), readonly, autoFocus, onChange, onUpdate },
{
children,
getValue,
value,
cursor,
incomingValue,
extensions = new Map<Compartment, Extension>(),
readonly,
autoFocus,
onChange,
onCursorChange,
onUpdate,
},
ref,
) {
value = value ?? getValue?.();
@@ -32,7 +44,7 @@ export const ReactCodemirror = observer<IReactCodeMirrorProps, IEditorRef>(
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [view, setView] = useState<EditorView | null>(null);
const [incomingView, setIncomingView] = useState<EditorView | null>(null);
const callbackRef = useObjectRef({ onChange, onUpdate });
const callbackRef = useObjectRef({ onChange, onCursorChange, onUpdate });
const [selection, setSelection] = useState(view?.state.selection.main ?? null);
useLayoutEffect(() => {
@@ -50,6 +62,11 @@ export const ReactCodemirror = observer<IReactCodeMirrorProps, IEditorRef>(
callbackRef.onChange?.(value, update);
}
if (update.selectionSet && !remote) {
const selection = update.state.selection.main;
callbackRef.onCursorChange?.(selection, update);
}
callbackRef.onUpdate?.(update);
});
@@ -136,13 +153,22 @@ export const ReactCodemirror = observer<IReactCodeMirrorProps, IEditorRef>(
});
useLayoutEffect(() => {
if (value !== undefined && view && value !== view.state.doc.toString()) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
annotations: [External.of(true)],
});
if (view) {
const transaction: TransactionSpec = { annotations: [External.of(true)] };
if (value !== undefined && value !== view.state.doc.toString()) {
transaction.changes = { from: 0, to: view.state.doc.length, insert: value };
}
if (cursor && (view.state.selection.main.anchor !== cursor.anchor || view.state.selection.main.head !== cursor.head)) {
transaction.selection = cursor;
}
if (transaction.changes || transaction.selection) {
view.dispatch(transaction);
}
}
}, [value, view]);
});
useLayoutEffect(() => {
if (incomingValue !== undefined && incomingView && incomingValue !== incomingView.state.doc.toString()) {
@@ -125,13 +125,17 @@ export const SQLCodeEditorPanel: TabContainerPanelComponent<ISqlEditorModeProps>
<SQLCodeEditorLoader
ref={setEditorRef}
getValue={() => data.value}
cursor={{
anchor: data.cursor.begin,
head: data.cursor.end,
}}
incomingValue={data.incomingValue}
extensions={extensions}
readonly={data.readonly}
autoFocus
lineNumbers
onChange={panel.onQueryChange}
onUpdate={panel.onUpdate}
onCursorChange={selection => panel.onCursorChange(selection.from, selection.to)}
>
{data.isIncomingChanges && (
<>
@@ -5,6 +5,7 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { action } from 'mobx';
import { useCallback } from 'react';
import { useExecutor, useObservableRef } from '@cloudbeaver/core-blocks';
@@ -17,7 +18,7 @@ import type { IEditor } from '../SQLCodeEditor/useSQLCodeEditor';
interface State {
highlightActiveQuery: () => void;
onQueryChange: (query: string) => void;
onUpdate: (update: ViewUpdate) => void;
onCursorChange: (begin: number, end?: number) => void;
}
export function useSQLCodeEditorPanel(data: ISQLEditorData, editor: IEditor) {
@@ -35,21 +36,12 @@ export function useSQLCodeEditorPanel(data: ISQLEditorData, editor: IEditor) {
onQueryChange(query: string) {
this.data.setScript(query);
},
onUpdate(update: ViewUpdate) {
const transactions = update.transactions.filter(t => t.selection !== undefined);
const lastTransaction = transactions[transactions.length - 1] as Transaction | undefined;
if (lastTransaction) {
const from = lastTransaction.selection?.main.from ?? update.state.selection.main.from;
const to = lastTransaction.selection?.main.to ?? update.state.selection.main.to;
this.data.setCursor(from, to);
}
onCursorChange(begin: number, end?: number) {
this.data.setCursor(begin, end);
},
}),
{},
{ onQueryChange: action.bound, onCursorChange: action.bound },
{ editor, data },
['onQueryChange', 'onUpdate'],
);
const updateHighlight = useCallback(
@@ -138,7 +138,6 @@ export function useSqlEditor(state: ISqlEditorTabState): ISQLEditorData {
onExecute: new SyncExecutor(),
onSegmentExecute: new SyncExecutor(),
onUpdate: new SyncExecutor(),
onFormat: new SyncExecutor(),
parser: new SQLParser(),
cursor: { begin: 0, end: 0 },
@@ -225,8 +224,8 @@ export function useSqlEditor(state: ISqlEditorTabState): ISQLEditorData {
this.readonlyState = true;
const formatted = await this.sqlDialectInfoService.formatScript(this.dataSource.executionContext, script.query);
this.onFormat.execute([script, formatted]);
this.setScript(query.substring(0, script.begin) + formatted + query.substring(script.end));
this.setCursor(script.begin + formatted.length);
} finally {
this.readonlyState = false;
}