Files
AFFiNE-Mirror/libs/components/common/src/lib/text/plugins/link.tsx
T
2022-08-12 16:41:37 +08:00

574 lines
16 KiB
TypeScript

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 {
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';
import { LinkIcon } from '@toeverything/components/icons';
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 }} />
))(({ theme }) => ({
[`& .${muiTooltipClasses.tooltip}`]: {
backgroundColor: '#fff',
color: '#3E6FDB',
boxShadow: theme.affine.shadows.shadow1,
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} />
<LinkModalContainer
style={{
top: top + height + GAP_BETWEEN_CONTENT_AND_MODAL,
left,
}}
>
<div className={styles('linkModalContainerIcon')}>
<LinkIcon
style={{ fontSize: '16px', marginTop: '2px' }}
/>
</div>
<input
className={styles('linkModalContainerInput')}
onKeyDown={handle_key_down}
placeholder="Paste link url, like https://affine.pro"
autoComplete="off"
ref={inputEl}
/>
</LinkModalContainer>
</>
),
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 LinkModalContainer = styled('div')(({ theme }) => ({
position: 'fixed',
width: '354px',
height: '40px',
padding: '12px',
display: 'flex',
borderRadius: '4px',
boxShadow: theme.affine.shadows.shadow1,
backgroundColor: '#fff',
alignItems: 'center',
zIndex: '1',
}));
const styles = style9.create({
linkModalContainerIcon: {
display: 'flex',
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',
},
color: '#4C6275',
},
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',
},
},
});