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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import { debounce } from '$app/utils/tool';
export function useMenuStyle(container: HTMLDivElement) {
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 reCalculatePosition = useCallback(() => {

View File

@ -2,6 +2,7 @@ import { useMenuStyle } from './index.hooks';
import { useAppSelector } from '$app/stores/store';
import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
import BlockPortal from '$app/components/document/BlockPortal';
import { useMemo } from 'react';
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
const { ref, id } = useMenuStyle(container);
@ -14,7 +15,7 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
style={{
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) => {
// prevent toolbar from taking focus away from editor
e.preventDefault();
@ -27,16 +28,24 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
);
};
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
const canShow = useAppSelector((state) => {
const { isDragging, focus, anchor, ranges } = state.documentRange;
const range = useAppSelector((state) => state.documentRange);
const canShow = useMemo(() => {
const { isDragging, focus, anchor, ranges, caret } = range;
// don't show if dragging
if (isDragging) return false;
if (!focus || !anchor) return false;
const isSameLine = anchor.id === focus.id;
const anchorRange = ranges[anchor.id];
if (!anchorRange) return false;
const isCollapsed = isSameLine && anchorRange.length === 0;
return !isCollapsed;
});
// don't show if no focus or anchor
if (!caret) return false;
const isSameLine = anchor?.id === focus?.id;
// show toolbar if range has multiple nodes
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;
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 { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
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 dispatch = useAppDispatch();
@ -25,6 +26,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
[TextAction.Underline]: 'Underline',
[TextAction.Strikethrough]: 'Strike through',
[TextAction.Code]: 'Mark as Code',
[TextAction.Link]: 'Add Link',
}),
[]
);
@ -49,6 +51,26 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
[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(() => {
void (async () => {
const isActive = await isFormatActive();
@ -58,7 +80,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
return (
<MenuTooltip title={formatTooltips[format]}>
<IconButton size='small' sx={{ color }} onClick={() => toggleFormat(format)}>
<IconButton size='small' sx={{ color }} onClick={() => formatClick(format)}>
<FormatIcon icon={icon} />
</IconButton>
</MenuTooltip>

View File

@ -1,6 +1,7 @@
import React from 'react';
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
import { TextAction } from '$app/interfaces/document';
import LinkIcon from '@mui/icons-material/AddLink';
export const iconSize = { width: 18, height: 18 };
export default function FormatIcon({ icon }: { icon: string }) {
@ -15,6 +16,18 @@ export default function FormatIcon({ icon }: { icon: string }) {
return <CodeOutlined sx={iconSize} />;
case TextAction.Strikethrough:
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:
return null;
}

View File

@ -15,13 +15,14 @@ export function useTextActionMenu() {
const isSingleLine = useMemo(() => {
return range.focus?.id === range.anchor?.id;
}, [range]);
const focusId = range.focus?.id;
const focusId = range.caret?.id;
const { node } = useSubscribeNode(focusId || '');
const items = useMemo(() => {
if (!node) return [];
if (isSingleLine) {
const config = blockConfig[node?.type];
const config = blockConfig[node.type];
const { customItems, excludeItems } = {
...defaultTextActionProps,
...config.textActionMenuProps,
@ -30,7 +31,7 @@ export function useTextActionMenu() {
} else {
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
const groupItems: TextAction[][] = useMemo(() => {

View File

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

View File

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

View File

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

View File

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

View File

@ -2,12 +2,17 @@ import { useCallback, useEffect, useState } from 'react';
import { RangeStatic } from 'quill';
import { useAppDispatch } from '$app/stores/store';
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';
export function useSelection(id: string) {
const rangeRef = useRangeRef();
const { focusCaret, lastSelection } = useFocused(id);
const { focusCaret } = useFocused(id);
const decorateProps = useSubscribeDecorate(id);
const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
const dispatch = useAppDispatch();
@ -21,7 +26,6 @@ export function useSelection(id: string) {
const onSelectionChange = useCallback(
(range: RangeStatic | null, _oldRange: RangeStatic | null, _source?: string) => {
if (!range) return;
dispatch(
rangeActions.setCaret({
id,
@ -36,20 +40,19 @@ export function useSelection(id: string) {
useEffect(() => {
if (rangeRef.current && rangeRef.current?.isDragging) return;
const caret = focusCaret;
if (!caret) {
if (!focusCaret) {
setSelection(undefined);
return;
}
setSelection({
index: caret.index,
length: caret.length,
index: focusCaret.index,
length: focusCaret.length,
});
}, [rangeRef, focusCaret]);
return {
onSelectionChange,
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 { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor';
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) {
const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor({
const { editor, onChange, value, ref, ...editableProps } = useEditor({
...props,
isCodeBlock: true,
});
@ -15,16 +16,14 @@ function CodeEditor({ language, ...props }: CodeEditorProps) {
<div ref={ref}>
<Slate editor={editor} onChange={onChange} value={value}>
<Editable
{...editableProps}
decorate={(entry) => {
const codeRange = decorateCode(entry, language);
const range = decorate?.(entry) || [];
const range = editableProps.decorate?.(entry) || [];
return [...range, ...codeRange];
}}
renderLeaf={CodeLeaf}
renderLeaf={(leafProps) => <TextLeaf editor={editor} {...leafProps} isCodeBlock={true} />}
renderElement={CodeBlockElement}
onKeyDown={onKeyDown}
onDOMBeforeInput={onDOMBeforeInput}
onBlur={onBlur}
/>
</Slate>
</div>

View File

@ -1,46 +1,4 @@
import { RenderLeafProps, 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>
);
};
import { RenderElementProps } from 'slate-react';
export const CodeBlockElement = (props: RenderElementProps) => {
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';
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 (
<div ref={ref} className={'py-0.5'}>
<Slate editor={editor} onChange={onChange} value={value}>
<Editable
onKeyDown={onKeyDown}
onDOMBeforeInput={onDOMBeforeInput}
decorate={decorate}
renderLeaf={TextLeaf}
renderLeaf={(leafProps) => <TextLeaf {...leafProps} editor={editor} />}
placeholder={placeholder}
onBlur={onBlur}
renderElement={TextElement}
{...editableProps}
/>
</Slate>
</div>

View File

@ -1,35 +1,33 @@
import { RenderLeafProps } from 'slate-react';
import { ReactEditor, RenderLeafProps } from 'slate-react';
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 {
leaf: BaseText & {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
code?: string;
selection_high_lighted?: boolean;
};
leaf: BaseText & Attributes;
isCodeBlock?: boolean;
editor: ReactEditor;
}
const TextLeaf = (props: TextLeafProps) => {
const { attributes, children, leaf } = props;
const { attributes, children, leaf, isCodeBlock, editor } = props;
const ref = useRef<HTMLSpanElement>(null);
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) {
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 = [
isCodeBlock && 'token',
leaf.prism_token && leaf.prism_token,
leaf.strikethrough && 'line-through',
leaf.selection_high_lighted && 'bg-main-secondary',
leaf.link_selection_lighted && 'text-link bg-main-secondary',
leaf.code && 'inline-code',
leaf.bold && 'font-bold',
leaf.italic && 'italic',
leaf.underline && 'underline',
].filter(Boolean);
if (leaf.link_placeholder && leaf.text) {
newChildren = (
<LinkHighLight leaf={leaf} title={leaf.link_placeholder}>
{newChildren}
</LinkHighLight>
);
}
return (
<span ref={ref} {...attributes} className={className.join(' ')}>
{newChildren}

View File

@ -9,7 +9,7 @@ import {
indent,
outdent,
} 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 Delta from 'quill-delta';
import isHotkey from 'is-hotkey';
@ -20,13 +20,13 @@ export function useEditor({
onSelectionChange,
selection,
value: delta,
lastSelection,
decorateSelection,
onKeyDown,
isCodeBlock,
linkDecorateSelection,
}: EditorProps) {
const editor = useSlateYjs({ delta });
const { editor } = useSlateYjs({ delta });
const ref = useRef<HTMLDivElement | null>(null);
const newValue = useMemo(() => [], []);
const onSelectionChangeHandler = useCallback(
(slateSelection: Selection) => {
@ -42,7 +42,7 @@ export function useEditor({
onChange?.(convertToDelta(slateValue), oldContents);
onSelectionChangeHandler(editor.selection);
},
[delta, editor.selection, onChange, onSelectionChangeHandler]
[delta, editor, onChange, onSelectionChangeHandler]
);
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(
(entry: NodeEntry) => {
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) {
return [];
}
const range = {
const ranges: Range[] = [
getDecorateRange(path, decorateSelection, {
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 [];
return ranges;
},
[editor, lastSelection]
[decorateSelection, linkDecorateSelection, getDecorateRange]
);
const onKeyDownRewrite = useCallback(
@ -116,14 +139,53 @@ export function useEditor({
[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(() => {
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);
if (!slateSelection) return;
const isFocused = ReactEditor.isFocused(editor);
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
focusNodeByIndex(ref.current, selection.index, selection.length);
Transforms.select(editor, slateSelection);
const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
if (!isSuccess) {
Transforms.select(editor, slateSelection);
}
}, [editor, selection]);
return {
@ -135,5 +197,7 @@ export function useEditor({
ref,
onKeyDown: onKeyDownRewrite,
onBlur,
onMouseDownCapture,
onDoubleClick,
};
}

View File

@ -1,5 +1,5 @@
import Delta from 'quill-delta';
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useState } from 'react';
import * as Y from 'yjs';
import { convertToSlateValue } from '$app/utils/document/slate_editor';
import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
@ -7,14 +7,14 @@ import { withReact } from 'slate-react';
import { createEditor } from 'slate';
export function useSlateYjs({ delta }: { delta?: Delta }) {
const yTextRef = useRef<Y.Text>();
const [yText, setYText] = useState<Y.Text | undefined>(undefined);
const sharedType = useMemo(() => {
const yDoc = new Y.Doc();
const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
const value = convertToSlateValue(delta || new Delta());
const insertDelta = slateNodesToInsertDelta(value);
sharedType.applyDelta(insertDelta);
yTextRef.current = insertDelta[0].insert as Y.Text;
setYText(insertDelta[0].insert as Y.Text);
return sharedType;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -25,19 +25,17 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
useEffect(() => {
YjsEditor.connect(editor);
return () => {
yTextRef.current = undefined;
YjsEditor.disconnect(editor);
};
}, [editor]);
useEffect(() => {
const yText = yTextRef.current;
if (!yText) return;
const oldContents = new Delta(yText.toDelta());
const diffDelta = oldContents.diff(delta || new Delta());
if (diffDelta.ops.length === 0) return;
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 { useEffect, useMemo, useRef } from 'react';
import { createContext, useEffect, useMemo, useRef } from 'react';
import { Node } from '$app/interfaces/document';
/**
@ -35,3 +35,5 @@ export function useSubscribeNode(id: string) {
export function getBlock(id: string) {
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 { 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) {
const caretRef = useRef<RangeStatic>();
const focusCaret = useAppSelector((state) => {
@ -13,23 +32,14 @@ export function useFocused(id: string) {
return null;
});
const lastSelection = useAppSelector((state) => {
return state.documentRange.ranges[id];
});
const focused = useMemo(() => {
return focusCaret && focusCaret?.id === id;
}, [focusCaret, id]);
const memoizedLastSelection = useMemo(() => {
return lastSelection;
}, [JSON.stringify(lastSelection)]);
return {
focused,
caretRef,
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 = {
customItems: [
TextAction.Turn,
TextAction.Link,
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
@ -154,29 +155,24 @@ export const defaultTextActionProps: TextActionMenuProps = {
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 = {
customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
};
export const multiLineTextActionGroups = [
[
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
TextAction.Strikethrough,
TextAction.Code,
TextAction.Equation,
],
];
export const multiLineTextActionGroups = [groupKeys.format];
export const textActionGroups = [
[TextAction.Turn],
[
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
TextAction.Strikethrough,
TextAction.Code,
TextAction.Equation,
],
];
export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];

View File

@ -184,6 +184,7 @@ export enum TextAction {
Strikethrough = 'strikethrough',
Code = 'code',
Equation = 'equation',
Link = 'href',
}
export interface TextActionMenuProps {
/**
@ -253,7 +254,14 @@ export interface EditorProps {
placeholder?: string;
value?: Delta;
selection?: RangeStaticNoId;
lastSelection?: RangeStaticNoId;
decorateSelection?: RangeStaticNoId;
linkDecorateSelection?: {
selection?: {
index: number;
length: number;
};
placeholder?: string;
};
onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
@ -264,3 +272,15 @@ export interface BlockCopyData {
text: 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 { createAsyncThunk } from '@reduxjs/toolkit';
import Delta, { Op } from 'quill-delta';

View File

@ -12,7 +12,7 @@ export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
const { document, documentRange } = state;
const { ranges } = documentRange;
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]) => {
const node = document.nodes[id];
@ -36,15 +36,16 @@ export const toggleFormatThunk = createAsyncThunk(
const { payload: active } = await dispatch(getFormatActiveThunk(format));
isActive = !!active;
}
const formatValue = isActive ? undefined : true;
const state = getState() as RootState;
const { document } = state;
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 attributes = {
...op.attributes,
[format]: isActive ? undefined : true,
[format]: value,
};
return {
insert: op.insert,
@ -62,7 +63,7 @@ export const toggleFormatThunk = createAsyncThunk(
const beforeDelta = delta.slice(0, index);
const afterDelta = delta.slice(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);
return controller.getUpdateAction({

View File

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

View File

@ -60,6 +60,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
controller,
})
);
dispatch(rangeActions.clearRange());
dispatch(rangeActions.setCaret(caret));
return;
}
@ -99,7 +100,6 @@ export const enterActionForBlockThunk = createAsyncThunk(
const children = state.document.children[node.children];
const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
console.log('needMoveChildren', needMoveChildren);
const moveChildrenAction = needMoveChildren
? controller.getMoveChildrenAction(
children.map((id) => state.document.nodes[id]),
@ -150,6 +150,7 @@ export const upDownActionForBlockThunk = createAsyncThunk(
if (!newCaret) {
return;
}
dispatch(rangeActions.clearRange());
dispatch(rangeActions.setCaret(newCaret));
}
);
@ -193,6 +194,7 @@ export const leftActionForBlockThunk = createAsyncThunk(
if (!newCaret) {
return;
}
dispatch(rangeActions.clearRange());
dispatch(rangeActions.setCaret(newCaret));
}
);
@ -238,6 +240,8 @@ export const rightActionForBlockThunk = createAsyncThunk(
if (!newCaret) {
return;
}
dispatch(rangeActions.clearRange());
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 state = getState() as RootState;
const rangeState = state.documentRange;
// if no range, just return
if (rangeState.caret && rangeState.caret.length === 0) return;
const actions = [];
// get merge actions
const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);

View File

@ -5,6 +5,7 @@ import {
SlashCommandState,
RangeState,
RangeStatic,
LinkPopoverState,
} from '@/appflowy_app/interfaces/document';
import { BlockEventPayloadPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
@ -29,6 +30,8 @@ const slashCommandInitialState: SlashCommandState = {
isSlashCommand: false,
};
const linkPopoverState: LinkPopoverState = {};
export const documentSlice = createSlice({
name: 'document',
initialState: initialState,
@ -158,7 +161,11 @@ export const rangeSlice = createSlice({
setDragging: (state, action: PayloadAction<boolean>) => {
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;
state.ranges[id] = {
index: action.payload.index,
@ -167,10 +174,7 @@ export const rangeSlice = createSlice({
state.caret = action.payload;
},
clearRange: (state, _: PayloadAction) => {
state.isDragging = false;
state.ranges = {};
state.anchor = undefined;
state.focus = undefined;
return rangeInitialState;
},
},
});
@ -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 = {
[documentSlice.name]: documentSlice.reducer,
[rectSelectionSlice.name]: rectSelectionSlice.reducer,
[rangeSlice.name]: rangeSlice.reducer,
[slashCommandSlice.name]: slashCommandSlice.reducer,
[linkPopoverSlice.name]: linkPopoverSlice.reducer,
};
export const documentActions = documentSlice.actions;
export const rectSelectionActions = rectSelectionSlice.actions;
export const rangeActions = rangeSlice.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) {
const { anchor, focus, ranges } = rangeState;
if (!anchor || !focus) return;
if (anchor.id === focus.id) return;
const isForward = anchor.point.y < focus.point.y;
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;
}
function findFirstTextNode(node: Node): Node | null {
export function findFirstTextNode(node: Node): Node | null {
if (isTextNode(node)) {
return node;
}
@ -45,7 +45,7 @@ export function setCursorAtStartOfNode(node: Node): void {
selection?.addRange(range);
}
function findLastTextNode(node: Node): Node | null {
export function findLastTextNode(node: Node): Node | null {
if (isTextNode(node)) {
return node;
}
@ -174,7 +174,7 @@ export function findTextNode(
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"]`);
if (!textBoxNode) return;
const anchorNode = findTextNode(textBoxNode, index);
@ -185,10 +185,16 @@ export function focusNodeByIndex(node: Element, index: number, length: number) {
const range = document.createRange();
range.setStart(anchorNode.node, anchorNode.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();
selection?.removeAllRanges();
selection?.addRange(range);
return true;
}
export function getNodeTextBoxByBlockId(blockId: string) {
@ -229,3 +235,31 @@ export function findParent(node: Element, parentSelector: string) {
}
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 rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
const top = rect.top - nodeRect.top - toolbarDom.offsetHeight;
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;
}
return {
top,
left,

View File

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