Support to show text action toolbar when the selection exists and the range is not collapsed (#2525)

* feat: support text action menu

* fix: selection bugs

* fix: review suggestions

* fix: ci tsc failed
This commit is contained in:
Kilu.He
2023-05-19 14:56:43 +08:00
committed by GitHub
parent f04d64a191
commit f23c6098a7
50 changed files with 1607 additions and 767 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +0,0 @@
import { toggleFormat, isFormatActive } from '$app/utils/document/blocks/text/format';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { command } from '$app/constants/document/toolbar';
import FormatIcon from './FormatIcon';
import { BaseEditor } from 'slate';
const FormatButton = ({ editor, format, icon }: { editor: BaseEditor; format: string; icon: string }) => {
return (
<Tooltip
slotProps={{ tooltip: { style: { background: '#E0F8FF', borderRadius: 8 } } }}
title={
<div className='flex flex-col'>
<span className='text-base font-medium text-black'>{command[format].title}</span>
<span className='text-sm text-slate-400'>{command[format].key}</span>
</div>
}
placement='top-start'
>
<IconButton
size='small'
sx={{ color: isFormatActive(editor, format) ? '#00BCF0' : 'white' }}
onClick={() => toggleFormat(editor, format)}
>
<FormatIcon icon={icon} />
</IconButton>
</Tooltip>
);
};
export default FormatButton;

View File

@ -1,33 +0,0 @@
import { useEffect, useRef } from 'react';
import { useFocused, useSlate } from 'slate-react';
import { calcToolbarPosition } from '$app/utils/document/blocks/text/toolbar';
export function useHoveringToolbar(id: string) {
const editor = useSlate();
const inFocus = useFocused();
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const nodeRect = document.querySelector(`[data-block-id="${id}"]`)?.getBoundingClientRect();
if (!nodeRect) return;
const position = calcToolbarPosition(editor, el, nodeRect);
if (!position) {
el.style.opacity = '0';
el.style.pointerEvents = 'none';
} else {
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
el.style.top = position.top;
el.style.left = position.left;
}
});
return {
ref,
inFocus,
editor,
};
}

View File

@ -1,30 +0,0 @@
import FormatButton from './FormatButton';
import Portal from '../BlockPortal';
import { useHoveringToolbar } from './index.hooks';
const BlockHorizontalToolbar = ({ id }: { id: string }) => {
const { inFocus, ref, editor } = useHoveringToolbar(id);
if (!inFocus) return null;
return (
<Portal blockId={id}>
<div
ref={ref}
style={{
opacity: 0,
}}
className='absolute mt-[-6px] inline-flex h-[32px] items-stretch overflow-hidden rounded-[8px] bg-[#333] p-2 leading-tight shadow-lg transition-opacity duration-700'
onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor
e.preventDefault();
}}
>
{['bold', 'italic', 'underlined', 'strikethrough', 'code'].map((format) => (
<FormatButton key={format} editor={editor} format={format} icon={format} />
))}
</div>
</Portal>
);
};
export default BlockHorizontalToolbar;

View File

@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getBlockIdByPoint } from '$app/utils/document/blocks/selection';
import { rangeSelectionActions } from '$app_reducers/document/slice';
import { useAppDispatch } from '$app/stores/store';
import { getNodesInRange } from '$app/utils/document/blocks/common';
import { setRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
export function useBlockRangeSelection(container: HTMLDivElement) {
const dispatch = useAppDispatch();
const anchorRef = useRef<{
id: string;
point: { x: number; y: number };
range?: Range;
} | null>(null);
const [isDragging, setDragging] = useState(false);
const reset = useCallback(() => {
dispatch(rangeSelectionActions.clearRange());
}, [dispatch]);
useEffect(() => {
dispatch(rangeSelectionActions.setDragging(isDragging));
}, [dispatch, isDragging]);
const handleDragStart = useCallback(
(e: MouseEvent) => {
reset();
const blockId = getBlockIdByPoint(e.target as HTMLElement);
if (!blockId) {
return;
}
const startX = e.clientX + container.scrollLeft;
const startY = e.clientY + container.scrollTop;
anchorRef.current = {
id: blockId,
point: {
x: startX,
y: startY,
},
};
setDragging(true);
},
[container.scrollLeft, container.scrollTop, reset]
);
const handleDraging = useCallback(
(e: MouseEvent) => {
if (!isDragging || !anchorRef.current) return;
const blockId = getBlockIdByPoint(e.target as HTMLElement);
if (!blockId) {
return;
}
const anchorId = anchorRef.current.id;
if (anchorId === blockId) {
const endX = e.clientX + container.scrollTop;
const isForward = endX > anchorRef.current.point.x;
dispatch(rangeSelectionActions.setForward(isForward));
return;
}
const endY = e.clientY + container.scrollTop;
const isForward = endY > anchorRef.current.point.y;
dispatch(rangeSelectionActions.setForward(isForward));
},
[container.scrollTop, dispatch, isDragging]
);
const handleDragEnd = useCallback(() => {
if (!isDragging) return;
setDragging(false);
dispatch(setRangeSelectionThunk());
}, [dispatch, isDragging]);
// TODO: This is a hack to fix the issue that the selection is lost when scrolling
const handleScroll = useCallback(() => {
if (isDragging || !anchorRef.current) return;
const selection = window.getSelection();
if (!selection?.rangeCount && anchorRef.current.range) {
selection?.addRange(anchorRef.current.range);
} else {
anchorRef.current.range = selection?.getRangeAt(0);
}
}, [isDragging]);
useEffect(() => {
document.addEventListener('mousedown', handleDragStart);
document.addEventListener('mousemove', handleDraging, true);
document.addEventListener('mouseup', handleDragEnd);
container.addEventListener('scroll', handleScroll);
return () => {
document.removeEventListener('mousedown', handleDragStart);
document.removeEventListener('mousemove', handleDraging, true);
document.removeEventListener('mouseup', handleDragEnd);
container.removeEventListener('scroll', handleScroll);
};
}, [handleDragStart, handleDragEnd, handleDraging, container, handleScroll]);
return null;
}

View File

@ -1,18 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { rectSelectionActions } from "@/appflowy_app/stores/reducers/document/slice"; import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/slice';
import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks'; import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks';
import { setRectSelectionThunk } from "$app_reducers/document/async-actions/rect_selection"; import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
import { isPointInBlock } from '$app/utils/document/blocks/selection';
export function useBlockSelection({ export function useBlockRectSelection({ container }: { container: HTMLDivElement }) {
container, const dispatch = useAppDispatch();
onDragging,
}: {
container: HTMLDivElement;
onDragging?: (_isDragging: boolean) => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const disaptch = useAppDispatch();
const [isDragging, setDragging] = useState(false); const [isDragging, setDragging] = useState(false);
const startPointRef = useRef<number[]>([]); const startPointRef = useRef<number[]>([]);
@ -20,8 +14,8 @@ export function useBlockSelection({
const { getIntersectedBlockIds } = useNodesRect(container); const { getIntersectedBlockIds } = useNodesRect(container);
useEffect(() => { useEffect(() => {
onDragging?.(isDragging); dispatch(rectSelectionActions.setDragging(isDragging));
}, [isDragging, onDragging]); }, [dispatch, isDragging]);
const [rect, setRect] = useState<{ const [rect, setRect] = useState<{
startX: number; startX: number;
@ -45,17 +39,6 @@ export function useBlockSelection({
}; };
}, [container.scrollLeft, container.scrollTop, rect]); }, [container.scrollLeft, container.scrollTop, rect]);
const isPointInBlock = useCallback((target: HTMLElement | null) => {
let node = target;
while (node) {
if (node.getAttribute('data-block-id')) {
return true;
}
node = node.parentElement;
}
return false;
}, []);
const handleDragStart = useCallback( const handleDragStart = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (isPointInBlock(e.target as HTMLElement)) { if (isPointInBlock(e.target as HTMLElement)) {
@ -74,7 +57,7 @@ export function useBlockSelection({
endY: startY, endY: startY,
}); });
}, },
[container.scrollLeft, container.scrollTop, isPointInBlock] [container.scrollLeft, container.scrollTop]
); );
const updateSelctionsByPoint = useCallback( const updateSelctionsByPoint = useCallback(
@ -92,9 +75,9 @@ export function useBlockSelection({
}; };
const blockIds = getIntersectedBlockIds(newRect); const blockIds = getIntersectedBlockIds(newRect);
setRect(newRect); setRect(newRect);
disaptch(setRectSelectionThunk(blockIds)); dispatch(setRectSelectionThunk(blockIds));
}, },
[container.scrollLeft, container.scrollTop, disaptch, getIntersectedBlockIds, isDragging] [container.scrollLeft, container.scrollTop, dispatch, getIntersectedBlockIds, isDragging]
); );
const handleDraging = useCallback( const handleDraging = useCallback(
@ -119,7 +102,7 @@ export function useBlockSelection({
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (isPointInBlock(e.target as HTMLElement) && !isDragging) { if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
disaptch(rectSelectionActions.updateSelections([])); dispatch(rectSelectionActions.updateSelections([]));
return; return;
} }
if (!isDragging) return; if (!isDragging) return;
@ -128,11 +111,10 @@ export function useBlockSelection({
setDragging(false); setDragging(false);
setRect(null); setRect(null);
}, },
[disaptch, isDragging, isPointInBlock, updateSelctionsByPoint] [dispatch, isDragging, updateSelctionsByPoint]
); );
useEffect(() => { useEffect(() => {
if (!ref.current) return;
document.addEventListener('mousedown', handleDragStart); document.addEventListener('mousedown', handleDragStart);
document.addEventListener('mousemove', handleDraging); document.addEventListener('mousemove', handleDraging);
document.addEventListener('mouseup', handleDragEnd); document.addEventListener('mouseup', handleDragEnd);
@ -147,6 +129,5 @@ export function useBlockSelection({
return { return {
isDragging, isDragging,
style, style,
ref,
}; };
} }

View File

@ -0,0 +1,13 @@
import React from 'react';
import { useBlockRectSelection } from '$app/components/document/BlockSelection/BlockRectSelection.hooks';
function BlockRectSelection({ container }: { container: HTMLDivElement }) {
const { isDragging, style } = useBlockRectSelection({
container,
});
if (!isDragging) return null;
return <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} />;
}
export default BlockRectSelection;

View File

@ -1,21 +1,12 @@
import { useBlockSelection } from './BlockSelection.hooks';
import React from 'react'; import React from 'react';
import BlockRectSelection from '$app/components/document/BlockSelection/BlockRectSelection';
import { useBlockRangeSelection } from '$app/components/document/BlockSelection/BlockRangeSelection.hooks';
function BlockSelection({ function BlockSelection({ container }: { container: HTMLDivElement }) {
container, useBlockRangeSelection(container);
onDragging,
}: {
container: HTMLDivElement;
onDragging?: (_isDragging: boolean) => void;
}) {
const { isDragging, style, ref } = useBlockSelection({
container,
onDragging,
});
return ( return (
<div ref={ref} className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'> <div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
{isDragging ? <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} /> : null} <BlockRectSelection container={container} />
</div> </div>
); );
} }

View File

@ -5,13 +5,17 @@ import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
import Portal from '../BlockPortal'; import Portal from '../BlockPortal';
import { IconButton } from '@mui/material'; import { IconButton } from '@mui/material';
import BlockMenu from '../BlockMenu'; import BlockMenu from '../BlockMenu';
import { useAppSelector } from '$app/stores/store';
const sx = { height: 24, width: 24 }; const sx = { height: 24, width: 24 };
export default function BlockSideToolbar(props: { container: HTMLDivElement }) { export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props); const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
const isDragging = useAppSelector(
(state) => state.documentRangeSelection.isDragging || state.documentRectSelection.isDragging
);
if (!nodeId) return null; if (!nodeId || isDragging) return null;
return ( return (
<> <>
<Portal blockId={nodeId}> <Portal blockId={nodeId}>

View File

@ -1,10 +1,11 @@
import { BlockType, NestedBlock } from '$app/interfaces/document'; import { BlockType, NestedBlock } from '$app/interfaces/document';
import TextBlock from '$app/components/document/TextBlock'; import TextBlock from '$app/components/document/TextBlock';
import NodeChildren from '$app/components/document/Node/NodeChildren'; import NodeChildren from '$app/components/document/Node/NodeChildren';
import { IconButton, Popover } from '@mui/material'; import { IconButton } from '@mui/material';
import emojiData from '@emoji-mart/data'; import emojiData from '@emoji-mart/data';
import Picker from '@emoji-mart/react'; import Picker from '@emoji-mart/react';
import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks'; import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
import Popover from '@mui/material/Popover';
export default function CalloutBlock({ export default function CalloutBlock({
node, node,
@ -17,7 +18,7 @@ export default function CalloutBlock({
return ( return (
<div className={'my-1 flex rounded border border-solid border-main-accent bg-main-secondary p-4'}> <div className={'my-1 flex rounded border border-solid border-main-accent bg-main-secondary p-4'}>
<div className={'w-[1.5em]'}> <div className={'w-[1.5em]'} onMouseDown={(e) => e.stopPropagation()}>
<div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}> <div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
<IconButton <IconButton
aria-describedby={id} aria-describedby={id}
@ -27,8 +28,9 @@ export default function CalloutBlock({
{node.data.icon} {node.data.icon}
</IconButton> </IconButton>
<Popover <Popover
id={id} className={'border-none bg-transparent shadow-none'}
anchorEl={anchorEl} anchorEl={anchorEl}
disableAutoFocus={true}
open={open} open={open}
onClose={closeEmojiSelect} onClose={closeEmojiSelect}
anchorOrigin={{ anchorOrigin={{

View File

@ -8,6 +8,7 @@ interface CodeLeafProps extends RenderLeafProps {
underlined?: boolean; underlined?: boolean;
strikethrough?: boolean; strikethrough?: boolean;
prism_token?: string; prism_token?: string;
selectionHighlighted?: boolean;
}; };
} }
@ -27,8 +28,15 @@ export const CodeLeaf = (props: CodeLeafProps) => {
newChildren = <u>{newChildren}</u>; newChildren = <u>{newChildren}</u>;
} }
const className = [
'token',
leaf.prism_token && leaf.prism_token,
leaf.strikethrough && 'line-through',
leaf.selectionHighlighted && 'bg-main-secondary',
].filter(Boolean);
return ( return (
<span {...attributes} className={`token ${leaf.prism_token} ${leaf.strikethrough ? `line-through` : ''}`}> <span {...attributes} className={className.join(' ')}>
{newChildren} {newChildren}
</span> </span>
); );

View File

@ -1,7 +1,6 @@
import { BlockType, NestedBlock } from '$app/interfaces/document'; import { BlockType, NestedBlock } from '$app/interfaces/document';
import { useCodeBlock } from './CodeBlock.hooks'; import { useCodeBlock } from './CodeBlock.hooks';
import { Editable, Slate } from 'slate-react'; import { Editable, Slate } from 'slate-react';
import BlockHorizontalToolbar from '$app/components/document/BlockHorizontalToolbar';
import React from 'react'; import React from 'react';
import { CodeLeaf, CodeBlockElement } from './elements'; import { CodeLeaf, CodeBlockElement } from './elements';
import SelectLanguage from './SelectLanguage'; import SelectLanguage from './SelectLanguage';
@ -23,10 +22,13 @@ export default function CodeBlock({
<SelectLanguage id={id} language={language} /> <SelectLanguage id={id} language={language} />
</div> </div>
<Slate editor={editor} onChange={onChange} value={value}> <Slate editor={editor} onChange={onChange} value={value}>
<BlockHorizontalToolbar id={id} />
<Editable <Editable
{...rest} {...rest}
decorate={(entry) => decorateCodeFunc(entry, language)} decorate={(entry) => {
const codeRange = decorateCodeFunc(entry, language);
const range = rest.decorate(entry);
return [...range, ...codeRange];
}}
renderLeaf={CodeLeaf} renderLeaf={CodeLeaf}
renderElement={CodeBlockElement} renderElement={CodeBlockElement}
placeholder={placeholder || 'Please enter some text...'} placeholder={placeholder || 'Please enter some text...'}

View File

@ -1,13 +1,14 @@
import React, { useState } from 'react'; import React from 'react';
import BlockSideToolbar from '../BlockSideToolbar'; import BlockSideToolbar from '../BlockSideToolbar';
import BlockSelection from '../BlockSelection'; import BlockSelection from '../BlockSelection';
import TextActionMenu from '$app/components/document/TextActionMenu';
export default function Overlay({ container }: { container: HTMLDivElement }) { export default function Overlay({ container }: { container: HTMLDivElement }) {
const [isDragging, setDragging] = useState(false);
return ( return (
<> <>
{isDragging ? null : <BlockSideToolbar container={container} />} <BlockSideToolbar container={container} />
<BlockSelection onDragging={setDragging} container={container} /> <TextActionMenu container={container} />
<BlockSelection container={container} />
</> </>
); );
} }

View File

@ -19,18 +19,11 @@ function Root({ documentData }: { documentData: DocumentData }) {
} }
return ( return (
<div <>
id='appflowy-block-doc' <div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
className='h-[100%] overflow-hidden' <VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
onKeyDown={(e) => { </div>
// prevent backspace from going back </>
if (e.key === 'Backspace') {
e.stopPropagation();
}
}}
>
<VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
</div>
); );
} }

View File

@ -0,0 +1,43 @@
import { useEffect, useRef, useState } from 'react';
import { calcToolbarPosition } from '$app/utils/document/toolbar';
import { useAppSelector } from '$app/stores/store';
export function useMenuStyle(container: HTMLDivElement) {
const ref = useRef<HTMLDivElement | null>(null);
const range = useAppSelector((state) => state.documentRangeSelection);
const [scrollTop, setScrollTop] = useState(container.scrollTop);
useEffect(() => {
const el = ref.current;
if (!el) return;
const id = range.focus?.id;
if (!id) return;
const position = calcToolbarPosition(el);
if (!position) {
el.style.opacity = '0';
el.style.pointerEvents = 'none';
} else {
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
el.style.top = position.top;
el.style.left = position.left;
}
});
useEffect(() => {
const handleScroll = () => {
setScrollTop(container.scrollTop);
};
container.addEventListener('scroll', handleScroll);
return () => {
container.removeEventListener('scroll', handleScroll);
};
}, [container]);
return {
ref,
};
}

View File

@ -0,0 +1,46 @@
import { useMenuStyle } from './index.hooks';
import { useAppSelector } from '$app/stores/store';
import { isEqual } from '$app/utils/tool';
import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
const { ref } = useMenuStyle(container);
return (
<div
ref={ref}
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-200'
onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor
e.preventDefault();
e.stopPropagation();
}}
>
<TextActionMenuList />
</div>
);
};
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
const canShow = useAppSelector((state) => {
const range = state.documentRangeSelection;
if (range.isDragging) return false;
const anchorNode = range.anchor;
const focusNode = range.focus;
if (!anchorNode || !focusNode) return false;
const isSameLine = anchorNode.id === focusNode.id;
const isCollapsed = isEqual(anchorNode.selection.anchor, anchorNode.selection.focus);
return !(isSameLine && isCollapsed);
});
if (!canShow) return null;
return (
<div className='appflowy-block-toolbar-overlay pointer-events-none fixed inset-0 overflow-hidden'>
<TextActionComponent container={container} />
</div>
);
};
export default TextActionMenu;

View File

@ -0,0 +1,68 @@
import IconButton from '@mui/material/IconButton';
import FormatIcon from './FormatIcon';
import React, { useCallback, useEffect, useMemo, useContext } from 'react';
import { TextAction } from '$app/interfaces/document';
import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
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';
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const focusId = useAppSelector((state) => state.documentRangeSelection.focus?.id || '');
const { node: focusNode } = useSubscribeNode(focusId);
const [isActive, setIsActive] = React.useState(false);
const color = useMemo(() => (isActive ? '#00BCF0' : 'white'), [isActive]);
const formatTooltips: Record<string, string> = useMemo(
() => ({
[TextAction.Bold]: 'Bold',
[TextAction.Italic]: 'Italic',
[TextAction.Underline]: 'Underline',
[TextAction.Strikethrough]: 'Strike through',
[TextAction.Code]: 'Mark as Code',
}),
[]
);
const isFormatActive = useCallback(async () => {
if (!focusNode) return false;
const { payload: isActive } = await dispatch(getFormatActiveThunk(format));
return !!isActive;
}, [dispatch, format, focusNode]);
const toggleFormat = useCallback(
async (format: TextAction) => {
if (!controller) return;
await dispatch(
toggleFormatThunk({
format,
controller,
isActive,
})
);
},
[controller, dispatch, isActive]
);
useEffect(() => {
void (async () => {
const isActive = await isFormatActive();
setIsActive(isActive);
})();
}, [isFormatActive]);
return (
<MenuTooltip title={formatTooltips[format]}>
<IconButton size='small' sx={{ color }} onClick={() => toggleFormat(format)}>
<FormatIcon icon={icon} />
</IconButton>
</MenuTooltip>
);
};
export default FormatButton;

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material'; import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
import { iconSize } from '$app/constants/document/toolbar'; export const iconSize = { width: 18, height: 18 };
export default function FormatIcon({ icon }: { icon: string }) { export default function FormatIcon({ icon }: { icon: string }) {
switch (icon) { switch (icon) {

View File

@ -0,0 +1,20 @@
import React from 'react';
import Tooltip from '@mui/material/Tooltip';
function MenuTooltip({ title, children }: { children: JSX.Element; title?: string }) {
return (
<Tooltip
slotProps={{ tooltip: { style: { background: '#E0F8FF', borderRadius: 8 } } }}
title={
<div className='flex flex-col'>
<span className='text-base font-medium text-black'>{title}</span>
</div>
}
placement='top-start'
>
<div>{children}</div>
</Tooltip>
);
}
export default MenuTooltip;

View File

@ -0,0 +1,50 @@
import React, { useCallback } from 'react';
import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
import Button from '@mui/material/Button';
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
import MenuTooltip from './MenuTooltip';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
function TurnIntoSelect({ id }: { id: string }) {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const { node } = useSubscribeNode(id);
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const open = Boolean(anchorEl);
return (
<>
<MenuTooltip title='Turn into'>
<Button size={'small'} variant='text' onClick={handleClick}>
<div className='flex items-center text-main-accent'>
<span>{node.type}</span>
<ArrowDropDown />
</div>
</Button>
</MenuTooltip>
<TurnIntoPopover
id={id}
open={open}
onClose={handleClose}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'center',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'center',
horizontal: 'center',
}}
/>
</>
);
}
export default TurnIntoSelect;

View File

@ -0,0 +1,47 @@
import { useAppSelector } from '$app/stores/store';
import { useMemo } from 'react';
import {
blockConfig,
defaultTextActionProps,
multiLineTextActionGroups,
multiLineTextActionProps,
textActionGroups,
} from '$app/constants/document/config';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { TextAction } from '$app/interfaces/document';
export function useTextActionMenu() {
const range = useAppSelector((state) => state.documentRangeSelection);
const id = useMemo(() => {
return range.anchor?.id === range.focus?.id ? range.anchor?.id : undefined;
}, [range]);
const { node } = useSubscribeNode(id || '');
const items = useMemo(() => {
if (node) {
const config = blockConfig[node.type];
const { customItems, excludeItems } = {
...defaultTextActionProps,
...config.textActionMenuProps,
};
return customItems?.filter((item) => !excludeItems?.includes(item)) || [];
} else {
return multiLineTextActionProps.customItems || [];
}
}, [node]);
// the groups have default items, so we need to filter the items if this node has excluded items
const groupItems: TextAction[][] = useMemo(() => {
const groups = node ? textActionGroups : multiLineTextActionGroups;
return groups.map((group) => {
return group.filter((item) => items.includes(item));
});
}, [JSON.stringify(items), node]);
return {
groupItems,
id,
};
}

View File

@ -0,0 +1,39 @@
import { TextAction } from '$app/interfaces/document';
import React, { useCallback } from 'react';
import TurnIntoSelect from '$app/components/document/TextActionMenu/menu/TurnIntoSelect';
import FormatButton from '$app/components/document/TextActionMenu/menu/FormatButton';
import { useTextActionMenu } from '$app/components/document/TextActionMenu/menu/index.hooks';
function TextActionMenuList() {
const { groupItems, id } = useTextActionMenu();
const renderNode = useCallback((action: TextAction, id?: string) => {
switch (action) {
case TextAction.Turn:
return id ? <TurnIntoSelect id={id} /> : null;
case TextAction.Bold:
case TextAction.Italic:
case TextAction.Underline:
case TextAction.Strikethrough:
case TextAction.Code:
return <FormatButton format={action} icon={action} />;
default:
return null;
}
}, []);
return (
<div className={'flex px-1'}>
{groupItems.map((group, i: number) => (
<div className={'flex border-r border-solid border-shade-2 px-1 last:border-r-0'} key={i}>
{group.map((item) => (
<div key={item} className={'flex items-center'}>
{renderNode(item, id)}
</div>
))}
</div>
))}
</div>
);
}
export default TextActionMenuList;

View File

@ -10,20 +10,12 @@ interface LeafProps extends RenderLeafProps {
selectionHighlighted?: boolean; selectionHighlighted?: boolean;
}; };
} }
const Leaf = ({ const Leaf = ({ attributes, children, leaf }: LeafProps) => {
attributes,
children,
leaf,
}: LeafProps) => {
let newChildren = children; let newChildren = children;
if (leaf.bold) { if (leaf.bold) {
newChildren = <strong>{children}</strong>; newChildren = <strong>{children}</strong>;
} }
if (leaf.code) {
newChildren = <code className='rounded-sm bg-[#F2FCFF] p-1'>{newChildren}</code>;
}
if (leaf.italic) { if (leaf.italic) {
newChildren = <em>{newChildren}</em>; newChildren = <em>{newChildren}</em>;
} }
@ -32,16 +24,14 @@ const Leaf = ({
newChildren = <u>{newChildren}</u>; newChildren = <u>{newChildren}</u>;
} }
let className = ""; const className = [
if (leaf.strikethrough) { leaf.strikethrough && 'line-through',
className += "line-through"; leaf.selectionHighlighted && 'bg-main-secondary',
} leaf.code && 'bg-main-selector',
if (leaf.selectionHighlighted) { ].filter(Boolean);
className += " bg-main-secondary";
}
return ( return (
<span {...attributes} className={className}> <span {...attributes} className={className.join(' ')}>
{newChildren} {newChildren}
</span> </span>
); );

View File

@ -2,20 +2,23 @@ import { Editor } from 'slate';
import { useTurnIntoBlock } from './TurnIntoEvents.hooks'; import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
import { useCallback, useContext, useMemo } from 'react'; import { useCallback, useContext, useMemo } from 'react';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block'; import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import { triggerHotkey } from '$app/utils/document/blocks/text/hotkey';
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document'; import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import isHotkey from 'is-hotkey'; import isHotkey from 'is-hotkey';
import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions'; import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks'; import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
export function useTextBlockKeyEvent(id: string, editor: ReactEditor) { export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
const controller = useContext(DocumentControllerContext); const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const defaultTextInputEvents = useDefaultTextInputEvents(id); const defaultTextInputEvents = useDefaultTextInputEvents(id);
const isFocusCurrentNode = useAppSelector((state) => {
const { anchor, focus } = state.documentRangeSelection;
if (!anchor || !focus) return false;
return anchor.id === id && focus.id === id;
});
const { turnIntoBlockEvents } = useTurnIntoBlock(id); const { turnIntoBlockEvents } = useTurnIntoBlock(id);
@ -84,18 +87,20 @@ export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
const onKeyDown = useCallback( const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isFocusCurrentNode) {
event.preventDefault();
return;
}
event.stopPropagation();
// This is list of key events that can be handled by TextBlock // This is list of key events that can be handled by TextBlock
const keyEvents = [...events, ...turnIntoBlockEvents]; const keyEvents = [...events, ...turnIntoBlockEvents];
const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor)); const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
if (matchKeys.length === 0) {
triggerHotkey(event, editor);
return;
}
matchKeys.forEach((matchKey) => matchKey.handler(event, editor)); matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
}, },
[editor, events, turnIntoBlockEvents] [editor, events, turnIntoBlockEvents, isFocusCurrentNode]
); );
return { return {

View File

@ -1,8 +1,7 @@
import { Slate, Editable } from 'slate-react'; import { Slate, Editable } from 'slate-react';
import Leaf from './Leaf'; import Leaf from './Leaf';
import { useTextBlock } from './TextBlock.hooks'; import { useTextBlock } from './TextBlock.hooks';
import BlockHorizontalToolbar from '../BlockHorizontalToolbar'; import React from 'react';
import React, { useEffect } from 'react';
import { NestedBlock } from '$app/interfaces/document'; import { NestedBlock } from '$app/interfaces/document';
import NodeChildren from '$app/components/document/Node/NodeChildren'; import NodeChildren from '$app/components/document/Node/NodeChildren';
@ -23,7 +22,6 @@ function TextBlock({
<> <>
<div className={`px-1 py-[2px] ${className}`}> <div className={`px-1 py-[2px] ${className}`}>
<Slate editor={editor} onChange={onChange} value={value}> <Slate editor={editor} onChange={onChange} value={value}>
{/*<BlockHorizontalToolbar id={node.id} />*/}
<Editable <Editable
{...rest} {...rest}
renderLeaf={(leafProps) => <Leaf {...leafProps} />} renderLeaf={(leafProps) => <Leaf {...leafProps} />}

View File

@ -2,7 +2,7 @@ import { useAppSelector } from '@/appflowy_app/stores/store';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document'; import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document';
import { nodeInRange } from '$app/utils/document/blocks/common'; import { nodeInRange } from '$app/utils/document/blocks/common';
import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta'; import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
/** /**
* Subscribe node information * Subscribe node information
@ -18,7 +18,7 @@ export function useSubscribeNode(id: string) {
}); });
const isSelected = useAppSelector<boolean>((state) => { const isSelected = useAppSelector<boolean>((state) => {
return state.documentRectSelection.includes(id) || false; return state.documentRectSelection.selection.includes(id) || false;
}); });
// Memoize the node and its children // Memoize the node and its children
@ -50,6 +50,7 @@ export function useSubscribeRangeSelection(id: string) {
if (range.focus?.id === id) { if (range.focus?.id === id) {
return range.focus.selection; return range.focus.selection;
} }
return getAmendInRangeNodeSelection(id, range, state.document); return getAmendInRangeNodeSelection(id, range, state.document);
}); });
@ -60,17 +61,17 @@ export function useSubscribeRangeSelection(id: string) {
} }
function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) { function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) {
if (!range.anchor || !range.focus || range.anchor.id === range.focus.id) { if (!range.anchor || !range.focus || range.anchor.id === range.focus.id || range.isForward === undefined) {
return null; return null;
} }
const isForward = selectionIsForward(range.anchor.selection);
const isNodeInRange = nodeInRange( const isNodeInRange = nodeInRange(
id, id,
{ {
startId: range.anchor.id, startId: range.anchor.id,
endId: range.focus.id, endId: range.focus.id,
}, },
isForward, range.isForward,
document document
); );

View File

@ -11,7 +11,7 @@ import {
canHandleUpKey, canHandleUpKey,
} from '$app/utils/document/blocks/text/hotkey'; } from '$app/utils/document/blocks/text/hotkey';
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document'; import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import { ReactEditor } from "slate-react"; import { ReactEditor } from 'slate-react';
export function useDefaultTextInputEvents(id: string) { export function useDefaultTextInputEvents(id: string) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -81,11 +81,11 @@ export function useDefaultTextInputEvents(id: string) {
triggerEventKey: keyBoardEventKeyMap.Backspace, triggerEventKey: keyBoardEventKeyMap.Backspace,
canHandle: canHandleBackspaceKey, canHandle: canHandleBackspaceKey,
handler: (...args: TextBlockKeyEventHandlerParams) => { handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args; const [e, editor] = args;
e.preventDefault(); e.preventDefault();
void (async () => { void (async () => {
if (!controller) return; if (!controller) return;
await dispatch(backspaceNodeThunk({ id, controller })); await dispatch(backspaceNodeThunk({ id, controller, editor }));
})(); })();
}, },
}, },

View File

@ -1,4 +1,4 @@
import { createEditor, Descendant, Editor } from 'slate'; import { createEditor, Descendant, Editor, Transforms } from 'slate';
import { withReact } from 'slate-react'; import { withReact } from 'slate-react';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
@ -8,12 +8,12 @@ import { useAppDispatch } from '$app/stores/store';
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update'; import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common'; import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common';
import { isSameDelta } from '$app/utils/document/blocks/text/delta'; import { isSameDelta } from '$app/utils/document/blocks/text/delta';
import { debounce } from '$app/utils/tool';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks'; import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks';
export function useTextInput(id: string) { export function useTextInput(id: string) {
const { node } = useSubscribeNode(id); const { node } = useSubscribeNode(id);
const [editor] = useState(() => withReact(createEditor())); const [editor] = useState(() => withReact(createEditor()));
const isComposition = useRef(false); const isComposition = useRef(false);
const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor); const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor);
@ -24,16 +24,15 @@ export function useTextInput(id: string) {
} }
return node.data.delta; return node.data.delta;
}, [node]); }, [node]);
const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
const { sync, receive } = useUpdateDelta(id, editor); const { sync, receive } = useUpdateDelta(id, editor);
const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
// Update the editor's value when the node's delta changes. // Update the editor's value when the node's delta changes.
useEffect(() => { useEffect(() => {
// If composition is in progress, do nothing. // If composition is in progress, do nothing.
if (isComposition.current) return; if (isComposition.current) return;
receive(delta); receive(delta, setValue);
}, [delta, receive]); }, [delta, receive]);
// Update the node's delta when the editor's value changes. // Update the node's delta when the editor's value changes.
@ -88,33 +87,30 @@ function useUpdateDelta(id: string, editor: Editor) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const penddingRef = useRef(false); const penddingRef = useRef(false);
// when user input, update the node's delta after 200ms const update = useCallback(() => {
const debounceUpdate = useMemo(() => { if (!controller) return;
return debounce(() => { const delta = slateValueToDelta(editor.children);
if (!controller) return; void (async () => {
const delta = slateValueToDelta(editor.children); await dispatch(
void (async () => { updateNodeDeltaThunk({
await dispatch( id,
updateNodeDeltaThunk({ delta,
id, controller,
delta, })
controller, );
}) // reset pendding flag
); penddingRef.current = false;
// reset pendding flag })();
penddingRef.current = false;
})();
}, 200);
}, [controller, dispatch, editor, id]); }, [controller, dispatch, editor, id]);
const sync = useCallback(() => { const sync = useCallback(() => {
// set pendding flag // set pendding flag
penddingRef.current = true; penddingRef.current = true;
debounceUpdate(); update();
}, [debounceUpdate]); }, [update]);
const receive = useCallback( const receive = useCallback(
(delta: TextDelta[]) => { (delta: TextDelta[], setValue: (children: Descendant[]) => void) => {
// if pendding, do nothing // if pendding, do nothing
if (penddingRef.current) return; if (penddingRef.current) return;
@ -123,18 +119,14 @@ function useUpdateDelta(id: string, editor: Editor) {
const isSame = isSameDelta(delta, localDelta); const isSame = isSameDelta(delta, localDelta);
if (isSame) return; if (isSame) return;
Transforms.deselect(editor);
const slateValue = deltaToSlateValue(delta); const slateValue = deltaToSlateValue(delta);
editor.children = slateValue; editor.children = slateValue;
setValue(slateValue);
}, },
[editor] [editor]
); );
useEffect(() => {
return () => {
debounceUpdate.cancel();
};
});
return { return {
sync, sync,
receive, receive,

View File

@ -1,14 +1,14 @@
import { MouseEventHandler, useCallback, useEffect } from 'react'; import { MouseEvent, useCallback, useEffect, useRef } from 'react';
import { BaseRange, Editor, Node, Path, Range, Transforms } from 'slate'; import { BaseRange, Editor, Node, Path, Range, Transforms } from 'slate';
import { EditableProps } from 'slate-react/dist/components/editable'; import { EditableProps } from 'slate-react/dist/components/editable';
import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks'; import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { rangeSelectionActions } from '$app_reducers/document/slice';
import { TextSelection } from '$app/interfaces/document'; import { TextSelection } from '$app/interfaces/document';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection'; import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
import { getCollapsedRange } from '$app/utils/document/blocks/common'; import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
import { getEditorEndPoint, selectionIsForward } from '$app/utils/document/blocks/text/delta'; import { slateValueToDelta } from '$app/utils/document/blocks/common';
import { isEqual } from '$app/utils/tool';
export function useTextSelections(id: string, editor: ReactEditor) { export function useTextSelections(id: string, editor: ReactEditor) {
const { rangeRef, currentSelection } = useSubscribeRangeSelection(id); const { rangeRef, currentSelection } = useSubscribeRangeSelection(id);
@ -16,15 +16,21 @@ export function useTextSelections(id: string, editor: ReactEditor) {
useEffect(() => { useEffect(() => {
if (!rangeRef.current) return; if (!rangeRef.current) return;
const { isDragging, focus, anchor } = rangeRef.current; if (!currentSelection) {
if (isDragging || anchor?.id !== focus?.id || !currentSelection || !Range.isCollapsed(currentSelection as BaseRange)) ReactEditor.deselect(editor);
ReactEditor.blur(editor);
return; return;
}
const { isDragging, focus } = rangeRef.current;
if (isDragging || focus?.id !== id) return;
if (!ReactEditor.isFocused(editor)) { if (!ReactEditor.isFocused(editor)) {
ReactEditor.focus(editor); ReactEditor.focus(editor);
} }
Transforms.select(editor, currentSelection); if (!isEqual(editor.selection, currentSelection)) {
}, [currentSelection, editor, rangeRef]); Transforms.select(editor, currentSelection);
}
}, [currentSelection, editor, id, rangeRef]);
const decorate: EditableProps['decorate'] = useCallback( const decorate: EditableProps['decorate'] = useCallback(
(entry: [Node, Path]) => { (entry: [Node, Path]) => {
@ -48,48 +54,6 @@ export function useTextSelections(id: string, editor: ReactEditor) {
[editor, currentSelection] [editor, currentSelection]
); );
const onMouseDown: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
const range = getCollapsedRange(id, editor.selection as TextSelection);
dispatch(
rangeSelectionActions.setRange({
...range,
isDragging: true,
})
);
},
[dispatch, editor, id]
);
const onMouseMove: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
if (!rangeRef.current) return;
const { isDragging, anchor } = rangeRef.current;
if (!isDragging || !anchor || ReactEditor.isFocused(editor)) return;
const isForward = selectionIsForward(anchor.selection);
if (!isForward) {
Transforms.select(editor, getEditorEndPoint(editor));
}
ReactEditor.focus(editor);
},
[editor, rangeRef]
);
const onMouseUp: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
if (!rangeRef.current) return;
const { isDragging } = rangeRef.current;
if (!isDragging) return;
dispatch(
rangeSelectionActions.setRange({
isDragging: false,
})
);
},
[dispatch, rangeRef]
);
const setLastActiveSelection = useCallback( const setLastActiveSelection = useCallback(
(lastActiveSelection: Range) => { (lastActiveSelection: Range) => {
const selection = lastActiveSelection as TextSelection; const selection = lastActiveSelection as TextSelection;
@ -102,12 +66,33 @@ export function useTextSelections(id: string, editor: ReactEditor) {
ReactEditor.deselect(editor); ReactEditor.deselect(editor);
}, [editor]); }, [editor]);
const onMouseMove = useCallback(
(e: MouseEvent) => {
if (!rangeRef.current) return;
const { isDragging, isForward, anchor } = rangeRef.current;
if (!isDragging || !anchor) return;
if (ReactEditor.isFocused(editor)) {
return;
}
if (anchor.id === id) {
Transforms.select(editor, anchor.selection);
} else if (!isForward) {
const endSelection = getNodeEndSelection(slateValueToDelta(editor.children));
Transforms.select(editor, {
anchor: endSelection.anchor,
focus: editor.selection?.focus || endSelection.focus,
});
}
ReactEditor.focus(editor);
},
[editor, id, rangeRef]
);
return { return {
decorate, decorate,
onMouseDown,
onMouseMove,
onMouseUp,
onBlur, onBlur,
onMouseMove,
setLastActiveSelection, setLastActiveSelection,
}; };
} }

View File

@ -0,0 +1,49 @@
import { useAppDispatch } from '$app/stores/store';
import { useCallback, useContext } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document';
import { blockConfig } from '$app/constants/document/config';
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const turnIntoBlock = useCallback(
async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => {
if (!controller || isSelected) {
onClose?.();
return;
}
const config = blockConfig[type];
await dispatch(
turnToBlockThunk({
id: node.id,
controller,
type,
data: {
...config.defaultData,
delta: node?.data?.delta || [],
...data,
},
})
);
onClose?.();
},
[onClose, controller, dispatch, node]
);
const turnIntoHeading = useCallback(
(level: number, isSelected: boolean) => {
turnIntoBlock(BlockType.HeadingBlock, isSelected, { level });
},
[turnIntoBlock]
);
return {
turnIntoBlock,
turnIntoHeading,
};
}

View File

@ -0,0 +1,138 @@
import React, { useMemo } from 'react';
import { BlockType } from '$app/interfaces/document';
import {
ArrowRight,
Check,
DataObject,
FormatListBulleted,
FormatListNumbered,
FormatQuote,
Lightbulb,
TextFields,
Title,
Functions,
} from '@mui/icons-material';
import Popover, { PopoverProps } from '@mui/material/Popover';
import { ListItemIcon, ListItemText, MenuItem } from '@mui/material';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useTurnInto } from '$app/components/document/_shared/TurnInto/TurnInto.hooks';
const TurnIntoPopover = ({
id,
onClose,
...props
}: {
id: string;
onClose?: () => void;
} & PopoverProps) => {
const { node } = useSubscribeNode(id);
const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose });
const options: {
type: BlockType;
title: string;
icon: React.ReactNode;
selected?: boolean;
onClick?: (type: BlockType, isSelected: boolean) => void;
}[] = useMemo(
() => [
{
type: BlockType.TextBlock,
title: 'Text',
icon: <TextFields />,
},
{
type: BlockType.HeadingBlock,
title: 'Heading 1',
icon: <Title />,
selected: node?.data?.level === 1,
onClick: (type: BlockType, isSelected: boolean) => {
turnIntoHeading(1, isSelected);
},
},
{
type: BlockType.HeadingBlock,
title: 'Heading 2',
icon: <Title />,
selected: node?.data?.level === 2,
onClick: (type: BlockType, isSelected: boolean) => {
turnIntoHeading(2, isSelected);
},
},
{
type: BlockType.HeadingBlock,
title: 'Heading 3',
icon: <Title />,
selected: node?.data?.level === 3,
onClick: (type: BlockType, isSelected: boolean) => {
turnIntoHeading(3, isSelected);
},
},
{
type: BlockType.TodoListBlock,
title: 'To-do list',
icon: <Check />,
},
{
type: BlockType.BulletedListBlock,
title: 'Bulleted list',
icon: <FormatListBulleted />,
},
{
type: BlockType.NumberedListBlock,
title: 'Numbered list',
icon: <FormatListNumbered />,
},
{
type: BlockType.ToggleListBlock,
title: 'Toggle list',
icon: <ArrowRight />,
},
{
type: BlockType.CodeBlock,
title: 'Code',
icon: <DataObject />,
},
{
type: BlockType.QuoteBlock,
title: 'Quote',
icon: <FormatQuote />,
},
{
type: BlockType.CalloutBlock,
title: 'Callout',
icon: <Lightbulb />,
},
// {
// type: BlockType.EquationBlock,
// title: 'Block Equation',
// icon: <Functions />,
// },
],
[node?.data?.level, turnIntoHeading]
);
return (
<Popover disableAutoFocus={true} onClose={onClose} {...props}>
{options.map((option) => {
const isSelected = option.type === node.type && option.selected !== false;
return (
<MenuItem
className={'w-[100%]'}
key={option.title}
onClick={() =>
option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected)
}
>
<ListItemIcon>{option.icon}</ListItemIcon>
<ListItemText>{option.title}</ListItemText>
<ListItemIcon>{isSelected ? <Check /> : null}</ListItemIcon>
</MenuItem>
);
})}
</Popover>
);
};
export default TurnIntoPopover;

View File

@ -1,44 +1,9 @@
import { BlockData, BlockType } from '$app/interfaces/document'; import { BlockConfig, BlockType, SplitRelationship, TextAction, TextActionMenuProps } from '$app/interfaces/document';
export enum SplitRelationship {
NextSibling,
FirstChild,
}
/** /**
* If the block type is not in the config, it will be thrown an error in development env * If the block type is not in the config, it will be thrown an error in development env
*/ */
export const blockConfig: Record< export const blockConfig: Record<string, BlockConfig> = {
string,
{
/**
* Whether the block can have children
*/
canAddChild: boolean;
/**
* The regexps that will be used to match the markdown flag
*/
markdownRegexps?: RegExp[];
/**
* The default data of the block
*/
defaultData?: BlockData<any>;
/**
* The props that will be passed to the text split function
*/
splitProps?: {
/**
* The relationship between the next line block and the current block
*/
nextLineRelationShip: SplitRelationship;
/**
* The type of the next line block
*/
nextLineBlockType: BlockType;
};
}
> = {
[BlockType.TextBlock]: { [BlockType.TextBlock]: {
canAddChild: true, canAddChild: true,
defaultData: { defaultData: {
@ -169,5 +134,49 @@ export const blockConfig: Record<
* ``` * ```
*/ */
markdownRegexps: [/^(```)$/], markdownRegexps: [/^(```)$/],
textActionMenuProps: {
excludeItems: [TextAction.Code],
},
}, },
}; };
export const defaultTextActionProps: TextActionMenuProps = {
customItems: [
TextAction.Turn,
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
TextAction.Strikethrough,
TextAction.Code,
TextAction.Equation,
],
excludeItems: [],
};
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 textActionGroups = [
[TextAction.Turn],
[
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
TextAction.Strikethrough,
TextAction.Code,
TextAction.Equation,
],
];

View File

@ -1,25 +0,0 @@
export const iconSize = { width: 18, height: 18 };
export const command: Record<string, { title: string; key: string }> = {
bold: {
title: 'Bold',
key: '⌘ + B',
},
underlined: {
title: 'Underlined',
key: '⌘ + U',
},
italic: {
title: 'Italic',
key: '⌘ + I',
},
code: {
title: 'Mark as code',
key: '⌘ + E',
},
strikethrough: {
title: 'Strike through',
key: '⌘ + Shift + S or ⌘ + Shift + X',
},
};

View File

@ -1,6 +1,6 @@
import { Editor } from 'slate'; import { Editor } from 'slate';
import { RegionGrid } from '$app/utils/region_grid'; import { RegionGrid } from '$app/utils/region_grid';
import { ReactEditor } from "slate-react"; import { ReactEditor } from 'slate-react';
export enum BlockType { export enum BlockType {
PageBlock = 'page', PageBlock = 'page',
@ -11,6 +11,7 @@ export enum BlockType {
NumberedListBlock = 'numbered_list', NumberedListBlock = 'numbered_list',
ToggleListBlock = 'toggle_list', ToggleListBlock = 'toggle_list',
CodeBlock = 'code', CodeBlock = 'code',
EquationBlock = 'math_equation',
EmbedBlock = 'embed', EmbedBlock = 'embed',
QuoteBlock = 'quote', QuoteBlock = 'quote',
CalloutBlock = 'callout', CalloutBlock = 'callout',
@ -87,7 +88,7 @@ export interface NestedBlock<Type = any> {
} }
export interface TextDelta { export interface TextDelta {
insert: string; insert: string;
attributes?: Record<string, string | boolean>; attributes?: Record<string, string | boolean | undefined>;
} }
export enum BlockActionType { export enum BlockActionType {
@ -131,16 +132,21 @@ export interface DocumentState {
children: Record<string, string[]>; children: Record<string, string[]>;
} }
export interface RectSelectionState {
selection: string[];
isDragging: boolean;
}
export interface RangeSelectionState { export interface RangeSelectionState {
isDragging?: boolean, anchor?: PointState;
anchor?: PointState, focus?: PointState;
focus?: PointState, isForward?: boolean;
isDragging: boolean;
selection: string[];
} }
export interface PointState { export interface PointState {
id: string, id: string;
selection: TextSelection selection: TextSelection;
} }
export enum ChangeType { export enum ChangeType {
@ -161,3 +167,62 @@ export interface BlockPBValue {
} }
export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, ReactEditor & Editor]; export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, ReactEditor & Editor];
export enum SplitRelationship {
NextSibling,
FirstChild,
}
export enum TextAction {
Turn = 'turn',
Bold = 'bold',
Italic = 'italic',
Underline = 'underlined',
Strikethrough = 'strikethrough',
Code = 'code',
Equation = 'equation',
}
export interface TextActionMenuProps {
/**
* The custom items that will be covered in the default items
*/
customItems?: TextAction[];
/**
* The items that will be excluded from the default items
*/
excludeItems?: TextAction[];
}
export interface BlockConfig {
/**
* Whether the block can have children
*/
canAddChild: boolean;
/**
* The regexps that will be used to match the markdown flag
*/
markdownRegexps?: RegExp[];
/**
* The default data of the block
*/
defaultData?: BlockData<any>;
/**
* The props that will be passed to the text split function
*/
splitProps?: {
/**
* The relationship between the next line block and the current block
*/
nextLineRelationShip: SplitRelationship;
/**
* The type of the next line block
*/
nextLineBlockType: BlockType;
};
/**
* The props that will be passed to the text action menu
*/
textActionMenuProps?: TextActionMenuProps;
}

View File

@ -4,6 +4,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { outdentNodeThunk } from './outdent'; import { outdentNodeThunk } from './outdent';
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to'; import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/blocks/text/merge'; import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/blocks/text/merge';
import { ReactEditor } from 'slate-react';
/** /**
* 1. If current node is not text block, turn it to text block * 1. If current node is not text block, turn it to text block
@ -14,8 +15,8 @@ import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/block
*/ */
export const backspaceNodeThunk = createAsyncThunk( export const backspaceNodeThunk = createAsyncThunk(
'document/backspaceNode', 'document/backspaceNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController; editor: ReactEditor }, thunkAPI) => {
const { id, controller } = payload; const { id, controller, editor } = payload;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id]; const node = state.nodes[id];
@ -33,6 +34,7 @@ export const backspaceNodeThunk = createAsyncThunk(
// merge to previous line when parent is root // merge to previous line when parent is root
if (parentIsRoot || nextNodeId) { if (parentIsRoot || nextNodeId) {
// merge to previous line // merge to previous line
ReactEditor.deselect(editor);
await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true })); await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true }));
return; return;
} }

View File

@ -1,11 +1,11 @@
import { DocumentState } from '$app/interfaces/document'; import { DocumentState, SplitRelationship } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { setCursorBeforeThunk } from '../../cursor'; import { setCursorBeforeThunk } from '../../cursor';
import { newBlock } from '$app/utils/document/blocks/common'; import { newBlock } from '$app/utils/document/blocks/common';
import { blockConfig, SplitRelationship } from '$app/constants/document/config'; import { blockConfig } from '$app/constants/document/config';
import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta'; import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta';
import { ReactEditor } from "slate-react"; import { ReactEditor } from 'slate-react';
export const splitNodeThunk = createAsyncThunk( export const splitNodeThunk = createAsyncThunk(
'document/splitNode', 'document/splitNode',

View File

@ -0,0 +1,89 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from '$app/stores/store';
import { TextAction, TextDelta, TextSelection } from '$app/interfaces/document';
import { getAfterRangeDelta, getBeforeRangeDelta, getRangeDelta } from '$app/utils/document/blocks/text/delta';
import { DocumentController } from '$app/stores/effects/document/document_controller';
export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
'document/getFormatActive',
async (format, thunkAPI) => {
const { getState } = thunkAPI;
const state = getState() as RootState;
const { document } = state;
const { selection, anchor, focus } = state.documentRangeSelection;
const match = (delta: TextDelta[], format: TextAction) => {
return delta.every((op) => op.attributes?.[format] === true);
};
return selection.every((id) => {
const node = document.nodes[id];
let delta = node.data?.delta as TextDelta[];
if (!delta) return false;
if (id === anchor?.id) {
delta = getRangeDelta(delta, anchor.selection);
} else if (id === focus?.id) {
delta = getRangeDelta(delta, focus.selection);
}
return match(delta, format);
});
}
);
export const toggleFormatThunk = createAsyncThunk(
'document/toggleFormat',
async (payload: { format: TextAction; controller: DocumentController; isActive: boolean }, thunkAPI) => {
const { getState } = thunkAPI;
const { format, controller, isActive } = payload;
const state = getState() as RootState;
const { document } = state;
const { selection, anchor, focus } = state.documentRangeSelection;
const ids = Array.from(new Set(selection));
const toggle = (delta: TextDelta[], format: TextAction) => {
return delta.map((op) => {
const attributes = {
...op.attributes,
[format]: isActive ? undefined : true,
};
return {
insert: op.insert,
attributes: attributes,
};
});
};
const splitDelta = (delta: TextDelta[], selection: TextSelection) => {
const before = getBeforeRangeDelta(delta, selection);
const after = getAfterRangeDelta(delta, selection);
let middle = getRangeDelta(delta, selection);
middle = toggle(middle, format);
return [...before, ...middle, ...after];
};
const actions = ids.map((id) => {
const node = document.nodes[id];
let delta = node.data?.delta as TextDelta[];
if (!delta) return controller.getUpdateAction(node);
if (id === anchor?.id) {
delta = splitDelta(delta, anchor.selection);
} else if (id === focus?.id) {
delta = splitDelta(delta, focus.selection);
} else {
delta = toggle(delta, format);
}
return controller.getUpdateAction({
...node,
data: {
...node.data,
delta,
},
});
});
await controller.applyActions(actions);
}
);

View File

@ -1,8 +1,10 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentState, RangeSelectionState, TextSelection } from '$app/interfaces/document'; import { DocumentState, TextSelection } from '$app/interfaces/document';
import { rangeSelectionActions } from '$app_reducers/document/slice'; import { rangeSelectionActions } from '$app_reducers/document/slice';
import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta'; import { getNodeBeginSelection, getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
import { isEqual } from '$app/utils/tool'; import { isEqual } from '$app/utils/tool';
import { RootState } from '$app/stores/store';
import { getNodesInRange } from '$app/utils/document/blocks/common';
const amendAnchorNodeThunk = createAsyncThunk( const amendAnchorNodeThunk = createAsyncThunk(
'document/amendAnchorNode', 'document/amendAnchorNode',
@ -15,22 +17,18 @@ const amendAnchorNodeThunk = createAsyncThunk(
const { id } = payload; const { id } = payload;
const { getState, dispatch } = thunkAPI; const { getState, dispatch } = thunkAPI;
const nodes = (getState() as { document: DocumentState }).document.nodes; const nodes = (getState() as { document: DocumentState }).document.nodes;
const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection;
const { anchor: anchorNode, isDragging, focus: focusNode } = range; const state = getState() as RootState;
const { isDragging, isForward, ...range } = state.documentRangeSelection;
const { anchor: anchorNode, focus: focusNode } = range;
if (!isDragging || !anchorNode || anchorNode.id !== id) return; if (!isDragging || !anchorNode || anchorNode.id !== id) return;
const isCollapsed = focusNode?.id === id && anchorNode?.id === id; const isCollapsed = focusNode?.id === id && anchorNode?.id === id;
if (isCollapsed) return; if (isCollapsed) return;
const selection = anchorNode.selection; const selection = anchorNode.selection;
const isForward = selectionIsForward(selection);
const node = nodes[id]; const node = nodes[id];
const focus = isForward const focus = isForward ? getNodeEndSelection(node.data.delta).anchor : getNodeBeginSelection().anchor;
? getNodeEndSelection(node.data.delta).anchor
: {
path: [0, 0],
offset: 0,
};
if (isEqual(focus, selection.focus)) return; if (isEqual(focus, selection.focus)) return;
const newSelection = { const newSelection = {
anchor: selection.anchor, anchor: selection.anchor,
@ -58,29 +56,64 @@ export const syncRangeSelectionThunk = createAsyncThunk(
thunkAPI thunkAPI
) => { ) => {
const { getState, dispatch } = thunkAPI; const { getState, dispatch } = thunkAPI;
const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection; const state = getState() as RootState;
const range = state.documentRangeSelection;
const isDragging = range.isDragging;
const { id, selection } = payload; const { id, selection } = payload;
const updateRange = { const updateRange = {
focus: { focus: {
id, id,
selection, selection,
}, },
}; };
const isAnchor = range.anchor?.id === id;
if (isAnchor) { if (!isDragging && range.anchor?.id === id) {
Object.assign(updateRange, { Object.assign(updateRange, {
anchor: { anchor: {
id, id,
selection, selection: { ...selection },
},
});
dispatch(rangeSelectionActions.setRange(updateRange));
return;
}
if (!range.anchor || range.anchor.id === id) {
Object.assign(updateRange, {
anchor: {
id,
selection: {
anchor: !range.anchor ? selection.anchor : range.anchor.selection.anchor,
focus: selection.focus,
},
}, },
}); });
} }
dispatch(rangeSelectionActions.setRange(updateRange)); dispatch(rangeSelectionActions.setRange(updateRange));
const anchorId = range.anchor?.id; const anchorId = range.anchor?.id;
if (!isAnchor && anchorId) { // more than one node is selected
if (anchorId && anchorId !== id) {
dispatch(amendAnchorNodeThunk({ id: anchorId })); dispatch(amendAnchorNodeThunk({ id: anchorId }));
} }
} }
); );
export const setRangeSelectionThunk = createAsyncThunk('document/setRangeSelection', async (payload, thunkAPI) => {
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
const { anchor, focus, isForward } = state.documentRangeSelection;
const document = state.document;
if (!anchor || !focus || isForward === undefined) return;
const rangeIds = getNodesInRange(
{
startId: anchor.id,
endId: focus.id,
},
isForward,
document
);
dispatch(rangeSelectionActions.setSelection(rangeIds));
});

View File

@ -1,16 +1,29 @@
import { DocumentState, Node, RangeSelectionState } from '@/appflowy_app/interfaces/document'; import {
DocumentState,
Node,
PointState,
RangeSelectionState,
RectSelectionState,
} from '@/appflowy_app/interfaces/document';
import { BlockEventPayloadPB } from '@/services/backend'; import { BlockEventPayloadPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { parseValue, matchChange } from '$app/utils/document/subscribe'; import { parseValue, matchChange } from '$app/utils/document/subscribe';
import { getNodesInRange } from '$app/utils/document/blocks/common';
const initialState: DocumentState = { const initialState: DocumentState = {
nodes: {}, nodes: {},
children: {}, children: {},
}; };
const rectSelectionInitialState: string[] = []; const rectSelectionInitialState: RectSelectionState = {
selection: [],
isDragging: false,
};
const rangeSelectionInitialState: RangeSelectionState = {}; const rangeSelectionInitialState: RangeSelectionState = {
isDragging: false,
selection: [],
};
export const documentSlice = createSlice({ export const documentSlice = createSlice({
name: 'document', name: 'document',
@ -35,7 +48,6 @@ export const documentSlice = createSlice({
state.nodes = nodes; state.nodes = nodes;
state.children = children; state.children = children;
}, },
/** /**
This function listens for changes in the data layer triggered by the data API, This function listens for changes in the data layer triggered by the data API,
and updates the UI state accordingly. and updates the UI state accordingly.
@ -67,14 +79,18 @@ export const rectSelectionSlice = createSlice({
reducers: { reducers: {
// update block selections // update block selections
updateSelections: (state, action: PayloadAction<string[]>) => { updateSelections: (state, action: PayloadAction<string[]>) => {
return action.payload; state.selection = action.payload;
}, },
// set block selected // set block selected
setSelectionById: (state, action: PayloadAction<string>) => { setSelectionById: (state, action: PayloadAction<string>) => {
const id = action.payload; const id = action.payload;
if (state.includes(id)) return; if (state.selection.includes(id)) return;
state.push(id); state.selection = [...state.selection, id];
},
setDragging: (state, action: PayloadAction<boolean>) => {
state.isDragging = action.payload;
}, },
}, },
}); });
@ -83,13 +99,27 @@ export const rangeSelectionSlice = createSlice({
name: 'documentRangeSelection', name: 'documentRangeSelection',
initialState: rangeSelectionInitialState, initialState: rangeSelectionInitialState,
reducers: { reducers: {
setRange: (state, action: PayloadAction<RangeSelectionState>) => { setRange: (
state,
action: PayloadAction<{
anchor?: PointState;
focus?: PointState;
}>
) => {
return { return {
...state, ...state,
...action.payload, ...action.payload,
}; };
}, },
setSelection: (state, action: PayloadAction<string[]>) => {
state.selection = action.payload;
},
setDragging: (state, action: PayloadAction<boolean>) => {
state.isDragging = action.payload;
},
setForward: (state, action: PayloadAction<boolean>) => {
state.isForward = action.payload;
},
clearRange: (state, _: PayloadAction) => { clearRange: (state, _: PayloadAction) => {
return rangeSelectionInitialState; return rangeSelectionInitialState;
}, },

View File

@ -28,7 +28,7 @@ import 'prismjs/components/prism-php';
import 'prismjs/components/prism-sql'; import 'prismjs/components/prism-sql';
import 'prismjs/components/prism-visual-basic'; import 'prismjs/components/prism-visual-basic';
import { BaseRange, NodeEntry, Text, Path } from 'slate'; import { BaseRange, NodeEntry, Text, Path, Range, Editor } from 'slate';
const push_string = ( const push_string = (
token: string | Prism.Token, token: string | Prism.Token,

View File

@ -151,9 +151,54 @@ export function getCollapsedRange(id: string, selection: TextSelection): RangeSe
anchor: clone(point), anchor: clone(point),
focus: clone(point), focus: clone(point),
isDragging: false, isDragging: false,
selection: [],
}; };
} }
export function iterateNodes(
range: {
startId: string;
endId: string;
},
isForward: boolean,
document: DocumentState,
callback: (nodeId?: string) => boolean
) {
const { startId, endId } = range;
let currentId = startId;
while (currentId && currentId !== endId) {
if (isForward) {
currentId = getNextLineId(document, currentId) || '';
} else {
currentId = getPrevLineId(document, currentId) || '';
}
if (callback(currentId)) {
break;
}
}
}
export function getNodesInRange(
range: {
startId: string;
endId: string;
},
isForward: boolean,
document: DocumentState
) {
const nodeIds: string[] = [];
nodeIds.push(range.startId);
iterateNodes(range, isForward, document, (nodeId) => {
if (nodeId) {
nodeIds.push(nodeId);
return false;
} else {
return true;
}
});
nodeIds.push(range.endId);
return nodeIds;
}
export function nodeInRange( export function nodeInRange(
id: string, id: string,
range: { range: {
@ -163,17 +208,13 @@ export function nodeInRange(
isForward: boolean, isForward: boolean,
document: DocumentState document: DocumentState
) { ) {
const { startId, endId } = range; let match = false;
let currentId = startId; iterateNodes(range, isForward, document, (nodeId) => {
while (currentId && currentId !== id && currentId !== endId) { if (nodeId === id) {
if (isForward) { match = true;
currentId = getNextLineId(document, currentId) || ''; return true;
} else {
currentId = getPrevLineId(document, currentId) || '';
} }
} return false;
if (currentId === id) { });
return true; return match;
}
return false;
} }

View File

@ -0,0 +1,22 @@
export function isPointInBlock(target: HTMLElement | null) {
let node = target;
while (node) {
if (node.getAttribute('data-block-id')) {
return true;
}
node = node.parentElement;
}
return false;
}
export function getBlockIdByPoint(target: HTMLElement | null) {
let node = target;
while (node) {
const id = node.getAttribute('data-block-id');
if (id) {
return id;
}
node = node.parentElement;
}
return null;
}

View File

@ -1,4 +1,4 @@
import { Editor, Element, Location, Text } from 'slate'; import { Editor, Element, Location, Text, Range } from 'slate';
import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document'; import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common'; import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
@ -14,6 +14,86 @@ export function getDelta(editor: Editor, at: Location): TextDelta[] {
}); });
} }
export function getBeforeRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
const anchor = Range.start(range);
const sliceNodes = delta.slice(0, anchor.path[1] + 1);
const sliceEnd = sliceNodes[sliceNodes.length - 1];
const sliceEndText = sliceEnd.insert.slice(0, anchor.offset);
const sliceEndAttributes = sliceEnd.attributes;
const sliceEndNode =
sliceEndText.length > 0
? {
insert: sliceEndText,
attributes: sliceEndAttributes,
}
: null;
const sliceMiddleNodes = sliceNodes.slice(0, sliceNodes.length - 1);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return [...sliceMiddleNodes, sliceEndNode].filter((item) => item);
}
export function getAfterRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
const focus = Range.end(range);
const sliceNodes = delta.slice(focus.path[1], delta.length);
const sliceStart = sliceNodes[0];
const sliceStartText = sliceStart.insert.slice(focus.offset);
const sliceStartAttributes = sliceStart.attributes;
const sliceStartNode =
sliceStartText.length > 0
? {
insert: sliceStartText,
attributes: sliceStartAttributes,
}
: null;
const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return [sliceStartNode, ...sliceMiddleNodes].filter((item) => item);
}
export function getRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
const anchor = Range.start(range);
const focus = Range.end(range);
const sliceNodes = delta.slice(anchor.path[1], focus.path[1] + 1);
if (anchor.path[1] === focus.path[1]) {
return sliceNodes.map((item) => {
const { insert, attributes } = item;
const text = insert.slice(anchor.offset, focus.offset);
return {
insert: text,
attributes,
};
});
}
const sliceStart = sliceNodes[0];
const sliceEnd = sliceNodes[sliceNodes.length - 1];
const sliceStartText = sliceStart.insert.slice(anchor.offset);
const sliceEndText = sliceEnd.insert.slice(0, focus.offset);
const sliceStartAttributes = sliceStart.attributes;
const sliceEndAttributes = sliceEnd.attributes;
const sliceStartNode =
sliceStartText.length > 0
? {
insert: sliceStartText,
attributes: sliceStartAttributes,
}
: null;
const sliceEndNode =
sliceEndText.length > 0
? {
insert: sliceEndText,
attributes: sliceEndAttributes,
}
: null;
const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length - 1);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return [sliceStartNode, ...sliceMiddleNodes, sliceEndNode].filter((item) => item);
}
/** /**
* get the selection between the beginning of the editor and the point * get the selection between the beginning of the editor and the point
* form 0 to point * form 0 to point
@ -290,7 +370,8 @@ export function getPointOfCurrentLineBeginning(editor: Editor) {
return beginPoint; return beginPoint;
} }
export function selectionIsForward(selection: TextSelection) { export function selectionIsForward(selection: TextSelection | null) {
if (!selection) return false;
const { anchor, focus } = selection; const { anchor, focus } = selection;
if (!anchor || !focus) return false; if (!anchor || !focus) return false;
return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset); return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset);

View File

@ -1,25 +0,0 @@
import {
Editor,
Transforms,
Text,
Node
} from 'slate';
export function toggleFormat(editor: Editor, format: string) {
const isActive = isFormatActive(editor, format)
Transforms.setNodes(
editor,
{ [format]: isActive ? null : true },
{ match: Text.isText, split: true }
)
}
export const isFormatActive = (editor: Editor, format: string) => {
const [match] = Editor.nodes(editor, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
match: (n: Node) => n[format] === true,
mode: 'all',
})
return !!match
}

View File

@ -1,5 +1,4 @@
import isHotkey from 'is-hotkey'; import isHotkey from 'is-hotkey';
import { toggleFormat } from './format';
import { Editor, Range } from 'slate'; import { Editor, Range } from 'slate';
import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta'; import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block'; import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
@ -13,16 +12,6 @@ const HOTKEYS: Record<string, string> = {
'mod+shift+S': 'strikethrough', 'mod+shift+S': 'strikethrough',
}; };
export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event)) {
event.preventDefault();
const format = HOTKEYS[hotkey];
toggleFormat(editor, format);
}
}
}
export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) { export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const isBackspaceKey = isHotkey('backspace', event); const isBackspaceKey = isHotkey('backspace', event);
const selection = editor.selection; const selection = editor.selection;

View File

@ -1,27 +0,0 @@
import { Editor, Range } from 'slate';
export function calcToolbarPosition(editor: Editor, toolbarDom: HTMLDivElement, blockRect: DOMRect) {
const { selection } = editor;
if (!selection || Range.isCollapsed(selection) || Editor.string(editor, selection) === '') {
return;
}
const domSelection = window.getSelection();
let domRange;
if (domSelection?.rangeCount === 0) {
return;
} else {
domRange = domSelection?.getRangeAt(0);
}
const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
const top = `${-toolbarDom.offsetHeight - 5 + (rect.top - blockRect.y)}px`;
const left = `${rect.left - blockRect.x - toolbarDom.offsetWidth / 2 + rect.width / 2}px`;
return {
top,
left,
}
}

View File

@ -0,0 +1,19 @@
export function calcToolbarPosition(toolbarDom: HTMLDivElement) {
const domSelection = window.getSelection();
let domRange;
if (domSelection?.rangeCount === 0) {
return;
} else {
domRange = domSelection?.getRangeAt(0);
}
const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
let top = rect.top - toolbarDom.offsetHeight;
let left = rect.left - toolbarDom.offsetWidth / 2 + rect.width / 2;
return {
top: top + 'px',
left: left + 'px',
};
}

View File

@ -3,9 +3,15 @@ import { createTheme, ThemeProvider } from '@mui/material';
import Root from '../components/document/Root'; import Root from '../components/document/Root';
import { DocumentControllerContext } from '../stores/effects/document/document_controller'; import { DocumentControllerContext } from '../stores/effects/document/document_controller';
const theme = createTheme({ const muiTheme = createTheme({
typography: { typography: {
fontFamily: ['Poppins'].join(','), fontFamily: ['Poppins'].join(','),
fontSize: 14,
},
palette: {
primary: {
main: '#00BCF0',
},
}, },
}); });
@ -14,7 +20,7 @@ export const DocumentPage = () => {
if (!documentId || !documentData || !controller) return null; if (!documentId || !documentData || !controller) return null;
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={muiTheme}>
<DocumentControllerContext.Provider value={controller}> <DocumentControllerContext.Provider value={controller}>
<Root documentData={documentData} /> <Root documentData={documentData} />
</DocumentControllerContext.Provider> </DocumentControllerContext.Provider>

View File

@ -4,3 +4,5 @@ export * from "./models/flowy-folder2";
export * from "./models/flowy-document2"; export * from "./models/flowy-document2";
export * from "./models/flowy-net"; export * from "./models/flowy-net";
export * from "./models/flowy-error"; export * from "./models/flowy-error";
export * from "./models/flowy-config";

View File

@ -24,7 +24,6 @@ body {
@apply bg-[transparent] @apply bg-[transparent]
} }
.btn { .btn {
@apply rounded-xl border border-gray-500 px-4 py-3; @apply rounded-xl border border-gray-500 px-4 py-3;
} }