mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
init: the first public commit for AFFiNE
This commit is contained in:
940
libs/components/common/src/lib/text/EditableText.tsx
Normal file
940
libs/components/common/src/lib/text/EditableText.tsx
Normal file
@@ -0,0 +1,940 @@
|
||||
/* eslint-disable max-lines */
|
||||
import React, {
|
||||
KeyboardEvent,
|
||||
KeyboardEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
MouseEventHandler,
|
||||
useLayoutEffect,
|
||||
CSSProperties,
|
||||
MouseEvent,
|
||||
DragEvent,
|
||||
} from 'react';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import {
|
||||
createEditor,
|
||||
Descendant,
|
||||
Range,
|
||||
Element as SlateElement,
|
||||
Editor,
|
||||
Transforms,
|
||||
Node,
|
||||
Path,
|
||||
} from 'slate';
|
||||
import {
|
||||
Editable,
|
||||
withReact,
|
||||
Slate,
|
||||
ReactEditor,
|
||||
useSlateStatic,
|
||||
} from 'slate-react';
|
||||
|
||||
import { ErrorBoundary, isEqual, uaHelper } from '@toeverything/utils';
|
||||
|
||||
import { Contents, SlateUtils, isSelectAll } from './slate-utils';
|
||||
import {
|
||||
getCommentsIdsOnTextNode,
|
||||
getExtraPropertiesFromEditorOutmostNode,
|
||||
isInterceptCharacter,
|
||||
matchMarkdown,
|
||||
} from './utils';
|
||||
import { HOTKEYS, INLINE_STYLES } from './constants';
|
||||
import { LinkComponent, LinkModal, withLinks, wrapLink } from './plugins/link';
|
||||
import { withDate, InlineDate } from './plugins/date';
|
||||
import { CustomElement } from '..';
|
||||
import isUrl from 'is-url';
|
||||
import { InlineRefLink } from './plugins/reflink';
|
||||
import { TextWithComments } from './element-leaf/TextWithComments';
|
||||
|
||||
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,
|
||||
}: {
|
||||
isCollAndStart: boolean;
|
||||
}) => 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();
|
||||
}
|
||||
preventBindIfNeeded(handleBackSpace)(e, { isCollAndStart });
|
||||
};
|
||||
|
||||
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 start_selection = utils.current.getStartSelection();
|
||||
utils.current.setSelection(start_selection);
|
||||
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': {
|
||||
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: 'rgba(135,131,120,0.15)',
|
||||
borderRadius: '3px',
|
||||
color: '#EB5757',
|
||||
fontSize: '0.875em',
|
||||
padding: '0.25em 0.375em',
|
||||
}}
|
||||
>
|
||||
{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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
192
libs/components/common/src/lib/text/constants.ts
Normal file
192
libs/components/common/src/lib/text/constants.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
const InnerTextStyle = {
|
||||
FONT_FAMILY: 'FONT_FAMILY',
|
||||
FONT_SIZE: 'FONT_SIZE',
|
||||
COLOR: 'FOREGROUND_COLOR',
|
||||
BACKGROUND_COLOR: 'BACKGROUND_COLOR',
|
||||
BOLD: 'BOLD',
|
||||
ITALIC: 'ITALIC',
|
||||
UNDERLINE: 'UNDERLINE',
|
||||
STRIKETHROUGH: 'STRIKETHROUGH',
|
||||
VERTICAL_ALIGN: 'VERTICAL_ALIGN',
|
||||
UNDER_LINE: 'UNDER_LINE',
|
||||
};
|
||||
export const MARKDOWN_REGS = [
|
||||
{
|
||||
type: InnerTextStyle.BOLD,
|
||||
reg: /\*{2}.+\*{2}$/,
|
||||
start: '**',
|
||||
end: '**',
|
||||
},
|
||||
{
|
||||
type: InnerTextStyle.ITALIC,
|
||||
reg: /\*.+\*$/,
|
||||
start: '*',
|
||||
end: '*',
|
||||
},
|
||||
{
|
||||
type: InnerTextStyle.STRIKETHROUGH,
|
||||
reg: /[~~]{2}.+[~~]{2}$/,
|
||||
start: '~~',
|
||||
end: '~~',
|
||||
},
|
||||
{
|
||||
type: InnerTextStyle.UNDER_LINE,
|
||||
reg: /[~~].+[~~]$/,
|
||||
start: '~',
|
||||
end: '~',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.heading3,
|
||||
reg: /^#{3}$/,
|
||||
start: '###',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.heading2,
|
||||
reg: /^#{2}$/,
|
||||
start: '##',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.heading1,
|
||||
reg: /^#$/,
|
||||
start: '#',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.numbered,
|
||||
reg: /^1\.$/,
|
||||
start: '1.',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.bullet,
|
||||
reg: /^(-|\*|\+)$/,
|
||||
start: '*',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.todo,
|
||||
reg: /^(\[]|【】)$/,
|
||||
start: '[]',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.code,
|
||||
reg: /^`{3}$/,
|
||||
start: '```',
|
||||
},
|
||||
// {
|
||||
// type: Protocol.Block.Type.toc,
|
||||
// reg: /^\[toc\]$/,
|
||||
// start: '[t'
|
||||
// },
|
||||
{
|
||||
type: Protocol.Block.Type.quote,
|
||||
reg: /^[>》]$/,
|
||||
start: '>',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.groupDivider,
|
||||
reg: /^(={3}|\*{3})$/,
|
||||
start: '===',
|
||||
},
|
||||
// divider
|
||||
{
|
||||
type: Protocol.Block.Type.divider,
|
||||
reg: /^(-{3}|\*{3})$/,
|
||||
start: '---',
|
||||
},
|
||||
// callout
|
||||
{
|
||||
type: Protocol.Block.Type.callout,
|
||||
reg: /^!-$/,
|
||||
start: '!-',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.file,
|
||||
reg: /^file$/,
|
||||
start: 'file',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.image,
|
||||
reg: /^img$/,
|
||||
start: 'img',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.youtube,
|
||||
reg: /^youtube$/,
|
||||
start: 'youtube',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.figma,
|
||||
reg: /^figma$/,
|
||||
start: 'figma',
|
||||
},
|
||||
{
|
||||
type: Protocol.Block.Type.embedLink,
|
||||
reg: /^embedLink$/,
|
||||
start: 'embedLink',
|
||||
},
|
||||
];
|
||||
|
||||
export const HOTKEYS = {
|
||||
'mod+b': 'bold',
|
||||
'mod+i': 'italic',
|
||||
'mod+u': 'underline',
|
||||
'mod+k': 'link',
|
||||
'mod+shift+s': 'strikethrough',
|
||||
'mod+e': 'inlinecode',
|
||||
} as const;
|
||||
|
||||
export const fontColorPalette = {
|
||||
default: 'rgb(0,0,0)',
|
||||
affineGray: 'rgb(155, 154, 151)',
|
||||
affineBrown: 'rgb(100, 71, 58)',
|
||||
affineOrange: 'rgb(217, 115, 13)',
|
||||
affineYellow: 'rgb(223, 171, 1)',
|
||||
affineGreen: 'rgb(77, 100, 97)',
|
||||
affineBlue: 'rgb(11, 110, 153)',
|
||||
affinePurple: 'rgb(105, 64, 165)',
|
||||
affinePink: 'rgb(173, 26, 114)',
|
||||
affineRed: 'rgb(224, 62, 62)',
|
||||
} as const;
|
||||
|
||||
export const fontBgColorPalette = {
|
||||
default: 'rgb(255,255,255)',
|
||||
affineGray: 'rgb(235, 236, 237)',
|
||||
affineBrown: 'rgb(233, 229, 227)',
|
||||
affineOrange: 'rgb(250, 235, 221)',
|
||||
affineYellow: 'rgb(251, 243, 219)',
|
||||
affineGreen: 'rgb(221, 237, 234)',
|
||||
affineBlue: 'rgb(221, 235, 241)',
|
||||
affinePurple: 'rgb(234, 228, 242)',
|
||||
affinePink: 'rgb(244, 223, 235)',
|
||||
affineRed: 'rgb(251, 228, 228)',
|
||||
} as const;
|
||||
|
||||
export const fontColorPaletteKeys = Object.keys(fontColorPalette).reduce(
|
||||
(aac, curr) => ({ [curr]: curr, ...aac }),
|
||||
{}
|
||||
) as Record<keyof typeof fontColorPalette, keyof typeof fontColorPalette>;
|
||||
|
||||
export const fontBgColorPaletteKeys = Object.keys(fontBgColorPalette).reduce(
|
||||
(aac, curr) => ({ [curr]: curr, ...aac }),
|
||||
{}
|
||||
) as Record<keyof typeof fontBgColorPalette, keyof typeof fontBgColorPalette>;
|
||||
|
||||
export type TextStyleMark =
|
||||
| typeof HOTKEYS[keyof typeof HOTKEYS]
|
||||
| 'fontColor'
|
||||
| 'fontBgColor';
|
||||
|
||||
export type TextAlignOptions =
|
||||
| 'left'
|
||||
| 'center'
|
||||
| 'right'
|
||||
| 'justify'
|
||||
| undefined;
|
||||
|
||||
export const interceptMarks = ['\u0020'];
|
||||
|
||||
export const INLINE_STYLES = [
|
||||
InnerTextStyle.BOLD,
|
||||
InnerTextStyle.STRIKETHROUGH,
|
||||
InnerTextStyle.UNDER_LINE,
|
||||
InnerTextStyle.ITALIC,
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { styled, MuiClickAwayListener } from '@toeverything/components/ui';
|
||||
|
||||
type TextWithCommentsProps = {
|
||||
commentsIds: string[];
|
||||
isActive?: boolean;
|
||||
children?: ReactNode;
|
||||
[p: string]: unknown;
|
||||
};
|
||||
|
||||
export const TextWithComments = (props: TextWithCommentsProps) => {
|
||||
const { children, ...restProps } = props;
|
||||
// const [isActive, setIsActive] = useState(false);
|
||||
// console.log(';; isActive ', isActive, props.commentsIds);
|
||||
|
||||
return <StyledText {...restProps}>{children}</StyledText>;
|
||||
|
||||
// return props.commentsIds.length > 0 ? (
|
||||
// <MuiClickAwayListener
|
||||
// onClickAway={() => {
|
||||
// setIsActive(false);
|
||||
// }}
|
||||
// >
|
||||
// <StyledText
|
||||
// isActive={isActive}
|
||||
// onClick={() => {
|
||||
// setIsActive(true);
|
||||
// }}
|
||||
// {...restProps}
|
||||
// >
|
||||
// {children}
|
||||
// </StyledText>
|
||||
// </MuiClickAwayListener>
|
||||
// ) : (
|
||||
// <StyledText {...restProps}>{children}</StyledText>
|
||||
// );
|
||||
};
|
||||
|
||||
const StyledText = styled('span', {
|
||||
shouldForwardProp: (prop: string) =>
|
||||
!['commentsIds', 'isActive'].includes(prop),
|
||||
})<TextWithCommentsProps>(({ theme, commentsIds, isActive }) => {
|
||||
return {
|
||||
width: 20,
|
||||
height: 20,
|
||||
// color: '',
|
||||
backgroundColor:
|
||||
commentsIds.length > 1
|
||||
? 'rgba(19, 217, 227, 0.4)'
|
||||
: commentsIds.length === 1
|
||||
? 'rgba(19, 217, 227, 0.2)'
|
||||
: '',
|
||||
border: isActive ? '2px solid rgba(19, 217, 227, 0.3)' : '',
|
||||
};
|
||||
});
|
||||
8
libs/components/common/src/lib/text/index.ts
Normal file
8
libs/components/common/src/lib/text/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { TextProps } from './EditableText';
|
||||
export { Text } from './EditableText';
|
||||
export type { Contents } from './slate-utils';
|
||||
export { SlateUtils } from './slate-utils';
|
||||
export * from './constants';
|
||||
export { getRandomString, getEditorMarkForCommentId } from './utils';
|
||||
|
||||
export { InlineRefLink } from './plugins/reflink';
|
||||
59
libs/components/common/src/lib/text/plugins/date.tsx
Normal file
59
libs/components/common/src/lib/text/plugins/date.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { useRichStyle } from './hooks';
|
||||
|
||||
export const withDate = (editor: ReactEditor) => {
|
||||
const { isInline, isVoid } = editor;
|
||||
|
||||
editor.isInline = element => {
|
||||
// @ts-ignore
|
||||
return element.type === 'date' ? true : isInline(element);
|
||||
};
|
||||
|
||||
editor.isVoid = element => {
|
||||
// @ts-ignore
|
||||
return element.type === 'date' ? true : isVoid(element);
|
||||
};
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
export const InlineDate = ({ attributes, children, element }: any) => {
|
||||
const onClick = () => {
|
||||
console.log('date click');
|
||||
};
|
||||
|
||||
const richStyles = useRichStyle(element);
|
||||
|
||||
const time = useMemo(() => {
|
||||
const { timeStamp } = element;
|
||||
const date = new Date(timeStamp);
|
||||
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
|
||||
}, [element]);
|
||||
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
contentEditable={false}
|
||||
data-cy={`${element.timeStamp}`}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
margin: '0 1px',
|
||||
verticalAlign: 'baseline',
|
||||
display: 'inline-block',
|
||||
color: 'darkgray',
|
||||
cursor: 'pointer',
|
||||
fontStyle: richStyles['italic'] ? 'italic' : 'none',
|
||||
textDecoration: `${
|
||||
richStyles['strikethrough'] ? 'line-through' : ''
|
||||
} ${richStyles['underline'] ? 'underline' : ''}`,
|
||||
fontWeight: richStyles['bold'] ? 'bolder' : 'normal',
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span style={{ opacity: 0.6 }}>@</span>
|
||||
{time}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
22
libs/components/common/src/lib/text/plugins/hooks.tsx
Normal file
22
libs/components/common/src/lib/text/plugins/hooks.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useRichStyle = function (element: any) {
|
||||
const richStyles = useMemo(() => {
|
||||
const richStyles: { [key: string]: boolean } = {};
|
||||
if (element.bold) {
|
||||
richStyles['bold'] = true;
|
||||
}
|
||||
if (element.italic) {
|
||||
richStyles['italic'] = true;
|
||||
}
|
||||
if (element.underline) {
|
||||
richStyles['underline'] = true;
|
||||
}
|
||||
if (element.strikethrough) {
|
||||
richStyles['strikethrough'] = true;
|
||||
}
|
||||
return richStyles;
|
||||
}, [element]);
|
||||
|
||||
return richStyles;
|
||||
};
|
||||
572
libs/components/common/src/lib/text/plugins/link.tsx
Normal file
572
libs/components/common/src/lib/text/plugins/link.tsx
Normal file
@@ -0,0 +1,572 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
memo,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import isUrl from 'is-url';
|
||||
import style9 from 'style9';
|
||||
import {
|
||||
Editor,
|
||||
Transforms,
|
||||
Element as SlateElement,
|
||||
Descendant,
|
||||
Range as SlateRange,
|
||||
Node,
|
||||
} from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import LinkOffIcon from '@mui/icons-material/LinkOff';
|
||||
import AttachmentIcon from '@mui/icons-material/Attachment';
|
||||
import {
|
||||
MuiTooltip as Tooltip,
|
||||
styled,
|
||||
muiTooltipClasses,
|
||||
type MuiTooltipProps,
|
||||
} from '@toeverything/components/ui';
|
||||
import {
|
||||
getRelativeUrlForInternalPageUrl,
|
||||
isInternalPageUrl,
|
||||
} from '@toeverything/utils';
|
||||
|
||||
import { getRandomString } from '../utils';
|
||||
import { colors } from '../../colors';
|
||||
|
||||
export type LinkElement = {
|
||||
type: 'link';
|
||||
url: string;
|
||||
children: Descendant[];
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const withLinks = (editor: ReactEditor) => {
|
||||
const { isInline } = editor;
|
||||
|
||||
editor.isInline = element => {
|
||||
// @ts-ignore
|
||||
return element.type === 'link' ? true : isInline(element);
|
||||
};
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
const unwrapLink = (editor: Editor) => {
|
||||
Transforms.unwrapNodes(editor, {
|
||||
match: n => {
|
||||
return (
|
||||
!Editor.isEditor(n) &&
|
||||
SlateElement.isElement(n) &&
|
||||
// @ts-expect-error
|
||||
n.type === 'link'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const wrapLink = (
|
||||
editor: ReactEditor,
|
||||
url: string,
|
||||
preSelection?: SlateRange
|
||||
) => {
|
||||
if (!ReactEditor.isFocused(editor) && preSelection) {
|
||||
Transforms.select(editor, preSelection);
|
||||
}
|
||||
if (isLinkActive(editor)) {
|
||||
unwrapLink(editor);
|
||||
}
|
||||
const realUrl = normalizeUrl(url);
|
||||
const { selection } = editor;
|
||||
const isCollapsed = selection && SlateRange.isCollapsed(selection);
|
||||
const link: LinkElement = {
|
||||
type: 'link',
|
||||
url: realUrl,
|
||||
children: isCollapsed ? [{ text: realUrl }] : [],
|
||||
id: getRandomString('link'),
|
||||
};
|
||||
|
||||
if (isCollapsed) {
|
||||
Transforms.insertNodes(editor, link as Node);
|
||||
} else {
|
||||
Transforms.wrapNodes(editor, link, { split: true });
|
||||
Transforms.collapse(editor, { edge: 'end' });
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
ReactEditor.focus(editor);
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeUrl = (url: string) => {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
return /^https?/.test(url) ? url : `${location.protocol}//${url}`;
|
||||
};
|
||||
|
||||
const isLinkActive = (editor: ReactEditor) => {
|
||||
const [link] = Editor.nodes(editor, {
|
||||
match: n =>
|
||||
!Editor.isEditor(n) &&
|
||||
SlateElement.isElement(n) &&
|
||||
// @ts-expect-error
|
||||
n.type === 'link',
|
||||
});
|
||||
return !!link;
|
||||
};
|
||||
|
||||
const LinkStyledTooltip = styled(({ className, ...props }: MuiTooltipProps) => (
|
||||
<Tooltip {...props} classes={{ popper: className }} />
|
||||
))(() => ({
|
||||
[`& .${muiTooltipClasses.tooltip}`]: {
|
||||
backgroundColor: '#fff',
|
||||
color: '#4C6275',
|
||||
boxShadow: '0px 1px 10px rgba(152, 172, 189, 0.6)',
|
||||
fontSize: '14px',
|
||||
},
|
||||
[`& .MuiTooltip-tooltipPlacementBottom`]: {
|
||||
// prevent tooltip disappear as soon as mouse moves
|
||||
// margin: 0
|
||||
},
|
||||
}));
|
||||
|
||||
export const LinkComponent = ({
|
||||
attributes,
|
||||
children,
|
||||
element,
|
||||
editor,
|
||||
onLinkModalVisibleChange,
|
||||
hideInlineMenu,
|
||||
}: any) => {
|
||||
const navigate = useNavigate();
|
||||
const [tooltip_visible, set_tooltip_visible] = useState(false);
|
||||
|
||||
const handle_tooltip_visible_change = useCallback((visible: boolean) => {
|
||||
set_tooltip_visible(visible);
|
||||
}, []);
|
||||
|
||||
const handle_link_modal_visible_change = useCallback(
|
||||
(visible: boolean, url?: string) => {
|
||||
onLinkModalVisibleChange(visible, undefined, url);
|
||||
},
|
||||
[onLinkModalVisibleChange]
|
||||
);
|
||||
|
||||
const handle_click_link_text = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
// prevent route to href url
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { url } = element;
|
||||
|
||||
if (isInternalPageUrl(url)) {
|
||||
navigate(getRelativeUrlForInternalPageUrl(url));
|
||||
} else {
|
||||
const new_window = window.open(url, '_blank');
|
||||
if (new_window) {
|
||||
new_window.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
[element, navigate]
|
||||
);
|
||||
|
||||
return (
|
||||
<LinkStyledTooltip
|
||||
open={tooltip_visible}
|
||||
onOpen={() => handle_tooltip_visible_change(true)}
|
||||
onClose={() => handle_tooltip_visible_change(false)}
|
||||
placement="bottom-start"
|
||||
title={
|
||||
<LinkTooltips
|
||||
url={element.url}
|
||||
id={element.id}
|
||||
editor={editor}
|
||||
onLinkModalVisibleChange={handle_link_modal_visible_change}
|
||||
onVisibleChange={handle_tooltip_visible_change}
|
||||
hideInlineMenu={hideInlineMenu}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<a
|
||||
{...attributes}
|
||||
className={styles({
|
||||
linkWrapper: true,
|
||||
linkWrapperHover: tooltip_visible,
|
||||
})}
|
||||
href={element.url}
|
||||
>
|
||||
{/* <InlineChromiumBugfix /> */}
|
||||
<span onClick={handle_click_link_text}>{children}</span>
|
||||
{/* <InlineChromiumBugfix /> */}
|
||||
</a>
|
||||
</LinkStyledTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkTooltipsProps = {
|
||||
/** Uniquely identifies */
|
||||
id: string;
|
||||
/** The url to which the hyperlink points */
|
||||
url: string;
|
||||
/** slate instance */
|
||||
editor: Editor;
|
||||
/** Used to display linkModal */
|
||||
onLinkModalVisibleChange: (visible: boolean, url?: string) => void;
|
||||
/** used to hide inlinemenu */
|
||||
hideInlineMenu: () => void;
|
||||
/** visibleChange of the entire tooltips */
|
||||
onVisibleChange: (visible: boolean) => void;
|
||||
};
|
||||
|
||||
const LinkTooltips = (props: LinkTooltipsProps) => {
|
||||
const {
|
||||
id,
|
||||
url,
|
||||
editor,
|
||||
onLinkModalVisibleChange,
|
||||
hideInlineMenu,
|
||||
onVisibleChange,
|
||||
} = props;
|
||||
|
||||
const select_link = useCallback(() => {
|
||||
const { children } = editor;
|
||||
// @ts-ignore
|
||||
const realChildren = children[0]?.children;
|
||||
const path = [0];
|
||||
let offset = 0;
|
||||
if (realChildren && Array.isArray(realChildren)) {
|
||||
for (let i = 0; i < realChildren.length; i++) {
|
||||
const child = realChildren[i];
|
||||
if (child.type === 'link' && child.id === id) {
|
||||
path.push(i);
|
||||
const linkChildren = child.children;
|
||||
path.push(linkChildren.length - 1);
|
||||
offset = linkChildren[linkChildren.length - 1].text.length;
|
||||
}
|
||||
}
|
||||
if (path.length === 3 && offset) {
|
||||
const anchor = Editor.before(
|
||||
editor,
|
||||
{
|
||||
path,
|
||||
offset: 0,
|
||||
},
|
||||
{
|
||||
unit: 'offset',
|
||||
}
|
||||
);
|
||||
const focus = Editor.after(
|
||||
editor,
|
||||
{
|
||||
path,
|
||||
offset,
|
||||
},
|
||||
{
|
||||
unit: 'offset',
|
||||
}
|
||||
);
|
||||
Transforms.select(editor, { anchor, focus });
|
||||
ReactEditor.focus(editor as ReactEditor);
|
||||
onVisibleChange(false);
|
||||
requestAnimationFrame(() => {
|
||||
hideInlineMenu?.();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [editor, hideInlineMenu, id, onVisibleChange]);
|
||||
|
||||
const handle_edit_link_url = useCallback(() => {
|
||||
const selectSuccess = select_link();
|
||||
|
||||
if (selectSuccess) {
|
||||
onVisibleChange(false);
|
||||
requestAnimationFrame(() => {
|
||||
onLinkModalVisibleChange(true, url);
|
||||
});
|
||||
}
|
||||
}, [onLinkModalVisibleChange, onVisibleChange, select_link, url]);
|
||||
|
||||
const handle_unlink = useCallback(() => {
|
||||
const selectSuccess = select_link();
|
||||
if (selectSuccess) {
|
||||
requestAnimationFrame(() => {
|
||||
unwrapLink(editor);
|
||||
ReactEditor.deselect(editor);
|
||||
});
|
||||
}
|
||||
}, [editor, select_link]);
|
||||
|
||||
return (
|
||||
<div className={styles('linkTooltipContainer')}>
|
||||
<span className={styles('linkModalIcon')}>
|
||||
<OpenInNewIcon style={{ fontSize: 15 }} />
|
||||
</span>
|
||||
<span className={styles('linkModalUrl')}>{url}</span>
|
||||
<div className={styles('linkModalStick')} />
|
||||
<div
|
||||
onClick={handle_edit_link_url}
|
||||
className={styles('linkModalBtn')}
|
||||
>
|
||||
<EditIcon style={{ fontSize: 16 }} />
|
||||
</div>
|
||||
<div onClick={handle_unlink} className={styles('linkModalBtn')}>
|
||||
<LinkOffIcon style={{ fontSize: 16 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InlineChromiumBugfix = () => (
|
||||
<span contentEditable={false} style={{ fontSize: 0 }}>
|
||||
${String.fromCodePoint(160)}
|
||||
</span>
|
||||
);
|
||||
|
||||
function useBody() {
|
||||
const [div] = useState(document.createElement('div'));
|
||||
|
||||
useEffect(() => {
|
||||
document.body.appendChild(div);
|
||||
return () => {
|
||||
div.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
const GAP_BETWEEN_CONTENT_AND_MODAL = 4;
|
||||
|
||||
type LinkModalProps = {
|
||||
visible: boolean;
|
||||
url?: string;
|
||||
/** Hide display callback */
|
||||
onVisibleChange: (visible: boolean, isInsertLink?: boolean) => void;
|
||||
/** Insert link to slate */
|
||||
insertLink: (url: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* modal for adding and editing link url, input element as content.
|
||||
*/
|
||||
export const LinkModal = memo((props: LinkModalProps) => {
|
||||
const body = useBody();
|
||||
const { visible, onVisibleChange, url = '', insertLink } = props;
|
||||
|
||||
const inputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
const rect = useMemo(() => {
|
||||
return window.getSelection().getRangeAt(0).getClientRects()[0];
|
||||
}, []);
|
||||
|
||||
const rects = useMemo(() => {
|
||||
return window.getSelection().getRangeAt(0).getClientRects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
requestAnimationFrame(() => {
|
||||
if (url) {
|
||||
inputEl.current.value = url;
|
||||
}
|
||||
inputEl.current?.focus();
|
||||
});
|
||||
}
|
||||
}, [visible, url]);
|
||||
|
||||
const add_link_url_to_text = () => {
|
||||
const newUrl = inputEl.current.value;
|
||||
if (newUrl && newUrl !== url && isUrl(normalizeUrl(newUrl))) {
|
||||
insertLink(newUrl);
|
||||
onVisibleChange(false, true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handle_key_down = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
// console.log(';; link input keydown ', e.key, e);
|
||||
if (e.key === 'Enter') {
|
||||
add_link_url_to_text();
|
||||
}
|
||||
// TODO: FIX unable to catch ESCAPE key down
|
||||
if (e.key === 'Escape') {
|
||||
onVisibleChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handle_mouse_down = () => {
|
||||
onVisibleChange(false);
|
||||
};
|
||||
|
||||
const { top, left, height } = rect;
|
||||
|
||||
return createPortal(
|
||||
visible && (
|
||||
<>
|
||||
<LinkBehavior onMousedown={handle_mouse_down} rects={rects} />
|
||||
<div
|
||||
className={styles('linkModalContainer')}
|
||||
style={{
|
||||
top: top + height + GAP_BETWEEN_CONTENT_AND_MODAL,
|
||||
left,
|
||||
}}
|
||||
>
|
||||
<div className={styles('linkModalContainerIcon')}>
|
||||
<AttachmentIcon
|
||||
style={{ color: colors.Gray04, fontSize: 16 }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className={styles('linkModalContainerInput')}
|
||||
onKeyDown={handle_key_down}
|
||||
placeholder="Paste link url, like https://affine.pro"
|
||||
autoComplete="off"
|
||||
ref={inputEl}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
body
|
||||
);
|
||||
});
|
||||
|
||||
const LinkBehavior = (props: {
|
||||
onMousedown: (e: MouseEvent) => void;
|
||||
rects: DOMRectList;
|
||||
}) => {
|
||||
const { onMousedown, rects } = props;
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const prevent = useCallback((e: any) => {
|
||||
// console.log(e);
|
||||
// e.preventDefault();
|
||||
// e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousemove', prevent, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', prevent, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const renderFakeSelection = useCallback(() => {
|
||||
const rectsArr = Array.from(rects);
|
||||
if (rectsArr.length) {
|
||||
return rectsArr.map((rect, i) => {
|
||||
const { top, left, width, height } = rect;
|
||||
return (
|
||||
<div
|
||||
key={`fake-selection-${i}`}
|
||||
className={styles('fakeSelection')}
|
||||
style={{ top, left, width, height }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, [rects]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles('linkMask')}
|
||||
onMouseDown={onMousedown}
|
||||
/>
|
||||
{renderFakeSelection()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = style9.create({
|
||||
linkModalContainer: {
|
||||
position: 'fixed',
|
||||
width: '354px',
|
||||
height: '40px',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0px 1px 10px rgba(152, 172, 189, 0.6)',
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
zIndex: '1',
|
||||
},
|
||||
linkModalContainerIcon: {
|
||||
width: '16px',
|
||||
margin: '0 16px 0 4px',
|
||||
},
|
||||
linkModalContainerInput: {
|
||||
flex: '1',
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
padding: '0',
|
||||
fontFamily: 'Helvetica,Arial,"Microsoft Yahei",SimHei,sans-serif',
|
||||
'::-webkit-input-placeholder': {
|
||||
color: '#98acbd',
|
||||
},
|
||||
},
|
||||
linkMask: {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: '1',
|
||||
},
|
||||
fakeSelection: {
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
// backgroundColor: 'rgba(80, 46, 196, 0.1)',
|
||||
zIndex: '1',
|
||||
},
|
||||
linkWrapper: {
|
||||
cursor: 'pointer',
|
||||
textDecorationLine: 'none',
|
||||
},
|
||||
linkWrapperHover: {},
|
||||
linkTooltipContainer: {
|
||||
// color: 'var(--ligo-Gray04)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
linkModalIcon: {},
|
||||
linkModalStick: {
|
||||
width: '1px',
|
||||
height: '20px',
|
||||
margin: '0 10px 0 16px',
|
||||
},
|
||||
linkModalUrl: {
|
||||
marginLeft: '8px',
|
||||
maxWidth: '261px',
|
||||
textOverflow: 'ellipsis',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
linkModalBtn: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
transitionProperty: 'background-color',
|
||||
transitionDuration: '0.3s',
|
||||
borderRadius: '4px',
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
});
|
||||
41
libs/components/common/src/lib/text/plugins/reflink.tsx
Normal file
41
libs/components/common/src/lib/text/plugins/reflink.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Descendant } from 'slate';
|
||||
|
||||
// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries
|
||||
import { BlockSearchItem } from '@toeverything/datasource/jwt';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
import { BlockPreview } from '../../block-preview';
|
||||
|
||||
export type RefLinkElement = {
|
||||
type: 'reflink';
|
||||
reference: string;
|
||||
children: Descendant[];
|
||||
};
|
||||
|
||||
const BlockPreviewContainer = styled(BlockPreview)({
|
||||
width: '100%',
|
||||
margin: '0px!important',
|
||||
paddingLeft: '0px!important',
|
||||
paddingRight: '0px!important',
|
||||
});
|
||||
|
||||
type InlineRefLinkProps = {
|
||||
block?: BlockSearchItem;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const InlineRefLink = ({ block, pageId }: InlineRefLinkProps) => {
|
||||
const { workspace_id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (block) {
|
||||
return (
|
||||
<BlockPreviewContainer
|
||||
block={block}
|
||||
onClick={() => navigate(`/${workspace_id}/${pageId}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>Loading...</span>;
|
||||
};
|
||||
1089
libs/components/common/src/lib/text/slate-utils.ts
Normal file
1089
libs/components/common/src/lib/text/slate-utils.ts
Normal file
File diff suppressed because it is too large
Load Diff
76
libs/components/common/src/lib/text/utils.ts
Normal file
76
libs/components/common/src/lib/text/utils.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { CustomElement } from '..';
|
||||
import { interceptMarks, MARKDOWN_REGS } from './constants';
|
||||
|
||||
export const isInterceptCharacter = (str: string) => {
|
||||
return interceptMarks.indexOf(str) > -1;
|
||||
};
|
||||
|
||||
export enum MARKDOWN_STYLE_MAP {
|
||||
BOLD = 'bold',
|
||||
ITALIC = 'italic',
|
||||
STRIKETHROUGH = 'strikethrough',
|
||||
UNDER_LINE = 'underline',
|
||||
}
|
||||
|
||||
export type MatchRes = {
|
||||
start: string;
|
||||
end: string;
|
||||
style: string;
|
||||
startLength: number;
|
||||
};
|
||||
|
||||
export const matchMarkdown = (str: string) => {
|
||||
const regs = MARKDOWN_REGS;
|
||||
for (let i = 0; i < regs.length; i++) {
|
||||
const matchResult = str.match(regs[i].reg);
|
||||
if (matchResult && matchResult[0]) {
|
||||
return {
|
||||
start: regs[i].start,
|
||||
end: regs[i].end,
|
||||
style: regs[i].type,
|
||||
startLength: regs[i].start.length,
|
||||
} as MatchRes;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getRandomString = function (prefix: string) {
|
||||
const x = 2147483648;
|
||||
return `${prefix}.${Math.floor(Math.random() * x).toString(36)}${Math.abs(
|
||||
Math.floor(Math.random() * x) ^ +new Date()
|
||||
).toString(36)}`;
|
||||
};
|
||||
|
||||
const COMMENT_PREFIX_FOR_MARK = 'comment_id_';
|
||||
|
||||
export const getEditorMarkForCommentId = (commentId: string) => {
|
||||
return `${COMMENT_PREFIX_FOR_MARK}${commentId}`;
|
||||
};
|
||||
|
||||
export const getCommentsIdsOnTextNode = (textNode: Record<string, unknown>) => {
|
||||
const ids = Object.keys(textNode)
|
||||
.filter(
|
||||
maybeCommentMarkProp =>
|
||||
maybeCommentMarkProp.startsWith(COMMENT_PREFIX_FOR_MARK) &&
|
||||
textNode[maybeCommentMarkProp] !== 'resolved'
|
||||
)
|
||||
.map(commentMark => commentMark.replace(COMMENT_PREFIX_FOR_MARK, ''));
|
||||
return new Set(ids);
|
||||
};
|
||||
|
||||
const _usefulEditorLevelProps = ['textAlign'];
|
||||
/** get extra props from editor top level node; it's usually user custom props */
|
||||
export const getExtraPropertiesFromEditorOutmostNode = (
|
||||
editorNode: CustomElement
|
||||
) => {
|
||||
const textStyle = {} as Record<string, string>;
|
||||
_usefulEditorLevelProps.forEach(p => {
|
||||
if (p in editorNode) {
|
||||
// @ts-ignore
|
||||
textStyle[p] = editorNode[p] as string;
|
||||
}
|
||||
});
|
||||
|
||||
return textStyle;
|
||||
};
|
||||
Reference in New Issue
Block a user