mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 15:46:29 +08:00
966 lines
29 KiB
TypeScript
966 lines
29 KiB
TypeScript
/* eslint-disable max-lines */
|
|
import { ErrorBoundary, isEqual } from '@toeverything/utils';
|
|
import isHotkey from 'is-hotkey';
|
|
import isUrl from 'is-url';
|
|
import React, {
|
|
CSSProperties,
|
|
DragEvent,
|
|
forwardRef,
|
|
KeyboardEvent,
|
|
KeyboardEventHandler,
|
|
MouseEvent,
|
|
MouseEventHandler,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
createEditor,
|
|
Descendant,
|
|
Editor,
|
|
Element as SlateElement,
|
|
Node,
|
|
Path,
|
|
Range,
|
|
Transforms,
|
|
} from 'slate';
|
|
import {
|
|
Editable,
|
|
ReactEditor,
|
|
Slate,
|
|
useSlateStatic,
|
|
withReact,
|
|
} from 'slate-react';
|
|
import { CustomElement } from '..';
|
|
import { HOTKEYS, INLINE_STYLES } from './constants';
|
|
import { TextWithComments } from './element-leaf/TextWithComments';
|
|
import { InlineDate, withDate } from './plugins/date';
|
|
import { DoubleLinkComponent } from './plugins/DoubleLink';
|
|
import { LinkComponent, LinkModal, withLinks, wrapLink } from './plugins/link';
|
|
import { InlineRefLink } from './plugins/reflink';
|
|
import {
|
|
Contents,
|
|
isSelectAll,
|
|
SelectionStartAndEnd,
|
|
SlateUtils,
|
|
} from './slate-utils';
|
|
import {
|
|
getCommentsIdsOnTextNode,
|
|
getExtraPropertiesFromEditorOutmostNode,
|
|
isInterceptCharacter,
|
|
matchMarkdown,
|
|
} from './utils';
|
|
|
|
export interface TextProps {
|
|
/** read only */
|
|
readonly?: boolean;
|
|
/** current value */
|
|
currentValue?: CustomElement[];
|
|
/** extra props at editor top level; it's stored at the parent of currentValue */
|
|
textStyle?: Record<string, unknown>;
|
|
/** auto focus */
|
|
autoFocus?: boolean;
|
|
/** id */
|
|
id?: string;
|
|
/** keyDown event, return true, cancel the default behavior */
|
|
handleKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => boolean | undefined;
|
|
/** enter event, return true, cancel the default behavior */
|
|
handleEnter?: ({
|
|
splitContents,
|
|
isShiftKey,
|
|
}: {
|
|
splitContents: Contents;
|
|
isShiftKey: boolean;
|
|
}) => Promise<boolean | undefined> | boolean | undefined;
|
|
/** select event */
|
|
handleSelectAll?: () => void;
|
|
/** select event */
|
|
handleSelect?: (selection: Range) => void;
|
|
/** After text change event, generally used to synchronize model */
|
|
handleChange?: (
|
|
value: SlateElement[],
|
|
textStyle?: Record<string, unknown>
|
|
) => void;
|
|
/** tab event, return true, cancel the default behavior */
|
|
handleTab?: ({
|
|
isShiftKey,
|
|
}: {
|
|
isShiftKey: boolean;
|
|
}) => boolean | undefined | Promise<boolean | undefined>;
|
|
/** Backspace event */
|
|
handleBackSpace?: ({
|
|
isCollAndStart,
|
|
splitContents,
|
|
selection,
|
|
}: {
|
|
isCollAndStart: boolean;
|
|
splitContents: Contents;
|
|
selection: SelectionStartAndEnd;
|
|
}) => boolean | undefined | Promise<boolean | undefined>;
|
|
/** Whether markdown is supported */
|
|
supportMarkdown?: boolean;
|
|
/** Whether to support inline linking */
|
|
supportLink?: boolean;
|
|
/** Whether to show placeholder all the time */
|
|
alwaysShowPlaceholder?: boolean;
|
|
/** placeholder */
|
|
placeholder?: string;
|
|
/** Convert Block API */
|
|
handleConvert?: (type: string, options?: Record<string, unknown>) => void;
|
|
/** undo */
|
|
handleUndo?: () => void;
|
|
/** redo */
|
|
handleRedo?: () => void;
|
|
/** up button */
|
|
handleUp?: (event: KeyboardEvent) => boolean | undefined | Promise<boolean>;
|
|
/** down button */
|
|
handleDown?: (
|
|
event: KeyboardEvent
|
|
) => boolean | undefined | Promise<boolean>;
|
|
/** left button */
|
|
handleLeft?: () => boolean | undefined | Promise<boolean>;
|
|
/** right button */
|
|
handleRight?: () => boolean | undefined | Promise<boolean>;
|
|
/** press / */
|
|
handleSlash?: () => void;
|
|
/** Click event, fired after select */
|
|
handleClick?: MouseEventHandler<HTMLDivElement>;
|
|
handleMouseDown?: MouseEventHandler<HTMLDivElement>;
|
|
/** No need to synchronize the model's change event */
|
|
handleTextChange?: (value: SlateElement[]) => void;
|
|
/** esc */
|
|
handleEsc?: () => void;
|
|
/** focus */
|
|
handleFocus?: (selection: Range) => void;
|
|
handleBlur?: (selection: Range) => void;
|
|
/** hide inlinemenu */
|
|
hideInlineMenu?: () => void;
|
|
/** Whether as a pure controlled component */
|
|
isControlled?: boolean;
|
|
/** The dataset that needs to be added to the text-paragraph dom is initialized and used, and changes are not supported */
|
|
paragraphDataSets?: string[];
|
|
/** class */
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
/** if return true prevent the default slate copy */
|
|
handleCopy?: () => boolean | undefined | Promise<boolean>;
|
|
}
|
|
type ExtendedTextUtils = SlateUtils & {
|
|
setLinkModalVisible: (visible: boolean) => void;
|
|
};
|
|
|
|
// @refresh reset
|
|
export const Text = forwardRef<ExtendedTextUtils, TextProps>((props, ref) => {
|
|
const {
|
|
currentValue = [],
|
|
textStyle = {},
|
|
readonly = false,
|
|
id,
|
|
handleKeyDown,
|
|
handleEnter,
|
|
handleSelectAll,
|
|
handleSelect,
|
|
handleChange,
|
|
handleTab,
|
|
handleBackSpace,
|
|
handleConvert,
|
|
handleRedo,
|
|
handleUndo,
|
|
handleUp,
|
|
handleLeft,
|
|
handleRight,
|
|
handleTextChange,
|
|
handleDown,
|
|
handleClick,
|
|
handleMouseDown,
|
|
handleEsc,
|
|
handleSlash,
|
|
handleFocus,
|
|
handleBlur,
|
|
handleCopy,
|
|
hideInlineMenu,
|
|
className,
|
|
supportMarkdown = true,
|
|
supportLink = true,
|
|
alwaysShowPlaceholder = false,
|
|
placeholder = '',
|
|
autoFocus = false,
|
|
isControlled = false,
|
|
paragraphDataSets = [],
|
|
style,
|
|
} = props;
|
|
|
|
/** forceupdate */
|
|
const [updateTimes, forceUpdate] = useState<number>(0);
|
|
|
|
/** placeholder */
|
|
const [showPlaceholder, setShowPlaceholder] = useState<boolean>(
|
|
() => alwaysShowPlaceholder
|
|
);
|
|
|
|
/** Whether linkModal is displayed */
|
|
const [linkModalVisible, setLinkModalVisible] = useState<boolean>(false);
|
|
|
|
/** linkUrl */
|
|
const [linkUrl, setLinkUrl] = useState<string>('');
|
|
|
|
/** The selection area through blur deselect is used to restore the selection area */
|
|
const previous_selection_from_on_blur_ref = useRef<Range>(null);
|
|
|
|
const focused = useRef(false);
|
|
|
|
const editor = useMemo(
|
|
() => withDate(withLinks(withReact(createEditor() as ReactEditor))),
|
|
[]
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
const newVal = createSlateText(currentValue, textStyle);
|
|
if (!isEqual(editor.children, newVal)) {
|
|
editor.children = newVal;
|
|
forceUpdate(v => v + 1);
|
|
}
|
|
}, [currentValue, editor, textStyle]);
|
|
|
|
const onLinkModalVisibleChange = useCallback(
|
|
(visible: boolean, isInsertLink?: boolean, url?: string) => {
|
|
setLinkModalVisible(visible);
|
|
if (url) {
|
|
setLinkUrl(url);
|
|
}
|
|
if (!isInsertLink && previous_selection_from_on_blur_ref.current) {
|
|
Transforms.select(
|
|
editor,
|
|
previous_selection_from_on_blur_ref.current
|
|
);
|
|
requestAnimationFrame(() => {
|
|
ReactEditor.focus(editor);
|
|
});
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const renderElement = useCallback(
|
|
(props: any) => {
|
|
return (
|
|
<EditorElement
|
|
{...props}
|
|
editor={editor}
|
|
onLinkModalVisibleChange={onLinkModalVisibleChange}
|
|
hideInlineMenu={hideInlineMenu}
|
|
paragraphDataSets={paragraphDataSets}
|
|
id={id}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
editor,
|
|
hideInlineMenu,
|
|
id,
|
|
onLinkModalVisibleChange,
|
|
paragraphDataSets,
|
|
]
|
|
);
|
|
|
|
const renderLeaf = useCallback((props: any) => {
|
|
return <EditorLeaf {...props} />;
|
|
}, []);
|
|
|
|
const utils = useRef<SlateUtils>(null);
|
|
|
|
const resetSelectionIfNeeded = () => {
|
|
if (
|
|
currentValue &&
|
|
Array.isArray(currentValue) &&
|
|
utils.current &&
|
|
editor.selection
|
|
) {
|
|
const { selectionEnd } = utils.current.getSelectionStartAndEnd();
|
|
const end = currentValue[currentValue.length - 1];
|
|
if ('text' in end) {
|
|
const endOffset = end.text.length;
|
|
if (selectionEnd.offset > end.text.length) {
|
|
utils.current.setSelection({
|
|
focus: {
|
|
path: selectionEnd.path,
|
|
offset: endOffset,
|
|
},
|
|
anchor: {
|
|
path: selectionEnd.path,
|
|
offset: endOffset,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!utils.current) {
|
|
utils.current = new SlateUtils(editor);
|
|
if (ref && 'current' in ref) {
|
|
ref.current = utils.current as ExtendedTextUtils;
|
|
ref.current.setLinkModalVisible = setLinkModalVisible;
|
|
}
|
|
}
|
|
if (autoFocus) {
|
|
utils.current.focus();
|
|
}
|
|
return () => {
|
|
utils.current.dispose();
|
|
utils.current = null;
|
|
};
|
|
}, []);
|
|
|
|
const onKeyDown: KeyboardEventHandler<HTMLDivElement> = e => {
|
|
const shouldPreventDefault = handleKeyDown && handleKeyDown(e);
|
|
if (shouldPreventDefault) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (e.metaKey && e.key === 'a') {
|
|
e.stopPropagation();
|
|
if (isSelectAll(editor)) {
|
|
e.preventDefault();
|
|
handleSelectAll && handleSelectAll();
|
|
}
|
|
}
|
|
|
|
if (e.metaKey && e.key === 'z') {
|
|
if (e.shiftKey) {
|
|
// redo
|
|
handleRedo && handleRedo();
|
|
} else {
|
|
// undo
|
|
handleUndo && handleUndo();
|
|
}
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (e.code === 'ShiftLeft') {
|
|
return;
|
|
}
|
|
// https://github.com/facebook/react/issues/13104
|
|
if (!e.nativeEvent.isComposing) {
|
|
switch (e.code) {
|
|
case 'Enter': {
|
|
onEnter(e);
|
|
break;
|
|
}
|
|
case 'Tab': {
|
|
onTab(e);
|
|
break;
|
|
}
|
|
case 'Backspace': {
|
|
onBackSpace(e);
|
|
break;
|
|
}
|
|
case 'ArrowUp': {
|
|
e.stopPropagation();
|
|
onUp(e);
|
|
break;
|
|
}
|
|
case 'ArrowDown': {
|
|
e.stopPropagation();
|
|
onDown(e);
|
|
break;
|
|
}
|
|
case 'ArrowRight': {
|
|
e.stopPropagation();
|
|
onRight(e);
|
|
break;
|
|
}
|
|
case 'ArrowLeft': {
|
|
e.stopPropagation();
|
|
onLeft(e);
|
|
break;
|
|
}
|
|
case 'Digit2': {
|
|
break;
|
|
}
|
|
case 'Escape': {
|
|
e.stopPropagation();
|
|
handleEsc?.();
|
|
}
|
|
}
|
|
}
|
|
handle_hotkey_if_needed(e);
|
|
};
|
|
|
|
const handle_hotkey_if_needed = (e: KeyboardEvent) => {
|
|
for (const hotkey in HOTKEYS) {
|
|
if (isHotkey(hotkey, e)) {
|
|
e.preventDefault();
|
|
const mark = HOTKEYS[hotkey as keyof typeof HOTKEYS];
|
|
if (mark === 'link' && supportLink) {
|
|
setLinkModalVisible(true);
|
|
hideInlineMenu?.();
|
|
return;
|
|
}
|
|
toggleMark(editor, mark);
|
|
}
|
|
}
|
|
};
|
|
|
|
const isMarkActive = (editor: ReactEditor, format: string) => {
|
|
const marks: any = Editor.marks(editor);
|
|
return marks ? marks[format] === true : false;
|
|
};
|
|
|
|
const toggleMark = (editor: ReactEditor, format: string) => {
|
|
const isActive = isMarkActive(editor, format);
|
|
|
|
if (isActive) {
|
|
Editor.removeMark(editor, format);
|
|
} else {
|
|
Editor.addMark(editor, format, true);
|
|
}
|
|
};
|
|
|
|
const onSlash = () => {
|
|
handleSlash && handleSlash();
|
|
};
|
|
|
|
const onDown = (e: KeyboardEvent) => {
|
|
e.stopPropagation();
|
|
preventBindIfNeeded(handleDown)(e, e);
|
|
};
|
|
|
|
const onUp = (e: KeyboardEvent) => {
|
|
e.stopPropagation();
|
|
preventBindIfNeeded(handleUp)(e, e);
|
|
};
|
|
const onRight = (e: KeyboardEvent) => {
|
|
preventBindIfNeeded(handleRight)(e);
|
|
};
|
|
const onLeft = (e: KeyboardEvent) => {
|
|
preventBindIfNeeded(handleLeft)(e);
|
|
};
|
|
|
|
const onEnter = (e: KeyboardEvent) => {
|
|
if (!editor.selection) {
|
|
return;
|
|
}
|
|
if (!e.isDefaultPrevented()) {
|
|
const splitContents = utils.current.getSplitContentsBySelection();
|
|
preventBindIfNeeded(handleEnter)(e, {
|
|
splitContents,
|
|
isShiftKey: !!e.shiftKey,
|
|
});
|
|
// TODO: When re-rendering, onSelect will be triggered again, resulting in the wrong cursor position in the list after carriage return, so manual blur is required, but some cases cannot be manually blurred
|
|
if (
|
|
!Range.equals(
|
|
editor.selection,
|
|
utils.current.getStartSelection()
|
|
) &&
|
|
!e.shiftKey
|
|
) {
|
|
ReactEditor.blur(editor);
|
|
}
|
|
}
|
|
};
|
|
|
|
const onBackSpace = (e: KeyboardEvent) => {
|
|
if (!editor.selection) {
|
|
return;
|
|
}
|
|
const isCool = utils.current.isCollapsed();
|
|
const isCollAndStart = utils.current.isStart() && isCool;
|
|
if (!isCool) {
|
|
hideInlineMenu && hideInlineMenu();
|
|
}
|
|
const selection = utils.current.getSelectionStartAndEnd();
|
|
const splitContents = utils.current.getSplitContentsBySelection();
|
|
preventBindIfNeeded(handleBackSpace)(e, {
|
|
isCollAndStart,
|
|
selection,
|
|
splitContents,
|
|
});
|
|
};
|
|
|
|
const onTab = (e: KeyboardEvent) => {
|
|
if (!editor.selection) {
|
|
return;
|
|
}
|
|
preventBindIfNeeded(handleTab)(e, { isShiftKey: !!e.shiftKey });
|
|
};
|
|
|
|
const onSelect = () => {
|
|
handleSelect && handleSelect(editor.selection);
|
|
};
|
|
|
|
const onChange = (newValue: Descendant[]) => {
|
|
if (newValue?.[0] && 'children' in newValue[0]) {
|
|
const children = [...newValue[0].children];
|
|
handleChange &&
|
|
handleChange(
|
|
children,
|
|
getExtraPropertiesFromEditorOutmostNode(newValue[0])
|
|
);
|
|
if (!isEqual(children, currentValue)) {
|
|
handleTextChange && handleTextChange(children);
|
|
}
|
|
}
|
|
|
|
// https://github.com/ianstormtaylor/slate/issues/2434
|
|
const nowFocus = ReactEditor.isFocused(editor);
|
|
|
|
if (!focused.current && nowFocus) {
|
|
if (!alwaysShowPlaceholder) {
|
|
setShowPlaceholder(true);
|
|
}
|
|
handleFocus?.(editor.selection);
|
|
}
|
|
|
|
focused.current = nowFocus;
|
|
};
|
|
|
|
const onBeforeInput = (e: InputEvent): boolean => {
|
|
// Paste does not follow the default logic
|
|
if (e.inputType === 'insertFromPaste') {
|
|
e.preventDefault();
|
|
return true;
|
|
}
|
|
if (isControlled) {
|
|
// TODO: must be changed to controlled component
|
|
return false;
|
|
}
|
|
if (e.data === '/') {
|
|
onSlash && onSlash();
|
|
return false;
|
|
}
|
|
// link interception
|
|
if (
|
|
supportLink &&
|
|
e.dataTransfer != null &&
|
|
e.dataTransfer.files.length === 0
|
|
) {
|
|
const { selection } = editor;
|
|
const text = e.dataTransfer.getData('text');
|
|
if (
|
|
isUrl(text) &&
|
|
selection.anchor.offset !== selection.focus.offset
|
|
) {
|
|
insertLink(text);
|
|
e.preventDefault();
|
|
return true;
|
|
}
|
|
}
|
|
// markdown interception
|
|
if (supportMarkdown && handleMarkdown(e)) {
|
|
const endOfMarkdown = utils.current.getEndSelection();
|
|
utils.current.setSelection(endOfMarkdown);
|
|
e.preventDefault();
|
|
return true;
|
|
}
|
|
if (handleSoftEnter(e)) {
|
|
e.preventDefault();
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const handleSoftEnter = (e: InputEvent) => {
|
|
if (e.inputType === 'insertLineBreak') {
|
|
// slate directly insertBreak inserts a new paragraph here, we need to insert a real linebreaker
|
|
Editor.insertText(editor, '\n');
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const handleMarkdown = (e: InputEvent) => {
|
|
/**
|
|
* 1. Detected that a suspected markdown logo was entered
|
|
* 2. Further detect whether the line contains markdown syntax, whether or not a newline
|
|
* 3. If it contains markdown syntax, find out the path that should be converted
|
|
* 4. Convert the content of path to the corresponding format
|
|
*/
|
|
if (supportMarkdown && isInterceptCharacter(e.data)) {
|
|
const strToBeTested = `${utils.current.getStringBetweenStartAndSelection()}`;
|
|
const matchRes = matchMarkdown(strToBeTested);
|
|
if (matchRes) {
|
|
if (INLINE_STYLES.includes(matchRes.style)) {
|
|
const pointsRes = utils.current.getPathOfString(matchRes);
|
|
if (pointsRes) {
|
|
const { startLength } = matchRes;
|
|
const { startPoint, endPoint, style } = pointsRes;
|
|
utils.current.turnStyleBetweenPoints(
|
|
startPoint,
|
|
endPoint,
|
|
style,
|
|
startLength
|
|
);
|
|
}
|
|
} else {
|
|
// get rid of the syntax first
|
|
const { style, startLength } = matchRes;
|
|
console.log('startLength', startLength);
|
|
const start = Editor.after(
|
|
editor,
|
|
utils.current.getStart(),
|
|
{ distance: startLength }
|
|
);
|
|
// Conversion logic, pop out
|
|
handleConvert &&
|
|
handleConvert(style, {
|
|
text: utils.current.getContentBetween(
|
|
start,
|
|
utils.current.getEnd()
|
|
),
|
|
});
|
|
}
|
|
e.preventDefault();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
const insertLink = (url: string) => {
|
|
wrapLink(editor, url, previous_selection_from_on_blur_ref.current);
|
|
};
|
|
|
|
const isSelectionError = (error: Error) => {
|
|
return (
|
|
error.message.indexOf(
|
|
'Cannot resolve a DOM point from Slate point:'
|
|
) !== -1
|
|
);
|
|
};
|
|
|
|
const errorHandler = (error: Error, info: { componentStack: string }) => {
|
|
if (!isSelectionError(error)) {
|
|
console.error(`rendering error`, error, info);
|
|
}
|
|
};
|
|
|
|
const ErrorFallback = ({ error, resetErrorBoundary }: any): null => {
|
|
if (isSelectionError(error)) {
|
|
resetErrorBoundary();
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const onClick: MouseEventHandler<HTMLDivElement> = e => {
|
|
handleClick && handleClick(e);
|
|
};
|
|
|
|
const onDrop = (event: DragEvent) => {
|
|
return true;
|
|
};
|
|
|
|
const onDragStart = (event: DragEvent) => {
|
|
return false;
|
|
};
|
|
|
|
const onCopy = () => {
|
|
if (handleCopy) {
|
|
return Boolean(handleCopy());
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const onBlur = () => {
|
|
if (!alwaysShowPlaceholder) {
|
|
setShowPlaceholder(false);
|
|
}
|
|
|
|
if (
|
|
editor.selection &&
|
|
editor.selection !== previous_selection_from_on_blur_ref.current
|
|
) {
|
|
// / ❓ make previous_selection not null, will it affect other features?
|
|
previous_selection_from_on_blur_ref.current = editor.selection;
|
|
utils.current?.setPreviousSelection(editor.selection);
|
|
}
|
|
|
|
Transforms.deselect(editor);
|
|
|
|
handleBlur?.(editor.selection);
|
|
focused.current = false;
|
|
};
|
|
|
|
const onMouseDown = (e: MouseEvent<HTMLDivElement>) => {
|
|
handleMouseDown?.(e);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ErrorBoundary
|
|
onReset={resetSelectionIfNeeded}
|
|
FallbackComponent={ErrorFallback}
|
|
onError={errorHandler}
|
|
>
|
|
<Slate editor={editor} value={currentValue} onChange={onChange}>
|
|
<Editable
|
|
readOnly={readonly}
|
|
renderElement={renderElement}
|
|
renderLeaf={renderLeaf}
|
|
className={`${className} text-manage`}
|
|
style={style}
|
|
placeholder={
|
|
alwaysShowPlaceholder
|
|
? placeholder
|
|
: showPlaceholder
|
|
? placeholder
|
|
: ''
|
|
}
|
|
onKeyDown={onKeyDown}
|
|
onSelect={onSelect}
|
|
onClick={onClick}
|
|
onMouseDown={onMouseDown}
|
|
onDOMBeforeInput={onBeforeInput}
|
|
spellCheck={false}
|
|
scrollSelectionIntoView={() => {}}
|
|
onDragStart={onDragStart}
|
|
onDrop={onDrop}
|
|
onCopy={onCopy}
|
|
onBlur={onBlur}
|
|
/>
|
|
</Slate>
|
|
</ErrorBoundary>
|
|
|
|
{supportLink && linkModalVisible && (
|
|
<LinkModal
|
|
visible={linkModalVisible}
|
|
onVisibleChange={onLinkModalVisibleChange}
|
|
insertLink={insertLink}
|
|
url={linkUrl}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
});
|
|
|
|
const EditorElement = (props: any) => {
|
|
const {
|
|
attributes,
|
|
children,
|
|
element,
|
|
editor,
|
|
onLinkModalVisibleChange,
|
|
hideInlineMenu,
|
|
paragraphDataSets = [],
|
|
id,
|
|
} = props;
|
|
const defaultElementStyles = {
|
|
textAlign: element['textAlign'],
|
|
} as React.CSSProperties;
|
|
|
|
switch (element.type) {
|
|
case 'link': {
|
|
if (element.linkType === 'doubleLink') {
|
|
return <DoubleLinkComponent {...props} editor={editor} />;
|
|
}
|
|
return (
|
|
<LinkComponent
|
|
{...props}
|
|
editor={editor}
|
|
onLinkModalVisibleChange={onLinkModalVisibleChange}
|
|
hideInlineMenu={hideInlineMenu}
|
|
/>
|
|
);
|
|
}
|
|
case 'date': {
|
|
return <InlineDate {...props} />;
|
|
}
|
|
case 'reflink': {
|
|
return <InlineRefLink block={null} pageId={element.reference} />;
|
|
}
|
|
default: {
|
|
for (let i = 0; i < paragraphDataSets.length; i++) {
|
|
attributes[paragraphDataSets] = 'true';
|
|
}
|
|
return (
|
|
<div
|
|
style={defaultElementStyles}
|
|
key={id}
|
|
className="text-paragraph"
|
|
{...attributes}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const EditorLeaf = ({ attributes, children, leaf }: any) => {
|
|
const textStyles = useMemo(() => {
|
|
const styles = {} as { color?: string; backgroundColor?: string };
|
|
if (leaf.fontColor) {
|
|
styles.color = leaf.fontColor as string;
|
|
}
|
|
if (leaf.fontBgColor) {
|
|
styles.backgroundColor = leaf.fontBgColor as string;
|
|
}
|
|
return styles as React.CSSProperties;
|
|
}, [leaf.fontBgColor, leaf.fontColor]);
|
|
|
|
const commentsIds = useMemo(
|
|
() => [...getCommentsIdsOnTextNode(leaf)],
|
|
[leaf]
|
|
);
|
|
|
|
if (leaf.placeholder) {
|
|
return <span {...attributes}>{children}</span>;
|
|
}
|
|
let customChildren = <String {...children.props} />;
|
|
|
|
if (leaf.inlinecode) {
|
|
customChildren = (
|
|
<span {...attributes}>
|
|
<code
|
|
style={{
|
|
backgroundColor: '#F2F5F9',
|
|
borderRadius: '5px',
|
|
color: '#3A4C5C',
|
|
padding: '1px 8px',
|
|
margin: '0 2px',
|
|
}}
|
|
>
|
|
{customChildren}
|
|
</code>
|
|
</span>
|
|
);
|
|
}
|
|
if (leaf.bold) {
|
|
customChildren = <strong>{customChildren}</strong>;
|
|
}
|
|
|
|
if (leaf.italic) {
|
|
customChildren = <em>{customChildren}</em>;
|
|
}
|
|
|
|
if (leaf.underline) {
|
|
attributes.style = {
|
|
...attributes.style,
|
|
borderBottom: '1px solid rgba(204, 204, 204, 0.9)',
|
|
};
|
|
}
|
|
|
|
if (leaf.strikethrough) {
|
|
if (attributes.style) {
|
|
attributes.style = {
|
|
...attributes.style,
|
|
textDecoration: 'line-through',
|
|
};
|
|
} else {
|
|
attributes.style = {
|
|
textDecoration: 'line-through',
|
|
};
|
|
}
|
|
}
|
|
|
|
if (leaf.doubleLinkSearch) {
|
|
customChildren = (
|
|
<span style={{ backgroundColor: '#eee', borderRadius: '4px' }}>
|
|
{customChildren}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
customChildren = (
|
|
<TextWithComments commentsIds={commentsIds}>
|
|
{customChildren}
|
|
</TextWithComments>
|
|
);
|
|
|
|
return (
|
|
<span style={textStyles} {...attributes}>
|
|
{customChildren}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const String = (props: {
|
|
isLast: boolean;
|
|
leaf: Text;
|
|
parent: Element;
|
|
text: Text;
|
|
}) => {
|
|
const { isLast, leaf, parent, text } = props;
|
|
const editor = useSlateStatic();
|
|
const path = ReactEditor.findPath(
|
|
editor as ReactEditor,
|
|
text as unknown as Node
|
|
);
|
|
const parentPath = Path.parent(path);
|
|
|
|
if (editor.isVoid(parent as any)) {
|
|
return <ZeroWidthString length={Node.string(parent as any).length} />;
|
|
}
|
|
|
|
if (
|
|
(leaf as any).text === '' &&
|
|
// @ts-ignore
|
|
parent.children[parent.children.length - 1] === text &&
|
|
!editor.isInline(parent as any) &&
|
|
Editor.string(editor, parentPath) === ''
|
|
) {
|
|
return <ZeroWidthString isLineBreak />;
|
|
}
|
|
|
|
if ((leaf as any).text === '') {
|
|
return <ZeroWidthString />;
|
|
}
|
|
|
|
if (isLast && (leaf as any).text.slice(-1) === '\n') {
|
|
return <TextString isTrailing text={(leaf as any).text} />;
|
|
}
|
|
|
|
return <TextString text={(leaf as any).text} />;
|
|
};
|
|
|
|
const TextString = (props: { text: string; isTrailing?: boolean }) => {
|
|
const { text, isTrailing = false } = props;
|
|
|
|
const textWithTrailing = useMemo(() => {
|
|
return `${text ?? ''}${isTrailing ? '\n' : ''}`;
|
|
}, [text, isTrailing]);
|
|
|
|
return <span data-slate-string>{textWithTrailing}</span>;
|
|
};
|
|
|
|
const ZeroWidthString = (props: { length?: number; isLineBreak?: boolean }) => {
|
|
const { length = 0, isLineBreak = false } = props;
|
|
return (
|
|
<span
|
|
data-slate-zero-width={isLineBreak ? 'n' : 'z'}
|
|
data-slate-length={length}
|
|
>
|
|
{'\uFEFF'}
|
|
{isLineBreak ? <br /> : null}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const preventBindIfNeeded = (cb: any) => {
|
|
return async (e: any, ...args: any[]) => {
|
|
const shouldPreventDefault =
|
|
cb && (args.length ? await cb(args[0]) : await cb());
|
|
if (shouldPreventDefault) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
};
|
|
};
|
|
|
|
const createSlateText = (
|
|
text: CustomElement[],
|
|
textStyle: Record<string, unknown>
|
|
): Descendant[] => {
|
|
const slateText = [
|
|
{
|
|
type: 'paragraph',
|
|
children: [...text],
|
|
...textStyle,
|
|
},
|
|
];
|
|
return slateText;
|
|
};
|