init: the first public commit for AFFiNE

This commit is contained in:
DarkSky
2022-07-22 15:49:21 +08:00
commit e3e3741393
1451 changed files with 108124 additions and 0 deletions

View 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;
};

View 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,
];

View File

@@ -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)' : '',
};
});

View 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';

View 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>
);
};

View 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;
};

View 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',
},
},
});

View 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>;
};

File diff suppressed because it is too large Load Diff

View 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;
};