diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts index edc6cc9fa4..a222e401d8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts @@ -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; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts index 8e8eb67659..a841e8ec1a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts @@ -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] ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx index a7c65bd5da..e35786b190 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx @@ -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 (
@@ -28,9 +30,7 @@ export default function CodeBlock({ placeholder={placeholder} language={language} onKeyDown={onKeyDown} - onSelectionChange={onSelectionChange} - selection={selection} - lastSelection={lastSelection} + {...selectionProps} />
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx index 9c2b95fd8a..a7f6bc9bcb 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx @@ -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) { const { node, childIds, isSelected, ref } = useNode(id); @@ -60,13 +61,15 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes - {renderBlock()} - - {isSelected ? ( -
- ) : null} -
+ +
+ {renderBlock()} + + {isSelected ? ( +
+ ) : null} +
+ ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx index 4280e429f9..ea38d29c92 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx @@ -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 }) { + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts index 4d00d48393..b72827689a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts @@ -6,7 +6,7 @@ import { debounce } from '$app/utils/tool'; export function useMenuStyle(container: HTMLDivElement) { const ref = useRef(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(() => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx index 82350c3a1b..66edf30113 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx @@ -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 ; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx index cdbb725f94..0f8b322278 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx @@ -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 ( - toggleFormat(format)}> + formatClick(format)}> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx index d8c0024213..89ac65768b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx @@ -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 ; case TextAction.Strikethrough: return ; + case TextAction.Link: + return ( +
+ +
Link
+
+ ); default: return null; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts index f20ff69f69..c13332eadd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts @@ -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(() => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx index 081f96f951..07ae25151e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx @@ -11,6 +11,7 @@ function TextActionMenuList() { switch (action) { case TextAction.Turn: return isSingleLine && focusId ? : null; + case TextAction.Link: case TextAction.Bold: case TextAction.Italic: case TextAction.Underline: diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx index 9401d1aac3..88efbbaf20 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx @@ -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 ( <> - + ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts index 166fe6bee8..cf94a19494 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts @@ -89,7 +89,6 @@ export function useKeyDown(id: string) { const onKeyDown = useCallback( (e: React.KeyboardEvent) => { e.stopPropagation(); - const filteredEvents = interceptEvents.filter((event) => event.canHandle(e)); filteredEvents.forEach((event) => event.handler(e)); }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts index 7100f2cf3e..ebd9866d5d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts @@ -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(), }; }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts index 7d6093f54d..208f75a1b4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts @@ -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(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, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Message/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Message/index.tsx new file mode 100644 index 0000000000..99eac15767 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Message/index.tsx @@ -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 ; +} + +interface MessageProps { + message?: string; + key?: string; + duration?: number; +} +export function useMessage() { + const [state, setState] = useState(); + const show = useCallback((message: MessageProps) => { + setState(message); + }, []); + const hide = useCallback(() => { + setState(undefined); + }, []); + + const contentHolder = useMemo(() => { + const open = !!state; + return ( + + + + ); + }, [hide, state]); + + return { + show, + hide, + contentHolder, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx index 6feeaa68a8..ed476f83d9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx @@ -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) {
{ const codeRange = decorateCode(entry, language); - const range = decorate?.(entry) || []; + const range = editableProps.decorate?.(entry) || []; return [...range, ...codeRange]; }} - renderLeaf={CodeLeaf} + renderLeaf={(leafProps) => } renderElement={CodeBlockElement} - onKeyDown={onKeyDown} - onDOMBeforeInput={onDOMBeforeInput} - onBlur={onBlur} />
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx index 8169420bfa..c3111a5a42 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx @@ -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 = {children}; - } - - if (leaf.italic) { - newChildren = {newChildren}; - } - - if (leaf.underline) { - newChildren = {newChildren}; - } - - const className = [ - 'token', - leaf.prism_token && leaf.prism_token, - leaf.strikethrough && 'line-through', - leaf.selection_high_lighted && 'bg-main-secondary', - ].filter(Boolean); - - return ( - - {newChildren} - - ); -}; +import { RenderElementProps } from 'slate-react'; export const CodeBlockElement = (props: RenderElementProps) => { return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx index 21683b6e9d..2a87497053 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx @@ -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 (
} placeholder={placeholder} - onBlur={onBlur} renderElement={TextElement} + {...editableProps} />
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx index b463547f9e..d06a87e083 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx @@ -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(null); let newChildren = children; - if (leaf.bold) { - newChildren = {children}; - } - - if (leaf.italic) { - newChildren = {newChildren}; - } - - if (leaf.underline) { - newChildren = {newChildren}; - } 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 = ( + + {newChildren} + + ); + } + 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 = ( + + {newChildren} + + ); + } return ( {newChildren} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts index 66517b6143..9becdd81b8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts @@ -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(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 + ) => { + 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, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts index f18384d0f1..8bf42dcf05 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts @@ -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(); + const [yText, setYText] = useState(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 }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts index a4aaf27920..fd966fdba8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts @@ -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(''); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts index 27aa9bd1ee..10438647e7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts @@ -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(); 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, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLink.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLink.tsx new file mode 100644 index 0000000000..94da916a1a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLink.tsx @@ -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 ( +
+
{text}
+
+ { + const newValue = e.target.value; + setVal(newValue); + }} + value={val} + /> +
+
+ ); +} + +export default EditLink; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLinkToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLinkToolbar.tsx new file mode 100644 index 0000000000..8455635b28 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLinkToolbar.tsx @@ -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(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 && ( + +
+
+
+ +
+
{href}
+
{ + try { + await copyText(href); + show({ message: 'Copied!', duration: 6000 }); + } catch { + show({ message: 'Copy failed!', duration: 6000 }); + } + }} + className={'mr-2 cursor-pointer'} + > + +
+
+ Edit +
+
+
+
+ )} + {contentHolder} + + ); +} + +export default EditLinkToolbar; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkButton.tsx new file mode 100644 index 0000000000..369dd25867 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkButton.tsx @@ -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 ( +
+ +
+ ); +} + +export default LinkButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkEditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkEditPopover.tsx new file mode 100644 index 0000000000..069090e861 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkEditPopover.tsx @@ -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 ( + 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, + }, + }} + > +
+ { + onChange({ + href: link, + title, + }); + }} + /> + + onChange({ + href, + title: text, + }) + } + /> + + } + onClick={() => { + onChange({ + title, + }); + onDone(); + }} + /> + } onClick={onDone} /> +
+
+ ); +} + +export default LinkEditPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx new file mode 100644 index 0000000000..65f618a71b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx @@ -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) ? ( + {title} + ) : null} + + + {children} + + + ); +} + +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; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/TextLink.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/TextLink.hooks.ts new file mode 100644 index 0000000000..7d9e0495e3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/TextLink.hooks.ts @@ -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(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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/index.tsx new file mode 100644 index 0000000000..29a4aa83b6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/index.tsx @@ -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 ( + <> + + {children} + + {ref.current && ( + + )} + + ); +} + +export default TextLink; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts index 2601d14ccd..2e868927df 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts @@ -144,6 +144,7 @@ export const blockConfig: Record = { 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]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index 1fd766ad43..6a0b337a6a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -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) => 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; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts index 44f677efa5..f1142856e5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts @@ -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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts index b60b06e8cc..a2474e3967 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts @@ -12,7 +12,7 @@ export const getFormatActiveThunk = createAsyncThunk( 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({ diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts index 73eb214085..9813c45c34 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts @@ -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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts index a6c8d51561..5c04afd6f5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts @@ -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)); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts new file mode 100644 index 0000000000..fcb540a9c5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts @@ -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('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, + }) + ); +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts index 8892ec0891..ecd567249e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts @@ -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); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts index 5262b0e4ee..71d27b93c6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts @@ -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) => { state.isDragging = action.payload; }, - setCaret: (state, action: PayloadAction) => { + setCaret: (state, action: PayloadAction) => { + 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) => { + return { + ...state, + ...action.payload, + }; + }, + updateLinkPopover: (state, action: PayloadAction) => { + 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; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts index 888ba64f07..89e6779fd5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts @@ -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; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts index 3004070b54..29a998663e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts @@ -80,3 +80,7 @@ export function getAppendBlockDeltaAction( }, }); } + +export function copyText(text: string) { + return navigator.clipboard.writeText(text); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts index 28378d43a2..aa289a5c9e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts @@ -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; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts index a024bfd208..93546f33b2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts @@ -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, diff --git a/frontend/appflowy_tauri/src/styles/template.css b/frontend/appflowy_tauri/src/styles/template.css index 5001b5650f..5887901b76 100644 --- a/frontend/appflowy_tauri/src/styles/template.css +++ b/frontend/appflowy_tauri/src/styles/template.css @@ -20,8 +20,8 @@ body { @apply bg-[#E0F8FF] } -#appflowy-block-doc ::selection { - @apply bg-[transparent] +div[role="textbox"] ::selection { + @apply bg-transparent; } .btn {