Support text block add link (#2730)

* feat: support text block href attribute

* fix: double click didn't select range

* fix: link update

* chore: ts lint

* chore: add new line

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>

* chore: update get word indices function

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
Kilu.He 2023-06-13 15:59:18 +08:00 committed by GitHub
parent aca3c90eb3
commit 00c0934df6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 984 additions and 213 deletions

View File

@ -30,6 +30,7 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
const reset = useCallback(() => { const reset = useCallback(() => {
dispatch(rangeActions.clearRange()); dispatch(rangeActions.clearRange());
setForward(true);
}, [dispatch]); }, [dispatch]);
// display caret color // display caret color
@ -85,7 +86,6 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
const handleDragStart = useCallback( const handleDragStart = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
// reset the range
reset(); reset();
// skip if the target is not a block // skip if the target is not a block
const blockId = getBlockIdByPoint(e.target as HTMLElement); const blockId = getBlockIdByPoint(e.target as HTMLElement);
@ -150,6 +150,8 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
if (!isDragging) return; if (!isDragging) return;
setFocus(null);
anchorRef.current = null;
dispatch(rangeActions.setDragging(false)); dispatch(rangeActions.setDragging(false));
}, [dispatch, isDragging]); }, [dispatch, isDragging]);
@ -164,7 +166,7 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
document.removeEventListener('mouseup', handleDragEnd); document.removeEventListener('mouseup', handleDragEnd);
container.removeEventListener('keydown', onKeyDown, true); container.removeEventListener('keydown', onKeyDown, true);
}; };
}, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]); }, [reset, handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
return null; return null;
} }

View File

@ -121,7 +121,9 @@ export function useRangeKeyDown() {
return; return;
} }
const { anchor, focus } = rangeRef.current; const { anchor, focus } = rangeRef.current;
if (anchor?.id === focus?.id) { if (!anchor || !focus) return;
if (anchor.id === focus.id) {
return; return;
} }
e.stopPropagation(); e.stopPropagation();
@ -131,7 +133,9 @@ export function useRangeKeyDown() {
return; return;
} }
const lastEvent = filteredEvents[lastIndex]; const lastEvent = filteredEvents[lastIndex];
lastEvent?.handler(e); if (!lastEvent) return;
e.preventDefault();
lastEvent.handler(e);
}, },
[interceptEvents, rangeRef] [interceptEvents, rangeRef]
); );

View File

@ -5,6 +5,7 @@ import { useChange } from '$app/components/document/_shared/EditorHooks/useChang
import { useKeyDown } from './useKeyDown'; import { useKeyDown } from './useKeyDown';
import CodeEditor from '$app/components/document/_shared/SlateEditor/CodeEditor'; import CodeEditor from '$app/components/document/_shared/SlateEditor/CodeEditor';
import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection'; import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection';
import { useSubscribeDecorate } from '$app/components/document/_shared/SubscribeSelection.hooks';
export default function CodeBlock({ export default function CodeBlock({
node, node,
@ -16,7 +17,8 @@ export default function CodeBlock({
const onKeyDown = useKeyDown(id); const onKeyDown = useKeyDown(id);
const className = props.className ? ` ${props.className}` : ''; const className = props.className ? ` ${props.className}` : '';
const { value, onChange } = useChange(node); const { value, onChange } = useChange(node);
const { onSelectionChange, selection, lastSelection } = useSelection(id); const selectionProps = useSelection(id);
return ( return (
<div {...props} className={`rounded bg-shade-6 p-6 ${className}`}> <div {...props} className={`rounded bg-shade-6 p-6 ${className}`}>
<div className={'mb-2 w-[100%]'}> <div className={'mb-2 w-[100%]'}>
@ -28,9 +30,7 @@ export default function CodeBlock({
placeholder={placeholder} placeholder={placeholder}
language={language} language={language}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onSelectionChange={onSelectionChange} {...selectionProps}
selection={selection}
lastSelection={lastSelection}
/> />
</div> </div>
); );

View File

@ -16,6 +16,7 @@ import DividerBlock from '$app/components/document/DividerBlock';
import CalloutBlock from '$app/components/document/CalloutBlock'; import CalloutBlock from '$app/components/document/CalloutBlock';
import BlockOverlay from '$app/components/document/Overlay/BlockOverlay'; import BlockOverlay from '$app/components/document/Overlay/BlockOverlay';
import CodeBlock from '$app/components/document/CodeBlock'; import CodeBlock from '$app/components/document/CodeBlock';
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) { function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
const { node, childIds, isSelected, ref } = useNode(id); const { node, childIds, isSelected, ref } = useNode(id);
@ -60,13 +61,15 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
if (!node) return null; if (!node) return null;
return ( return (
<div {...props} ref={ref} data-block-id={node.id} className={`relative ${className}`}> <NodeIdContext.Provider value={id}>
{renderBlock()} <div {...props} ref={ref} data-block-id={node.id} className={`relative ${className}`}>
<BlockOverlay id={id} /> {renderBlock()}
{isSelected ? ( <BlockOverlay id={id} />
<div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' /> {isSelected ? (
) : null} <div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
</div> ) : null}
</div>
</NodeIdContext.Provider>
); );
} }

View File

@ -5,6 +5,7 @@ import TextActionMenu from '$app/components/document/TextActionMenu';
import BlockSlash from '$app/components/document/BlockSlash'; import BlockSlash from '$app/components/document/BlockSlash';
import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy'; import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy';
import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste'; import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
export default function Overlay({ container }: { container: HTMLDivElement }) { export default function Overlay({ container }: { container: HTMLDivElement }) {
useCopy(container); useCopy(container);
@ -15,6 +16,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
<TextActionMenu container={container} /> <TextActionMenu container={container} />
<BlockSelection container={container} /> <BlockSelection container={container} />
<BlockSlash /> <BlockSlash />
<LinkEditPopover />
</> </>
); );
} }

View File

@ -6,7 +6,7 @@ import { debounce } from '$app/utils/tool';
export function useMenuStyle(container: HTMLDivElement) { export function useMenuStyle(container: HTMLDivElement) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const id = useAppSelector((state) => state.documentRange.focus?.id); const id = useAppSelector((state) => state.documentRange.caret?.id);
const [isScrolling, setIsScrolling] = useState(false); const [isScrolling, setIsScrolling] = useState(false);
const reCalculatePosition = useCallback(() => { const reCalculatePosition = useCallback(() => {

View File

@ -2,6 +2,7 @@ import { useMenuStyle } from './index.hooks';
import { useAppSelector } from '$app/stores/store'; import { useAppSelector } from '$app/stores/store';
import TextActionMenuList from '$app/components/document/TextActionMenu/menu'; import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
import BlockPortal from '$app/components/document/BlockPortal'; import BlockPortal from '$app/components/document/BlockPortal';
import { useMemo } from 'react';
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => { const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
const { ref, id } = useMenuStyle(container); const { ref, id } = useMenuStyle(container);
@ -14,7 +15,7 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
style={{ style={{
opacity: 0, opacity: 0,
}} }}
className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-[#333] leading-tight shadow-lg transition-opacity duration-100' className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-black leading-tight text-white shadow-lg transition-opacity duration-100'
onMouseDown={(e) => { onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor // prevent toolbar from taking focus away from editor
e.preventDefault(); e.preventDefault();
@ -27,16 +28,24 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
); );
}; };
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => { const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
const canShow = useAppSelector((state) => { const range = useAppSelector((state) => state.documentRange);
const { isDragging, focus, anchor, ranges } = state.documentRange; const canShow = useMemo(() => {
const { isDragging, focus, anchor, ranges, caret } = range;
// don't show if dragging
if (isDragging) return false; if (isDragging) return false;
if (!focus || !anchor) return false; // don't show if no focus or anchor
const isSameLine = anchor.id === focus.id; if (!caret) return false;
const anchorRange = ranges[anchor.id]; const isSameLine = anchor?.id === focus?.id;
if (!anchorRange) return false;
const isCollapsed = isSameLine && anchorRange.length === 0; // show toolbar if range has multiple nodes
return !isCollapsed; if (!isSameLine) return true;
}); const caretRange = ranges[caret.id];
// don't show if no caret range
if (!caretRange) return false;
// show toolbar if range is not collapsed
return caretRange.length > 0;
}, [range]);
if (!canShow) return null; if (!canShow) return null;
return <TextActionComponent container={container} />; return <TextActionComponent container={container} />;

View File

@ -7,6 +7,7 @@ import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { newLinkThunk } from '$app_reducers/document/async-actions/link';
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => { const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -25,6 +26,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
[TextAction.Underline]: 'Underline', [TextAction.Underline]: 'Underline',
[TextAction.Strikethrough]: 'Strike through', [TextAction.Strikethrough]: 'Strike through',
[TextAction.Code]: 'Mark as Code', [TextAction.Code]: 'Mark as Code',
[TextAction.Link]: 'Add Link',
}), }),
[] []
); );
@ -49,6 +51,26 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
[controller, dispatch, isActive] [controller, dispatch, isActive]
); );
const addLink = useCallback(() => {
dispatch(newLinkThunk());
}, [dispatch]);
const formatClick = useCallback(
(format: TextAction) => {
switch (format) {
case TextAction.Bold:
case TextAction.Italic:
case TextAction.Underline:
case TextAction.Strikethrough:
case TextAction.Code:
return toggleFormat(format);
case TextAction.Link:
return addLink();
}
},
[addLink, toggleFormat]
);
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
const isActive = await isFormatActive(); const isActive = await isFormatActive();
@ -58,7 +80,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
return ( return (
<MenuTooltip title={formatTooltips[format]}> <MenuTooltip title={formatTooltips[format]}>
<IconButton size='small' sx={{ color }} onClick={() => toggleFormat(format)}> <IconButton size='small' sx={{ color }} onClick={() => formatClick(format)}>
<FormatIcon icon={icon} /> <FormatIcon icon={icon} />
</IconButton> </IconButton>
</MenuTooltip> </MenuTooltip>

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material'; import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
import { TextAction } from '$app/interfaces/document'; import { TextAction } from '$app/interfaces/document';
import LinkIcon from '@mui/icons-material/AddLink';
export const iconSize = { width: 18, height: 18 }; export const iconSize = { width: 18, height: 18 };
export default function FormatIcon({ icon }: { icon: string }) { export default function FormatIcon({ icon }: { icon: string }) {
@ -15,6 +16,18 @@ export default function FormatIcon({ icon }: { icon: string }) {
return <CodeOutlined sx={iconSize} />; return <CodeOutlined sx={iconSize} />;
case TextAction.Strikethrough: case TextAction.Strikethrough:
return <StrikethroughSOutlined sx={iconSize} />; return <StrikethroughSOutlined sx={iconSize} />;
case TextAction.Link:
return (
<div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
<LinkIcon
sx={{
fontSize: '1.2rem',
marginRight: '0.25rem',
}}
/>
<div className={'underline'}>Link</div>
</div>
);
default: default:
return null; return null;
} }

View File

@ -15,13 +15,14 @@ export function useTextActionMenu() {
const isSingleLine = useMemo(() => { const isSingleLine = useMemo(() => {
return range.focus?.id === range.anchor?.id; return range.focus?.id === range.anchor?.id;
}, [range]); }, [range]);
const focusId = range.focus?.id; const focusId = range.caret?.id;
const { node } = useSubscribeNode(focusId || ''); const { node } = useSubscribeNode(focusId || '');
const items = useMemo(() => { const items = useMemo(() => {
if (!node) return [];
if (isSingleLine) { if (isSingleLine) {
const config = blockConfig[node?.type]; const config = blockConfig[node.type];
const { customItems, excludeItems } = { const { customItems, excludeItems } = {
...defaultTextActionProps, ...defaultTextActionProps,
...config.textActionMenuProps, ...config.textActionMenuProps,
@ -30,7 +31,7 @@ export function useTextActionMenu() {
} else { } else {
return multiLineTextActionProps.customItems || []; return multiLineTextActionProps.customItems || [];
} }
}, [isSingleLine, node?.type]); }, [isSingleLine, node]);
// the groups have default items, so we need to filter the items if this node has excluded items // the groups have default items, so we need to filter the items if this node has excluded items
const groupItems: TextAction[][] = useMemo(() => { const groupItems: TextAction[][] = useMemo(() => {

View File

@ -11,6 +11,7 @@ function TextActionMenuList() {
switch (action) { switch (action) {
case TextAction.Turn: case TextAction.Turn:
return isSingleLine && focusId ? <TurnIntoSelect id={focusId} /> : null; return isSingleLine && focusId ? <TurnIntoSelect id={focusId} /> : null;
case TextAction.Link:
case TextAction.Bold: case TextAction.Bold:
case TextAction.Italic: case TextAction.Italic:
case TextAction.Underline: case TextAction.Underline:

View File

@ -13,20 +13,12 @@ interface Props {
} }
function TextBlock({ node, childIds, placeholder }: Props) { function TextBlock({ node, childIds, placeholder }: Props) {
const { value, onChange } = useChange(node); const { value, onChange } = useChange(node);
const { onSelectionChange, selection, lastSelection } = useSelection(node.id); const selectionProps = useSelection(node.id);
const { onKeyDown } = useKeyDown(node.id); const { onKeyDown } = useKeyDown(node.id);
return ( return (
<> <>
<Editor <Editor value={value} onChange={onChange} {...selectionProps} onKeyDown={onKeyDown} placeholder={placeholder} />
value={value}
onChange={onChange}
onSelectionChange={onSelectionChange}
selection={selection}
lastSelection={lastSelection}
onKeyDown={onKeyDown}
placeholder={placeholder}
/>
<NodeChildren className='pl-[1.5em]' childIds={childIds} /> <NodeChildren className='pl-[1.5em]' childIds={childIds} />
</> </>
); );

View File

@ -89,7 +89,6 @@ export function useKeyDown(id: string) {
const onKeyDown = useCallback( const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation(); e.stopPropagation();
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e)); const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
filteredEvents.forEach((event) => event.handler(e)); filteredEvents.forEach((event) => event.handler(e));
}, },

View File

@ -74,8 +74,10 @@ export function useTurnIntoBlockEvents(id: string) {
[BlockType.HeadingBlock]: () => { [BlockType.HeadingBlock]: () => {
const flag = getFlag(); const flag = getFlag();
if (!flag) return; if (!flag) return;
const level = flag.match(/#/g)?.length;
if (!level || level > 3) return;
return { return {
level: flag.match(/#/g)?.length, level,
...getTurnIntoBlockDelta(), ...getTurnIntoBlockDelta(),
}; };
}, },

View File

@ -2,12 +2,17 @@ import { useCallback, useEffect, useState } from 'react';
import { RangeStatic } from 'quill'; import { RangeStatic } from 'quill';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { rangeActions } from '$app_reducers/document/slice'; import { rangeActions } from '$app_reducers/document/slice';
import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks'; import {
useFocused,
useRangeRef,
useSubscribeDecorate,
} from '$app/components/document/_shared/SubscribeSelection.hooks';
import { storeRangeThunk } from '$app_reducers/document/async-actions/range'; import { storeRangeThunk } from '$app_reducers/document/async-actions/range';
export function useSelection(id: string) { export function useSelection(id: string) {
const rangeRef = useRangeRef(); const rangeRef = useRangeRef();
const { focusCaret, lastSelection } = useFocused(id); const { focusCaret } = useFocused(id);
const decorateProps = useSubscribeDecorate(id);
const [selection, setSelection] = useState<RangeStatic | undefined>(undefined); const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -21,7 +26,6 @@ export function useSelection(id: string) {
const onSelectionChange = useCallback( const onSelectionChange = useCallback(
(range: RangeStatic | null, _oldRange: RangeStatic | null, _source?: string) => { (range: RangeStatic | null, _oldRange: RangeStatic | null, _source?: string) => {
if (!range) return; if (!range) return;
dispatch( dispatch(
rangeActions.setCaret({ rangeActions.setCaret({
id, id,
@ -36,20 +40,19 @@ export function useSelection(id: string) {
useEffect(() => { useEffect(() => {
if (rangeRef.current && rangeRef.current?.isDragging) return; if (rangeRef.current && rangeRef.current?.isDragging) return;
const caret = focusCaret; if (!focusCaret) {
if (!caret) { setSelection(undefined);
return; return;
} }
setSelection({ setSelection({
index: caret.index, index: focusCaret.index,
length: caret.length, length: focusCaret.length,
}); });
}, [rangeRef, focusCaret]); }, [rangeRef, focusCaret]);
return { return {
onSelectionChange, onSelectionChange,
selection, selection,
lastSelection, ...decorateProps,
}; };
} }

View File

@ -0,0 +1,47 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Portal, Snackbar } from '@mui/material';
import { TransitionProps } from '@mui/material/transitions';
import Slide, { SlideProps } from '@mui/material/Slide';
function SlideTransition(props: SlideProps) {
return <Slide {...props} direction='up' />;
}
interface MessageProps {
message?: string;
key?: string;
duration?: number;
}
export function useMessage() {
const [state, setState] = useState<MessageProps>();
const show = useCallback((message: MessageProps) => {
setState(message);
}, []);
const hide = useCallback(() => {
setState(undefined);
}, []);
const contentHolder = useMemo(() => {
const open = !!state;
return (
<Portal>
<Snackbar
autoHideDuration={state?.duration}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
open={open}
onClose={hide}
TransitionProps={{ onExited: hide }}
message={state?.message}
key={state?.key}
TransitionComponent={SlideTransition}
/>
</Portal>
);
}, [hide, state]);
return {
show,
hide,
contentHolder,
};
}

View File

@ -3,10 +3,11 @@ import { CodeEditorProps } from '$app/interfaces/document';
import { Editable, Slate } from 'slate-react'; import { Editable, Slate } from 'slate-react';
import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor'; import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor';
import { decorateCode } from '$app/components/document/_shared/SlateEditor/decorateCode'; import { decorateCode } from '$app/components/document/_shared/SlateEditor/decorateCode';
import { CodeLeaf, CodeBlockElement } from '$app/components/document/_shared/SlateEditor/CodeElements'; import { CodeBlockElement } from '$app/components/document/_shared/SlateEditor/CodeElements';
import TextLeaf from '$app/components/document/_shared/SlateEditor/TextLeaf';
function CodeEditor({ language, ...props }: CodeEditorProps) { function CodeEditor({ language, ...props }: CodeEditorProps) {
const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor({ const { editor, onChange, value, ref, ...editableProps } = useEditor({
...props, ...props,
isCodeBlock: true, isCodeBlock: true,
}); });
@ -15,16 +16,14 @@ function CodeEditor({ language, ...props }: CodeEditorProps) {
<div ref={ref}> <div ref={ref}>
<Slate editor={editor} onChange={onChange} value={value}> <Slate editor={editor} onChange={onChange} value={value}>
<Editable <Editable
{...editableProps}
decorate={(entry) => { decorate={(entry) => {
const codeRange = decorateCode(entry, language); const codeRange = decorateCode(entry, language);
const range = decorate?.(entry) || []; const range = editableProps.decorate?.(entry) || [];
return [...range, ...codeRange]; return [...range, ...codeRange];
}} }}
renderLeaf={CodeLeaf} renderLeaf={(leafProps) => <TextLeaf editor={editor} {...leafProps} isCodeBlock={true} />}
renderElement={CodeBlockElement} renderElement={CodeBlockElement}
onKeyDown={onKeyDown}
onDOMBeforeInput={onDOMBeforeInput}
onBlur={onBlur}
/> />
</Slate> </Slate>
</div> </div>

View File

@ -1,46 +1,4 @@
import { RenderLeafProps, RenderElementProps } from 'slate-react'; import { RenderElementProps } from 'slate-react';
import { BaseText } from 'slate';
interface CodeLeafProps extends RenderLeafProps {
leaf: BaseText & {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
prism_token?: string;
selection_high_lighted?: boolean;
};
}
export const CodeLeaf = (props: CodeLeafProps) => {
const { attributes, children, leaf } = props;
let newChildren = children;
if (leaf.bold) {
newChildren = <strong>{children}</strong>;
}
if (leaf.italic) {
newChildren = <em>{newChildren}</em>;
}
if (leaf.underline) {
newChildren = <u>{newChildren}</u>;
}
const className = [
'token',
leaf.prism_token && leaf.prism_token,
leaf.strikethrough && 'line-through',
leaf.selection_high_lighted && 'bg-main-secondary',
].filter(Boolean);
return (
<span {...attributes} className={className.join(' ')}>
{newChildren}
</span>
);
};
export const CodeBlockElement = (props: RenderElementProps) => { export const CodeBlockElement = (props: RenderElementProps) => {
return ( return (

View File

@ -6,19 +6,16 @@ import TextLeaf from '$app/components/document/_shared/SlateEditor/TextLeaf';
import { TextElement } from '$app/components/document/_shared/SlateEditor/TextElement'; import { TextElement } from '$app/components/document/_shared/SlateEditor/TextElement';
function TextEditor({ placeholder = "Type '/' for commands", ...props }: EditorProps) { function TextEditor({ placeholder = "Type '/' for commands", ...props }: EditorProps) {
const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor(props); const { editor, onChange, value, ref, ...editableProps } = useEditor(props);
return ( return (
<div ref={ref} className={'py-0.5'}> <div ref={ref} className={'py-0.5'}>
<Slate editor={editor} onChange={onChange} value={value}> <Slate editor={editor} onChange={onChange} value={value}>
<Editable <Editable
onKeyDown={onKeyDown} renderLeaf={(leafProps) => <TextLeaf {...leafProps} editor={editor} />}
onDOMBeforeInput={onDOMBeforeInput}
decorate={decorate}
renderLeaf={TextLeaf}
placeholder={placeholder} placeholder={placeholder}
onBlur={onBlur}
renderElement={TextElement} renderElement={TextElement}
{...editableProps}
/> />
</Slate> </Slate>
</div> </div>

View File

@ -1,35 +1,33 @@
import { RenderLeafProps } from 'slate-react'; import { ReactEditor, RenderLeafProps } from 'slate-react';
import { BaseText } from 'slate'; import { BaseText } from 'slate';
import { useRef } from 'react'; import { useCallback, useRef } from 'react';
import TextLink from '../TextLink';
import { converToIndexLength } from '$app/utils/document/slate_editor';
import LinkHighLight from '$app/components/document/_shared/TextLink/LinkHighLight';
interface Attributes {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
code?: string;
selection_high_lighted?: boolean;
href?: string;
prism_token?: string;
link_selection_lighted?: boolean;
link_placeholder?: string;
}
interface TextLeafProps extends RenderLeafProps { interface TextLeafProps extends RenderLeafProps {
leaf: BaseText & { leaf: BaseText & Attributes;
bold?: boolean; isCodeBlock?: boolean;
italic?: boolean; editor: ReactEditor;
underline?: boolean;
strikethrough?: boolean;
code?: string;
selection_high_lighted?: boolean;
};
} }
const TextLeaf = (props: TextLeafProps) => { const TextLeaf = (props: TextLeafProps) => {
const { attributes, children, leaf } = props; const { attributes, children, leaf, isCodeBlock, editor } = props;
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
let newChildren = children; let newChildren = children;
if (leaf.bold) {
newChildren = <strong>{children}</strong>;
}
if (leaf.italic) {
newChildren = <em>{newChildren}</em>;
}
if (leaf.underline) {
newChildren = <u>{newChildren}</u>;
}
if (leaf.code) { if (leaf.code) {
newChildren = ( newChildren = (
@ -45,12 +43,46 @@ const TextLeaf = (props: TextLeafProps) => {
); );
} }
const getSelection = useCallback(
(node: Element) => {
const slateNode = ReactEditor.toSlateNode(editor, node);
const path = ReactEditor.findPath(editor, slateNode);
const selection = converToIndexLength(editor, {
anchor: { path, offset: 0 },
focus: { path, offset: leaf.text.length },
});
return selection;
},
[editor, leaf]
);
if (leaf.href) {
newChildren = (
<TextLink getSelection={getSelection} title={leaf.text} href={leaf.href}>
{newChildren}
</TextLink>
);
}
const className = [ const className = [
isCodeBlock && 'token',
leaf.prism_token && leaf.prism_token,
leaf.strikethrough && 'line-through', leaf.strikethrough && 'line-through',
leaf.selection_high_lighted && 'bg-main-secondary', leaf.selection_high_lighted && 'bg-main-secondary',
leaf.link_selection_lighted && 'text-link bg-main-secondary',
leaf.code && 'inline-code', leaf.code && 'inline-code',
leaf.bold && 'font-bold',
leaf.italic && 'italic',
leaf.underline && 'underline',
].filter(Boolean); ].filter(Boolean);
if (leaf.link_placeholder && leaf.text) {
newChildren = (
<LinkHighLight leaf={leaf} title={leaf.link_placeholder}>
{newChildren}
</LinkHighLight>
);
}
return ( return (
<span ref={ref} {...attributes} className={className.join(' ')}> <span ref={ref} {...attributes} className={className.join(' ')}>
{newChildren} {newChildren}

View File

@ -9,7 +9,7 @@ import {
indent, indent,
outdent, outdent,
} from '$app/utils/document/slate_editor'; } from '$app/utils/document/slate_editor';
import { focusNodeByIndex } from '$app/utils/document/node'; import { focusNodeByIndex, getWordIndices } from '$app/utils/document/node';
import { Keyboard } from '$app/constants/document/keyboard'; import { Keyboard } from '$app/constants/document/keyboard';
import Delta from 'quill-delta'; import Delta from 'quill-delta';
import isHotkey from 'is-hotkey'; import isHotkey from 'is-hotkey';
@ -20,13 +20,13 @@ export function useEditor({
onSelectionChange, onSelectionChange,
selection, selection,
value: delta, value: delta,
lastSelection, decorateSelection,
onKeyDown, onKeyDown,
isCodeBlock, isCodeBlock,
linkDecorateSelection,
}: EditorProps) { }: EditorProps) {
const editor = useSlateYjs({ delta }); const { editor } = useSlateYjs({ delta });
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const newValue = useMemo(() => [], []); const newValue = useMemo(() => [], []);
const onSelectionChangeHandler = useCallback( const onSelectionChangeHandler = useCallback(
(slateSelection: Selection) => { (slateSelection: Selection) => {
@ -42,7 +42,7 @@ export function useEditor({
onChange?.(convertToDelta(slateValue), oldContents); onChange?.(convertToDelta(slateValue), oldContents);
onSelectionChangeHandler(editor.selection); onSelectionChangeHandler(editor.selection);
}, },
[delta, editor.selection, onChange, onSelectionChangeHandler] [delta, editor, onChange, onSelectionChangeHandler]
); );
const onDOMBeforeInput = useCallback((e: InputEvent) => { const onDOMBeforeInput = useCallback((e: InputEvent) => {
@ -54,27 +54,50 @@ export function useEditor({
} }
}, []); }, []);
const getDecorateRange = useCallback(
(
path: number[],
selection:
| {
index: number;
length: number;
}
| undefined,
value: Record<string, boolean | string | undefined>
) => {
if (!selection) return null;
const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange;
if (range && !Range.isCollapsed(range)) {
const intersection = Range.intersection(range, Editor.range(editor, path));
if (intersection) {
return {
...intersection,
...value,
};
}
}
return null;
},
[editor]
);
const decorate = useCallback( const decorate = useCallback(
(entry: NodeEntry) => { (entry: NodeEntry) => {
const [node, path] = entry; const [node, path] = entry;
if (!lastSelection) return [];
const slateSelection = convertToSlateSelection(lastSelection.index, lastSelection.length, editor.children);
if (slateSelection && !Range.isCollapsed(slateSelection as BaseRange)) {
const intersection = Range.intersection(slateSelection, Editor.range(editor, path));
if (!intersection) { const ranges: Range[] = [
return []; getDecorateRange(path, decorateSelection, {
}
const range = {
selection_high_lighted: true, selection_high_lighted: true,
...intersection, }),
}; getDecorateRange(path, linkDecorateSelection?.selection, {
link_selection_lighted: true,
link_placeholder: linkDecorateSelection?.placeholder,
}),
].filter((range) => range !== null) as Range[];
return [range]; return ranges;
}
return [];
}, },
[editor, lastSelection] [decorateSelection, linkDecorateSelection, getDecorateRange]
); );
const onKeyDownRewrite = useCallback( const onKeyDownRewrite = useCallback(
@ -116,14 +139,53 @@ export function useEditor({
[editor] [editor]
); );
// This is a hack to fix the bug that the editor decoration is updated cause selection is lost
const onMouseDownCapture = useCallback(
(event: React.MouseEvent) => {
editor.deselect();
requestAnimationFrame(() => {
const range = document.caretRangeFromPoint(event.clientX, event.clientY);
if (!range) return;
const selection = window.getSelection();
if (!selection) return;
selection.removeAllRanges();
selection.addRange(range);
});
},
[editor]
);
// double click to select a word
// This is a hack to fix the bug that mouse down event deselect the selection
const onDoubleClick = useCallback((event: React.MouseEvent) => {
const selection = window.getSelection();
if (!selection) return;
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
if (!range) return;
const node = range.startContainer;
const offset = range.startOffset;
const wordIndices = getWordIndices(node, offset);
if (wordIndices.length === 0) return;
range.setStart(node, wordIndices[0].startIndex);
range.setEnd(node, wordIndices[0].endIndex);
selection.removeAllRanges();
selection.addRange(range);
}, []);
useEffect(() => { useEffect(() => {
if (!selection || !ref.current) return; if (!ref.current) return;
const isFocused = ReactEditor.isFocused(editor);
if (!selection) {
isFocused && editor.deselect();
return;
}
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children); const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
if (!slateSelection) return; if (!slateSelection) return;
const isFocused = ReactEditor.isFocused(editor);
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return; if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
focusNodeByIndex(ref.current, selection.index, selection.length); const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
Transforms.select(editor, slateSelection); if (!isSuccess) {
Transforms.select(editor, slateSelection);
}
}, [editor, selection]); }, [editor, selection]);
return { return {
@ -135,5 +197,7 @@ export function useEditor({
ref, ref,
onKeyDown: onKeyDownRewrite, onKeyDown: onKeyDownRewrite,
onBlur, onBlur,
onMouseDownCapture,
onDoubleClick,
}; };
} }

View File

@ -1,5 +1,5 @@
import Delta from 'quill-delta'; import Delta from 'quill-delta';
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useState } from 'react';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { convertToSlateValue } from '$app/utils/document/slate_editor'; import { convertToSlateValue } from '$app/utils/document/slate_editor';
import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core'; import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
@ -7,14 +7,14 @@ import { withReact } from 'slate-react';
import { createEditor } from 'slate'; import { createEditor } from 'slate';
export function useSlateYjs({ delta }: { delta?: Delta }) { export function useSlateYjs({ delta }: { delta?: Delta }) {
const yTextRef = useRef<Y.Text>(); const [yText, setYText] = useState<Y.Text | undefined>(undefined);
const sharedType = useMemo(() => { const sharedType = useMemo(() => {
const yDoc = new Y.Doc(); const yDoc = new Y.Doc();
const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText; const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
const value = convertToSlateValue(delta || new Delta()); const value = convertToSlateValue(delta || new Delta());
const insertDelta = slateNodesToInsertDelta(value); const insertDelta = slateNodesToInsertDelta(value);
sharedType.applyDelta(insertDelta); sharedType.applyDelta(insertDelta);
yTextRef.current = insertDelta[0].insert as Y.Text; setYText(insertDelta[0].insert as Y.Text);
return sharedType; return sharedType;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@ -25,19 +25,17 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
useEffect(() => { useEffect(() => {
YjsEditor.connect(editor); YjsEditor.connect(editor);
return () => { return () => {
yTextRef.current = undefined;
YjsEditor.disconnect(editor); YjsEditor.disconnect(editor);
}; };
}, [editor]); }, [editor]);
useEffect(() => { useEffect(() => {
const yText = yTextRef.current;
if (!yText) return; if (!yText) return;
const oldContents = new Delta(yText.toDelta()); const oldContents = new Delta(yText.toDelta());
const diffDelta = oldContents.diff(delta || new Delta()); const diffDelta = oldContents.diff(delta || new Delta());
if (diffDelta.ops.length === 0) return; if (diffDelta.ops.length === 0) return;
yText.applyDelta(diffDelta.ops); yText.applyDelta(diffDelta.ops);
}, [delta, editor]); }, [delta, editor, yText]);
return editor; return { editor };
} }

View File

@ -1,5 +1,5 @@
import { store, useAppSelector } from '@/appflowy_app/stores/store'; import { store, useAppSelector } from '@/appflowy_app/stores/store';
import { useEffect, useMemo, useRef } from 'react'; import { createContext, useEffect, useMemo, useRef } from 'react';
import { Node } from '$app/interfaces/document'; import { Node } from '$app/interfaces/document';
/** /**
@ -35,3 +35,5 @@ export function useSubscribeNode(id: string) {
export function getBlock(id: string) { export function getBlock(id: string) {
return store.getState().document.nodes[id]; return store.getState().document.nodes[id];
} }
export const NodeIdContext = createContext<string>('');

View File

@ -2,6 +2,25 @@ import { useAppSelector } from '$app/stores/store';
import { RangeState, RangeStatic } from '$app/interfaces/document'; import { RangeState, RangeStatic } from '$app/interfaces/document';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
export function useSubscribeDecorate(id: string) {
const decorateSelection = useAppSelector((state) => {
return state.documentRange.ranges[id];
});
const linkDecorateSelection = useAppSelector((state) => {
const linkPopoverState = state.documentLinkPopover;
if (!linkPopoverState.open || linkPopoverState.id !== id) return;
return {
selection: linkPopoverState.selection,
placeholder: linkPopoverState.title,
};
});
return {
decorateSelection,
linkDecorateSelection,
};
}
export function useFocused(id: string) { export function useFocused(id: string) {
const caretRef = useRef<RangeStatic>(); const caretRef = useRef<RangeStatic>();
const focusCaret = useAppSelector((state) => { const focusCaret = useAppSelector((state) => {
@ -13,23 +32,14 @@ export function useFocused(id: string) {
return null; return null;
}); });
const lastSelection = useAppSelector((state) => {
return state.documentRange.ranges[id];
});
const focused = useMemo(() => { const focused = useMemo(() => {
return focusCaret && focusCaret?.id === id; return focusCaret && focusCaret?.id === id;
}, [focusCaret, id]); }, [focusCaret, id]);
const memoizedLastSelection = useMemo(() => {
return lastSelection;
}, [JSON.stringify(lastSelection)]);
return { return {
focused, focused,
caretRef, caretRef,
focusCaret, focusCaret,
lastSelection: memoizedLastSelection,
}; };
} }

View File

@ -0,0 +1,38 @@
import React, { useEffect, useState } from 'react';
function EditLink({
autoFocus,
text,
value,
onChange,
}: {
autoFocus?: boolean;
text: string;
value: string;
onChange?: (newValue: string) => void;
}) {
const [val, setVal] = useState(value);
useEffect(() => {
onChange?.(val);
}, [val, onChange]);
return (
<div className={'mb-2 text-sm'}>
<div className={'mb-1 text-shade-2'}>{text}</div>
<div className={'flex rounded border bg-main-selector p-1 focus-within:border-main-hovered'}>
<input
autoFocus={autoFocus}
className={'flex-1 outline-none'}
onChange={(e) => {
const newValue = e.target.value;
setVal(newValue);
}}
value={val}
/>
</div>
</div>
);
}
export default EditLink;

View File

@ -0,0 +1,89 @@
import React, { useEffect, useRef } from 'react';
import BlockPortal from '$app/components/document/BlockPortal';
import { getNode } from '$app/utils/document/node';
import LanguageIcon from '@mui/icons-material/Language';
import CopyIcon from '@mui/icons-material/CopyAll';
import { copyText } from '$app/utils/document/copy_paste';
import { useMessage } from '$app/components/document/_shared/Message';
const iconSize = {
width: '1rem',
height: '1rem',
};
function EditLinkToolbar({
blockId,
linkElement,
onMouseEnter,
onMouseLeave,
href,
editing,
onEdit,
}: {
blockId: string;
linkElement: HTMLAnchorElement;
href: string;
onMouseEnter: () => void;
onMouseLeave: () => void;
editing: boolean;
onEdit: () => void;
}) {
const { show, contentHolder } = useMessage();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const toolbarDom = ref.current;
if (!toolbarDom) return;
const linkRect = linkElement.getBoundingClientRect();
const node = getNode(blockId);
if (!node) return;
const nodeRect = node.getBoundingClientRect();
const top = linkRect.top - nodeRect.top + linkRect.height + 4;
const left = linkRect.left - nodeRect.left;
toolbarDom.style.top = `${top}px`;
toolbarDom.style.left = `${left}px`;
toolbarDom.style.opacity = '1';
});
return (
<>
{editing && (
<BlockPortal blockId={blockId}>
<div
ref={ref}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{
opacity: 0,
}}
className='absolute z-10 inline-flex h-[32px] min-w-[200px] max-w-[400px] items-stretch overflow-hidden rounded-[8px] bg-white leading-tight text-black shadow-md transition-opacity duration-100'
>
<div className={'flex w-[100%] items-center justify-between px-2 text-[75%]'}>
<div className={'mr-2'}>
<LanguageIcon sx={iconSize} />
</div>
<div className={'mr-2 flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>{href}</div>
<div
onClick={async () => {
try {
await copyText(href);
show({ message: 'Copied!', duration: 6000 });
} catch {
show({ message: 'Copy failed!', duration: 6000 });
}
}}
className={'mr-2 cursor-pointer'}
>
<CopyIcon sx={iconSize} />
</div>
<div onClick={onEdit} className={'cursor-pointer'}>
Edit
</div>
</div>
</div>
</BlockPortal>
)}
{contentHolder}
</>
);
}
export default EditLinkToolbar;

View File

@ -0,0 +1,22 @@
import React from 'react';
import Button from '@mui/material/Button';
import { DeleteOutline } from '@mui/icons-material';
function LinkButton({ icon, title, onClick }: { icon: React.ReactNode; title: string; onClick: () => void }) {
return (
<div className={'pt-1'}>
<Button
className={'w-[100%]'}
style={{
justifyContent: 'flex-start',
}}
startIcon={icon}
onClick={onClick}
>
{title}
</Button>
</div>
);
}
export default LinkButton;

View File

@ -0,0 +1,131 @@
import React, { useCallback, useContext } from 'react';
import Popover from '@mui/material/Popover';
import { Divider } from '@mui/material';
import { DeleteOutline, Done } from '@mui/icons-material';
import EditLink from '$app/components/document/_shared/TextLink/EditLink';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { updateLinkThunk } from '$app_reducers/document/async-actions';
import { formatLinkThunk } from '$app_reducers/document/async-actions/link';
import LinkButton from '$app/components/document/_shared/TextLink/LinkButton';
function LinkEditPopover() {
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const popoverState = useAppSelector((state) => state.documentLinkPopover);
const { anchorPosition, id, selection, title = '', href = '', open = false } = popoverState;
const onClose = useCallback(() => {
dispatch(linkPopoverActions.closeLinkPopover());
}, [dispatch]);
const onExited = useCallback(() => {
if (!id || !selection) return;
const newSelection = {
index: selection.index,
length: title.length,
};
dispatch(
rangeActions.setRange({
id,
rangeStatic: newSelection,
})
);
dispatch(
rangeActions.setCaret({
id,
...newSelection,
})
);
}, [id, selection, title, dispatch]);
const onChange = useCallback(
(newVal: { href?: string; title: string }) => {
if (!id) return;
if (newVal.title === title && newVal.href === href) return;
dispatch(
updateLinkThunk({
id,
href: newVal.href,
title: newVal.title,
})
);
},
[dispatch, href, id, title]
);
const onDone = useCallback(async () => {
if (!controller) return;
await dispatch(
formatLinkThunk({
controller,
})
);
onClose();
}, [controller, dispatch, onClose]);
return (
<Popover
onMouseDown={(e) => e.stopPropagation()}
open={open}
disableAutoFocus={true}
anchorReference='anchorPosition'
anchorPosition={anchorPosition}
TransitionProps={{
onExited,
}}
onClose={onClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
PaperProps={{
sx: {
width: 500,
},
}}
>
<div className='flex flex-col p-3'>
<EditLink
text={'URL'}
value={href}
onChange={(link) => {
onChange({
href: link,
title,
});
}}
/>
<EditLink
text={'Link title'}
value={title}
onChange={(text) =>
onChange({
href,
title: text,
})
}
/>
<Divider />
<LinkButton
title={'Remove link'}
icon={<DeleteOutline />}
onClick={() => {
onChange({
title,
});
onDone();
}}
/>
<LinkButton title={'Done'} icon={<Done />} onClick={onDone} />
</div>
</Popover>
);
}
export default LinkEditPopover;

View File

@ -0,0 +1,33 @@
import React from 'react';
function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
return (
<>
{leaf.text === title || isOverlappingPrefix(leaf.text, title) ? (
<span contentEditable={false}>{title}</span>
) : null}
<span
style={{
display: 'none',
}}
>
{children}
</span>
</>
);
}
export default LinkHighLight;
function isOverlappingPrefix(first: string, second: string): boolean {
if (first.length === 0 || second.length === 0) return false;
let i = 0;
while (i < first.length) {
const chars = first.substring(i);
if (chars.length > second.length) return false;
if (second.startsWith(chars)) return true;
i++;
}
return false;
}

View File

@ -0,0 +1,27 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { debounce } from '$app/utils/tool';
export function useTextLink(id: string) {
const [editing, setEditing] = useState(false);
const ref = useRef<HTMLAnchorElement | null>(null);
const show = useMemo(() => debounce(() => setEditing(true), 500), []);
const hide = useMemo(() => debounce(() => setEditing(false), 500), []);
const onMouseEnter = useCallback(() => {
hide.cancel();
show();
}, [hide, show]);
const onMouseLeave = useCallback(() => {
show.cancel();
hide();
}, [hide, show]);
return {
editing,
onMouseEnter,
onMouseLeave,
ref,
};
}

View File

@ -0,0 +1,76 @@
import React, { useCallback, useContext } from 'react';
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useTextLink } from '$app/components/document/_shared/TextLink/TextLink.hooks';
import EditLinkToolbar from '$app/components/document/_shared/TextLink/EditLinkToolbar';
import { useAppDispatch } from '$app/stores/store';
import { linkPopoverActions } from '$app_reducers/document/slice';
function TextLink({
getSelection,
title,
href,
children,
}: {
getSelection: (node: Element) => {
index: number;
length: number;
} | null;
children: React.ReactNode;
href: string;
title: string;
}) {
const blockId = useContext(NodeIdContext);
const { editing, ref, onMouseEnter, onMouseLeave } = useTextLink(blockId);
const dispatch = useAppDispatch();
const onEdit = useCallback(() => {
if (!ref.current) return;
const selection = getSelection(ref.current);
if (!selection) return;
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
dispatch(
linkPopoverActions.setLinkPopover({
anchorPosition: {
top: rect.top + rect.height,
left: rect.left + rect.width / 2,
},
id: blockId,
selection,
title,
href,
open: true,
})
);
}, [blockId, dispatch, getSelection, href, ref, title]);
if (!blockId) return null;
return (
<>
<a
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
ref={ref}
href={href}
target='_blank'
rel='noopener noreferrer'
className='cursor-pointer text-main-hovered'
>
<span className={' border-b-[1px] border-b-main-hovered '}>{children}</span>
</a>
{ref.current && (
<EditLinkToolbar
editing={editing}
href={href}
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
linkElement={ref.current}
blockId={blockId}
onEdit={onEdit}
/>
)}
</>
);
}
export default TextLink;

View File

@ -144,6 +144,7 @@ export const blockConfig: Record<string, BlockConfig> = {
export const defaultTextActionProps: TextActionMenuProps = { export const defaultTextActionProps: TextActionMenuProps = {
customItems: [ customItems: [
TextAction.Turn, TextAction.Turn,
TextAction.Link,
TextAction.Bold, TextAction.Bold,
TextAction.Italic, TextAction.Italic,
TextAction.Underline, TextAction.Underline,
@ -154,29 +155,24 @@ export const defaultTextActionProps: TextActionMenuProps = {
excludeItems: [], excludeItems: [],
}; };
const groupKeys = {
comment: [],
format: [
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
TextAction.Strikethrough,
TextAction.Code,
TextAction.Equation,
],
link: [TextAction.Link],
turn: [TextAction.Turn],
};
export const multiLineTextActionProps: TextActionMenuProps = { export const multiLineTextActionProps: TextActionMenuProps = {
customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code], customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
}; };
export const multiLineTextActionGroups = [ export const multiLineTextActionGroups = [groupKeys.format];
[
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
TextAction.Strikethrough,
TextAction.Code,
TextAction.Equation,
],
];
export const textActionGroups = [ export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];
[TextAction.Turn],
[
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
TextAction.Strikethrough,
TextAction.Code,
TextAction.Equation,
],
];

View File

@ -184,6 +184,7 @@ export enum TextAction {
Strikethrough = 'strikethrough', Strikethrough = 'strikethrough',
Code = 'code', Code = 'code',
Equation = 'equation', Equation = 'equation',
Link = 'href',
} }
export interface TextActionMenuProps { export interface TextActionMenuProps {
/** /**
@ -253,7 +254,14 @@ export interface EditorProps {
placeholder?: string; placeholder?: string;
value?: Delta; value?: Delta;
selection?: RangeStaticNoId; selection?: RangeStaticNoId;
lastSelection?: RangeStaticNoId; decorateSelection?: RangeStaticNoId;
linkDecorateSelection?: {
selection?: {
index: number;
length: number;
};
placeholder?: string;
};
onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void; onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void; onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void; onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
@ -264,3 +272,15 @@ export interface BlockCopyData {
text: string; text: string;
html: string; html: string;
} }
export interface LinkPopoverState {
anchorPosition?: { top: number; left: number };
id?: string;
selection?: {
index: number;
length: number;
};
open?: boolean;
href?: string;
title?: string;
}

View File

@ -1,4 +1,4 @@
import { DocumentState, BlockData } from '$app/interfaces/document'; import { BlockData, DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import Delta, { Op } from 'quill-delta'; import Delta, { Op } from 'quill-delta';

View File

@ -12,7 +12,7 @@ export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
const { document, documentRange } = state; const { document, documentRange } = state;
const { ranges } = documentRange; const { ranges } = documentRange;
const match = (delta: Delta, format: TextAction) => { const match = (delta: Delta, format: TextAction) => {
return delta.ops.every((op) => op.attributes?.[format] === true); return delta.ops.every((op) => op.attributes?.[format]);
}; };
return Object.entries(ranges).every(([id, range]) => { return Object.entries(ranges).every(([id, range]) => {
const node = document.nodes[id]; const node = document.nodes[id];
@ -36,15 +36,16 @@ export const toggleFormatThunk = createAsyncThunk(
const { payload: active } = await dispatch(getFormatActiveThunk(format)); const { payload: active } = await dispatch(getFormatActiveThunk(format));
isActive = !!active; isActive = !!active;
} }
const formatValue = isActive ? undefined : true;
const state = getState() as RootState; const state = getState() as RootState;
const { document } = state; const { document } = state;
const { ranges } = state.documentRange; const { ranges } = state.documentRange;
const toggle = (delta: Delta, format: TextAction) => { const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
const newOps = delta.ops.map((op) => { const newOps = delta.ops.map((op) => {
const attributes = { const attributes = {
...op.attributes, ...op.attributes,
[format]: isActive ? undefined : true, [format]: value,
}; };
return { return {
insert: op.insert, insert: op.insert,
@ -62,7 +63,7 @@ export const toggleFormatThunk = createAsyncThunk(
const beforeDelta = delta.slice(0, index); const beforeDelta = delta.slice(0, index);
const afterDelta = delta.slice(index + length); const afterDelta = delta.slice(index + length);
const rangeDelta = delta.slice(index, index + length); const rangeDelta = delta.slice(index, index + length);
const toggleFormatDelta = toggle(rangeDelta, format); const toggleFormatDelta = toggle(rangeDelta, format, formatValue);
const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta); const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta);
return controller.getUpdateAction({ return controller.getUpdateAction({

View File

@ -2,3 +2,4 @@ export * from './blocks';
export * from './turn_to'; export * from './turn_to';
export * from './keydown'; export * from './keydown';
export * from './range'; export * from './range';
export { updateLinkThunk } from '$app_reducers/document/async-actions/link';

View File

@ -60,6 +60,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
controller, controller,
}) })
); );
dispatch(rangeActions.clearRange());
dispatch(rangeActions.setCaret(caret)); dispatch(rangeActions.setCaret(caret));
return; return;
} }
@ -99,7 +100,6 @@ export const enterActionForBlockThunk = createAsyncThunk(
const children = state.document.children[node.children]; const children = state.document.children[node.children];
const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling; const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
console.log('needMoveChildren', needMoveChildren);
const moveChildrenAction = needMoveChildren const moveChildrenAction = needMoveChildren
? controller.getMoveChildrenAction( ? controller.getMoveChildrenAction(
children.map((id) => state.document.nodes[id]), children.map((id) => state.document.nodes[id]),
@ -150,6 +150,7 @@ export const upDownActionForBlockThunk = createAsyncThunk(
if (!newCaret) { if (!newCaret) {
return; return;
} }
dispatch(rangeActions.clearRange());
dispatch(rangeActions.setCaret(newCaret)); dispatch(rangeActions.setCaret(newCaret));
} }
); );
@ -193,6 +194,7 @@ export const leftActionForBlockThunk = createAsyncThunk(
if (!newCaret) { if (!newCaret) {
return; return;
} }
dispatch(rangeActions.clearRange());
dispatch(rangeActions.setCaret(newCaret)); dispatch(rangeActions.setCaret(newCaret));
} }
); );
@ -238,6 +240,8 @@ export const rightActionForBlockThunk = createAsyncThunk(
if (!newCaret) { if (!newCaret) {
return; return;
} }
dispatch(rangeActions.clearRange());
dispatch(rangeActions.setCaret(newCaret)); dispatch(rangeActions.setCaret(newCaret));
} }
); );

View File

@ -0,0 +1,103 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import Delta from 'quill-delta';
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
import { RootState } from '$app/stores/store';
export const formatLinkThunk = createAsyncThunk<
boolean,
{
controller: DocumentController;
}
>('document/formatLink', async (payload, thunkAPI) => {
const { controller } = payload;
const { getState } = thunkAPI;
const state = getState() as RootState;
const linkPopover = state.documentLinkPopover;
if (!linkPopover) return false;
const { selection, id, href, title = '' } = linkPopover;
if (!selection || !id) return false;
const document = state.document;
const node = document.nodes[id];
const nodeDelta = new Delta(node.data?.delta);
const index = selection.index || 0;
const length = selection.length || 0;
const regex = new RegExp(/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/);
if (href !== undefined && !regex.test(href)) {
return false;
}
const diffDelta = new Delta().retain(index).delete(length).insert(title, {
href,
});
const newDelta = nodeDelta.compose(diffDelta);
const updateAction = controller.getUpdateAction({
...node,
data: {
...node.data,
delta: newDelta.ops,
},
});
await controller.applyActions([updateAction]);
return true;
});
export const updateLinkThunk = createAsyncThunk<
void,
{
id: string;
href?: string;
title: string;
}
>('document/updateLink', async (payload, thunkAPI) => {
const { id, href, title } = payload;
const { dispatch } = thunkAPI;
dispatch(
linkPopoverActions.updateLinkPopover({
id,
href,
title,
})
);
});
export const newLinkThunk = createAsyncThunk<void>('document/newLink', async (payload, thunkAPI) => {
const { getState, dispatch } = thunkAPI;
const { documentRange, document } = getState() as RootState;
const { caret } = documentRange;
if (!caret) return;
const { index, length, id } = caret;
const block = document.nodes[id];
const delta = new Delta(block.data.delta).slice(index, index + length);
const op = delta.ops.find((op) => op.attributes?.href);
const href = op?.attributes?.href as string;
const domSelection = window.getSelection();
if (!domSelection) return;
const domRange = domSelection.rangeCount > 0 ? domSelection.getRangeAt(0) : null;
if (!domRange) return;
const title = domSelection.toString();
const { top, left, height, width } = domRange.getBoundingClientRect();
dispatch(rangeActions.clearRange());
dispatch(
linkPopoverActions.setLinkPopover({
anchorPosition: {
top: top + height,
left: left + width / 2,
},
id,
selection: {
index,
length,
},
title,
href,
open: true,
})
);
});

View File

@ -104,8 +104,7 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
const { getState, dispatch } = thunkAPI; const { getState, dispatch } = thunkAPI;
const state = getState() as RootState; const state = getState() as RootState;
const rangeState = state.documentRange; const rangeState = state.documentRange;
// if no range, just return
if (rangeState.caret && rangeState.caret.length === 0) return;
const actions = []; const actions = [];
// get merge actions // get merge actions
const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta); const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);

View File

@ -5,6 +5,7 @@ import {
SlashCommandState, SlashCommandState,
RangeState, RangeState,
RangeStatic, RangeStatic,
LinkPopoverState,
} from '@/appflowy_app/interfaces/document'; } from '@/appflowy_app/interfaces/document';
import { BlockEventPayloadPB } from '@/services/backend'; import { BlockEventPayloadPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
@ -29,6 +30,8 @@ const slashCommandInitialState: SlashCommandState = {
isSlashCommand: false, isSlashCommand: false,
}; };
const linkPopoverState: LinkPopoverState = {};
export const documentSlice = createSlice({ export const documentSlice = createSlice({
name: 'document', name: 'document',
initialState: initialState, initialState: initialState,
@ -158,7 +161,11 @@ export const rangeSlice = createSlice({
setDragging: (state, action: PayloadAction<boolean>) => { setDragging: (state, action: PayloadAction<boolean>) => {
state.isDragging = action.payload; state.isDragging = action.payload;
}, },
setCaret: (state, action: PayloadAction<RangeStatic>) => { setCaret: (state, action: PayloadAction<RangeStatic | null>) => {
if (!action.payload) {
state.caret = undefined;
return;
}
const id = action.payload.id; const id = action.payload.id;
state.ranges[id] = { state.ranges[id] = {
index: action.payload.index, index: action.payload.index,
@ -167,10 +174,7 @@ export const rangeSlice = createSlice({
state.caret = action.payload; state.caret = action.payload;
}, },
clearRange: (state, _: PayloadAction) => { clearRange: (state, _: PayloadAction) => {
state.isDragging = false; return rangeInitialState;
state.ranges = {};
state.anchor = undefined;
state.focus = undefined;
}, },
}, },
}); });
@ -197,14 +201,46 @@ export const slashCommandSlice = createSlice({
}, },
}); });
export const linkPopoverSlice = createSlice({
name: 'documentLinkPopover',
initialState: linkPopoverState,
reducers: {
setLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
return {
...state,
...action.payload,
};
},
updateLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
const { id } = action.payload;
if (!state.open || state.id !== id) return;
return {
...state,
...action.payload,
};
},
closeLinkPopover: (state, _: PayloadAction) => {
return {
...state,
open: false,
};
},
resetLinkPopover: (state, _: PayloadAction) => {
return linkPopoverState;
},
},
});
export const documentReducers = { export const documentReducers = {
[documentSlice.name]: documentSlice.reducer, [documentSlice.name]: documentSlice.reducer,
[rectSelectionSlice.name]: rectSelectionSlice.reducer, [rectSelectionSlice.name]: rectSelectionSlice.reducer,
[rangeSlice.name]: rangeSlice.reducer, [rangeSlice.name]: rangeSlice.reducer,
[slashCommandSlice.name]: slashCommandSlice.reducer, [slashCommandSlice.name]: slashCommandSlice.reducer,
[linkPopoverSlice.name]: linkPopoverSlice.reducer,
}; };
export const documentActions = documentSlice.actions; export const documentActions = documentSlice.actions;
export const rectSelectionActions = rectSelectionSlice.actions; export const rectSelectionActions = rectSelectionSlice.actions;
export const rangeActions = rangeSlice.actions; export const rangeActions = rangeSlice.actions;
export const slashCommandActions = slashCommandSlice.actions; export const slashCommandActions = slashCommandSlice.actions;
export const linkPopoverActions = linkPopoverSlice.actions;

View File

@ -57,7 +57,6 @@ export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentSt
export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) { export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
const { anchor, focus, ranges } = rangeState; const { anchor, focus, ranges } = rangeState;
if (!anchor || !focus) return; if (!anchor || !focus) return;
if (anchor.id === focus.id) return;
const isForward = anchor.point.y < focus.point.y; const isForward = anchor.point.y < focus.point.y;
const startId = isForward ? anchor.id : focus.id; const startId = isForward ? anchor.id : focus.id;

View File

@ -80,3 +80,7 @@ export function getAppendBlockDeltaAction(
}, },
}); });
} }
export function copyText(text: string) {
return navigator.clipboard.writeText(text);
}

View File

@ -12,7 +12,7 @@ export function exclude(node: Element) {
return isPlaceholder; return isPlaceholder;
} }
function findFirstTextNode(node: Node): Node | null { export function findFirstTextNode(node: Node): Node | null {
if (isTextNode(node)) { if (isTextNode(node)) {
return node; return node;
} }
@ -45,7 +45,7 @@ export function setCursorAtStartOfNode(node: Node): void {
selection?.addRange(range); selection?.addRange(range);
} }
function findLastTextNode(node: Node): Node | null { export function findLastTextNode(node: Node): Node | null {
if (isTextNode(node)) { if (isTextNode(node)) {
return node; return node;
} }
@ -174,7 +174,7 @@ export function findTextNode(
return { remainingIndex }; return { remainingIndex };
} }
export function focusNodeByIndex(node: Element, index: number, length: number) { export function getRangeByIndex(node: Element, index: number, length: number) {
const textBoxNode = node.querySelector(`[role="textbox"]`); const textBoxNode = node.querySelector(`[role="textbox"]`);
if (!textBoxNode) return; if (!textBoxNode) return;
const anchorNode = findTextNode(textBoxNode, index); const anchorNode = findTextNode(textBoxNode, index);
@ -185,10 +185,16 @@ export function focusNodeByIndex(node: Element, index: number, length: number) {
const range = document.createRange(); const range = document.createRange();
range.setStart(anchorNode.node, anchorNode.offset || 0); range.setStart(anchorNode.node, anchorNode.offset || 0);
range.setEnd(focusNode.node, focusNode.offset || 0); range.setEnd(focusNode.node, focusNode.offset || 0);
return range;
}
export function focusNodeByIndex(node: Element, index: number, length: number) {
const range = getRangeByIndex(node, index, length);
if (!range) return false;
const selection = window.getSelection(); const selection = window.getSelection();
selection?.removeAllRanges(); selection?.removeAllRanges();
selection?.addRange(range); selection?.addRange(range);
return true;
} }
export function getNodeTextBoxByBlockId(blockId: string) { export function getNodeTextBoxByBlockId(blockId: string) {
@ -229,3 +235,31 @@ export function findParent(node: Element, parentSelector: string) {
} }
return null; return null;
} }
export function getWordIndices(startContainer: Node, startOffset: number) {
const textNode = startContainer;
const textContent = textNode.textContent || '';
const wordRegex = /\b\w+\b/g;
let match;
const wordIndices = [];
while ((match = wordRegex.exec(textContent)) !== null) {
const word = match[0];
const wordIndex = match.index;
const wordEndIndex = wordIndex + word.length;
// If the startOffset is greater than the wordIndex and less than the wordEndIndex, then the startOffset is
if (startOffset > wordIndex && startOffset <= wordEndIndex) {
wordIndices.push({
word: word,
startIndex: wordIndex,
endIndex: wordEndIndex,
});
break;
}
}
// If there are no matches, then the startOffset is greater than the last wordEndIndex
return wordIndices;
}

View File

@ -9,7 +9,6 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, c
const nodeRect = node.getBoundingClientRect(); const nodeRect = node.getBoundingClientRect();
const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 }; const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
const top = rect.top - nodeRect.top - toolbarDom.offsetHeight; const top = rect.top - nodeRect.top - toolbarDom.offsetHeight;
let left = rect.left - nodeRect.left - toolbarDom.offsetWidth / 2 + rect.width / 2; let left = rect.left - nodeRect.left - toolbarDom.offsetWidth / 2 + rect.width / 2;
@ -25,7 +24,6 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, c
left = rightBound - toolbarDom.offsetWidth - nodeRect.left - rightThreshold; left = rightBound - toolbarDom.offsetWidth - nodeRect.left - rightThreshold;
} }
return { return {
top, top,
left, left,

View File

@ -20,8 +20,8 @@ body {
@apply bg-[#E0F8FF] @apply bg-[#E0F8FF]
} }
#appflowy-block-doc ::selection { div[role="textbox"] ::selection {
@apply bg-[transparent] @apply bg-transparent;
} }
.btn { .btn {