mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
aca3c90eb3
commit
00c0934df6
@ -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;
|
||||
}
|
||||
|
@ -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]
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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(() => {
|
||||
|
@ -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} />;
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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(() => {
|
||||
|
@ -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:
|
||||
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
@ -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));
|
||||
},
|
||||
|
@ -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(),
|
||||
};
|
||||
},
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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>
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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>('');
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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];
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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({
|
||||
|
@ -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';
|
||||
|
@ -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));
|
||||
}
|
||||
);
|
||||
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -80,3 +80,7 @@ export function getAppendBlockDeltaAction(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function copyText(text: string) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -20,8 +20,8 @@ body {
|
||||
@apply bg-[#E0F8FF]
|
||||
}
|
||||
|
||||
#appflowy-block-doc ::selection {
|
||||
@apply bg-[transparent]
|
||||
div[role="textbox"] ::selection {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
Loading…
Reference in New Issue
Block a user