uui-editor/src/SlateEditor.tsx (140 lines of code) (raw):
import React, {
FocusEventHandler, forwardRef, KeyboardEventHandler, memo, useCallback, useEffect, useMemo, useRef, useState,
} from 'react';
import {
IHasCX, IHasRawProps, cx, uuiMod, useForceUpdate,
IEditable,
} from '@epam/uui-core';
import { ScrollBars } from '@epam/uui';
import {
Plate, PlateContent, PlateEditor, PlatePlugin, Value, createPlugins, useComposedRef,
} from '@udecode/plate-common';
import { createPlateUI } from './components';
import { Toolbars } from './implementation/Toolbars';
import { EditorValue } from './types';
import css from './SlateEditor.module.scss';
import { useFocusEvents } from './plugins/eventEditorPlugin';
import { isEditorValueEmpty } from './helpers';
import { getMigratedPlateValue, isPlateValue } from './migrations';
import { PlateProps } from '@udecode/plate-core';
import isEqual from 'react-fast-compare';
export interface PlateEditorProps
extends IEditable<EditorValue>,
IHasCX,
IHasRawProps<React.HTMLAttributes<HTMLDivElement>>, Pick<PlateProps, 'maxLength'> {
plugins: PlatePlugin[];
isReadonly?: boolean;
autoFocus?: boolean;
minHeight?: number | 'none';
placeholder?: string;
mode?: 'form' | 'inline';
fontSize?: '14' | '16';
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
onBlur?: FocusEventHandler<HTMLDivElement>;
onFocus?: FocusEventHandler<HTMLDivElement>;
scrollbars?: boolean;
toolbarPosition?: 'floating' | 'fixed';
}
export const SlateEditor = memo(forwardRef<HTMLDivElement, PlateEditorProps>((props, ref) => {
const [currentId] = useState(String(Date.now()));
const editorRef = useRef<PlateEditor | null>(null);
const editableWrapperRef = useRef<HTMLDivElement>();
const prevChangedValue = useRef(props.value);
/** value */
/** consider legacy slate to plate content migraions once. should be deprecated in the near future */
const plateValue: Value | undefined = useMemo(
() => {
return getMigratedPlateValue(props.value);
},
[props.value],
);
const initialPlateValue: Value | undefined = useMemo(() => {
const content = editorRef.current?.children;
if (content) return content;
return plateValue;
}, [plateValue]);
const { isReadonly, onValueChange } = props;
const onChange = useCallback((v: Value) => {
if (isReadonly) {
return;
}
prevChangedValue.current = v;
onValueChange(v);
}, [isReadonly, onValueChange]);
/** config */
const plugins = useMemo(
() => createPlugins(props.plugins, { components: createPlateUI() }),
[props.plugins],
);
/** styles */
const contentStyle = useMemo(() => ({ minHeight: props.minHeight }), [props.minHeight]);
const editorWrapperClassNames = useMemo(() => cx(
'uui-typography',
props.cx,
css.container,
css['mode-' + (props.mode || 'form')],
props.isReadonly && uuiMod.readonly,
props.scrollbars && css.withScrollbars,
props.fontSize === '16' ? 'uui-typography-size-16' : 'uui-typography-size-14',
), [props.cx, props.fontSize, props.isReadonly, props.mode, props.scrollbars]);
/** focus management */
/** TODO: move to plate */
useFocusEvents({
editorId: currentId,
editorWrapperRef: editableWrapperRef,
isReadonly: props.isReadonly,
});
const autoFocusRef = useCallback((node: HTMLDivElement) => {
if (!editableWrapperRef.current && node) {
editableWrapperRef.current = node;
if (!props.isReadonly && props.autoFocus) {
editableWrapperRef.current.classList.add(uuiMod.focus);
}
}
return editableWrapperRef;
}, [props.autoFocus, props.isReadonly]);
const composedRef = useComposedRef(autoFocusRef, ref);
/** render related */
const renderContent = useCallback(() => {
const editor = editorRef.current;
const displayPlaceholder = !editor || (!!editor.children && isEditorValueEmpty(editor.children));
const placeholder = displayPlaceholder ? props.placeholder : undefined;
return (
<PlateContent
id={ currentId }
autoFocus={ props.autoFocus }
readOnly={ props.isReadonly }
className={ css.editor }
onKeyDown={ props.onKeyDown }
onBlur={ props.onBlur }
onFocus={ props.onFocus }
placeholder={ placeholder }
style={ contentStyle }
/>
);
}, [props.placeholder, props.autoFocus, props.isReadonly, props.onKeyDown, props.onBlur, props.onFocus, currentId, contentStyle]);
/** could not be memoized, since slate is uncontrolled component */
const content = props.scrollbars
? <ScrollBars cx={ css.scrollbars }>{ renderContent() }</ScrollBars>
: renderContent();
/** force update of uncontrolled component. Danger part, because Slate component is uncontrolled by default and doesn't support value change after init.
* This code can produce unexpected effects and bugs, e.g. updating value in a such value doesn't call value normalizers.
* We just guarantee that we can apply value change from empty state to the new one, like after content loading. Try to avoid updating editor value in other cases.
*/
const forceUpdate = useForceUpdate();
useEffect(() => {
if (isPlateValue(plateValue) && editorRef.current && !isEqual(prevChangedValue.current, props.value)) {
editorRef.current.children = plateValue;
prevChangedValue.current = props.value;
forceUpdate();
}
}, [props.value]);
return (
<Plate
key={ currentId }
id={ currentId }
initialValue={ initialPlateValue }
normalizeInitialValue // invokes plate migrations
plugins={ plugins }
onChange={ onChange }
editorRef={ editorRef }
maxLength={ props.maxLength }
>
<div
ref={ composedRef }
className={ editorWrapperClassNames }
{ ...props.rawProps }
>
{content}
<Toolbars toolbarPosition={ props.toolbarPosition } />
</div>
</Plate>
);
}));