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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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 { 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 { 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({
container,
onDragging,
}: {
container: HTMLDivElement;
onDragging?: (_isDragging: boolean) => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const disaptch = useAppDispatch();
export function useBlockRectSelection({ container }: { container: HTMLDivElement }) {
const dispatch = useAppDispatch();
const [isDragging, setDragging] = useState(false);
const startPointRef = useRef<number[]>([]);
@ -20,8 +14,8 @@ export function useBlockSelection({
const { getIntersectedBlockIds } = useNodesRect(container);
useEffect(() => {
onDragging?.(isDragging);
}, [isDragging, onDragging]);
dispatch(rectSelectionActions.setDragging(isDragging));
}, [dispatch, isDragging]);
const [rect, setRect] = useState<{
startX: number;
@ -45,17 +39,6 @@ export function useBlockSelection({
};
}, [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(
(e: MouseEvent) => {
if (isPointInBlock(e.target as HTMLElement)) {
@ -74,7 +57,7 @@ export function useBlockSelection({
endY: startY,
});
},
[container.scrollLeft, container.scrollTop, isPointInBlock]
[container.scrollLeft, container.scrollTop]
);
const updateSelctionsByPoint = useCallback(
@ -92,9 +75,9 @@ export function useBlockSelection({
};
const blockIds = getIntersectedBlockIds(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(
@ -119,7 +102,7 @@ export function useBlockSelection({
const handleDragEnd = useCallback(
(e: MouseEvent) => {
if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
disaptch(rectSelectionActions.updateSelections([]));
dispatch(rectSelectionActions.updateSelections([]));
return;
}
if (!isDragging) return;
@ -128,11 +111,10 @@ export function useBlockSelection({
setDragging(false);
setRect(null);
},
[disaptch, isDragging, isPointInBlock, updateSelctionsByPoint]
[dispatch, isDragging, updateSelctionsByPoint]
);
useEffect(() => {
if (!ref.current) return;
document.addEventListener('mousedown', handleDragStart);
document.addEventListener('mousemove', handleDraging);
document.addEventListener('mouseup', handleDragEnd);
@ -147,6 +129,5 @@ export function useBlockSelection({
return {
isDragging,
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 BlockRectSelection from '$app/components/document/BlockSelection/BlockRectSelection';
import { useBlockRangeSelection } from '$app/components/document/BlockSelection/BlockRangeSelection.hooks';
function BlockSelection({
container,
onDragging,
}: {
container: HTMLDivElement;
onDragging?: (_isDragging: boolean) => void;
}) {
const { isDragging, style, ref } = useBlockSelection({
container,
onDragging,
});
function BlockSelection({ container }: { container: HTMLDivElement }) {
useBlockRangeSelection(container);
return (
<div ref={ref} 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}
<div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
<BlockRectSelection container={container} />
</div>
);
}

View File

@ -5,13 +5,17 @@ import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
import Portal from '../BlockPortal';
import { IconButton } from '@mui/material';
import BlockMenu from '../BlockMenu';
import { useAppSelector } from '$app/stores/store';
const sx = { height: 24, width: 24 };
export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
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 (
<>
<Portal blockId={nodeId}>

View File

@ -1,10 +1,11 @@
import { BlockType, NestedBlock } from '$app/interfaces/document';
import TextBlock from '$app/components/document/TextBlock';
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 Picker from '@emoji-mart/react';
import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
import Popover from '@mui/material/Popover';
export default function CalloutBlock({
node,
@ -17,7 +18,7 @@ export default function CalloutBlock({
return (
<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'}>
<IconButton
aria-describedby={id}
@ -27,8 +28,9 @@ export default function CalloutBlock({
{node.data.icon}
</IconButton>
<Popover
id={id}
className={'border-none bg-transparent shadow-none'}
anchorEl={anchorEl}
disableAutoFocus={true}
open={open}
onClose={closeEmojiSelect}
anchorOrigin={{

View File

@ -8,6 +8,7 @@ interface CodeLeafProps extends RenderLeafProps {
underlined?: boolean;
strikethrough?: boolean;
prism_token?: string;
selectionHighlighted?: boolean;
};
}
@ -27,8 +28,15 @@ export const CodeLeaf = (props: CodeLeafProps) => {
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 (
<span {...attributes} className={`token ${leaf.prism_token} ${leaf.strikethrough ? `line-through` : ''}`}>
<span {...attributes} className={className.join(' ')}>
{newChildren}
</span>
);

View File

@ -1,7 +1,6 @@
import { BlockType, NestedBlock } from '$app/interfaces/document';
import { useCodeBlock } from './CodeBlock.hooks';
import { Editable, Slate } from 'slate-react';
import BlockHorizontalToolbar from '$app/components/document/BlockHorizontalToolbar';
import React from 'react';
import { CodeLeaf, CodeBlockElement } from './elements';
import SelectLanguage from './SelectLanguage';
@ -23,10 +22,13 @@ export default function CodeBlock({
<SelectLanguage id={id} language={language} />
</div>
<Slate editor={editor} onChange={onChange} value={value}>
<BlockHorizontalToolbar id={id} />
<Editable
{...rest}
decorate={(entry) => decorateCodeFunc(entry, language)}
decorate={(entry) => {
const codeRange = decorateCodeFunc(entry, language);
const range = rest.decorate(entry);
return [...range, ...codeRange];
}}
renderLeaf={CodeLeaf}
renderElement={CodeBlockElement}
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 BlockSelection from '../BlockSelection';
import TextActionMenu from '$app/components/document/TextActionMenu';
export default function Overlay({ container }: { container: HTMLDivElement }) {
const [isDragging, setDragging] = useState(false);
return (
<>
{isDragging ? null : <BlockSideToolbar container={container} />}
<BlockSelection onDragging={setDragging} container={container} />
<BlockSideToolbar container={container} />
<TextActionMenu container={container} />
<BlockSelection container={container} />
</>
);
}

View File

@ -19,18 +19,11 @@ function Root({ documentData }: { documentData: DocumentData }) {
}
return (
<div
id='appflowy-block-doc'
className='h-[100%] overflow-hidden'
onKeyDown={(e) => {
// prevent backspace from going back
if (e.key === 'Backspace') {
e.stopPropagation();
}
}}
>
<VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
</div>
<>
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
<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 { 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 }) {
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;
};
}
const Leaf = ({
attributes,
children,
leaf,
}: LeafProps) => {
const Leaf = ({ attributes, children, leaf }: LeafProps) => {
let newChildren = children;
if (leaf.bold) {
newChildren = <strong>{children}</strong>;
}
if (leaf.code) {
newChildren = <code className='rounded-sm bg-[#F2FCFF] p-1'>{newChildren}</code>;
}
if (leaf.italic) {
newChildren = <em>{newChildren}</em>;
}
@ -32,16 +24,14 @@ const Leaf = ({
newChildren = <u>{newChildren}</u>;
}
let className = "";
if (leaf.strikethrough) {
className += "line-through";
}
if (leaf.selectionHighlighted) {
className += " bg-main-secondary";
}
const className = [
leaf.strikethrough && 'line-through',
leaf.selectionHighlighted && 'bg-main-secondary',
leaf.code && 'bg-main-selector',
].filter(Boolean);
return (
<span {...attributes} className={className}>
<span {...attributes} className={className.join(' ')}>
{newChildren}
</span>
);

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import {
canHandleUpKey,
} from '$app/utils/document/blocks/text/hotkey';
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import { ReactEditor } from "slate-react";
import { ReactEditor } from 'slate-react';
export function useDefaultTextInputEvents(id: string) {
const dispatch = useAppDispatch();
@ -81,11 +81,11 @@ export function useDefaultTextInputEvents(id: string) {
triggerEventKey: keyBoardEventKeyMap.Backspace,
canHandle: canHandleBackspaceKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
const [e, editor] = args;
e.preventDefault();
void (async () => {
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 { 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 { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common';
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 { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks';
export function useTextInput(id: string) {
const { node } = useSubscribeNode(id);
const [editor] = useState(() => withReact(createEditor()));
const isComposition = useRef(false);
const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor);
@ -24,16 +24,15 @@ export function useTextInput(id: string) {
}
return node.data.delta;
}, [node]);
const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
const { sync, receive } = useUpdateDelta(id, editor);
const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
// Update the editor's value when the node's delta changes.
useEffect(() => {
// If composition is in progress, do nothing.
if (isComposition.current) return;
receive(delta);
receive(delta, setValue);
}, [delta, receive]);
// 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 penddingRef = useRef(false);
// when user input, update the node's delta after 200ms
const debounceUpdate = useMemo(() => {
return debounce(() => {
if (!controller) return;
const delta = slateValueToDelta(editor.children);
void (async () => {
await dispatch(
updateNodeDeltaThunk({
id,
delta,
controller,
})
);
// reset pendding flag
penddingRef.current = false;
})();
}, 200);
const update = useCallback(() => {
if (!controller) return;
const delta = slateValueToDelta(editor.children);
void (async () => {
await dispatch(
updateNodeDeltaThunk({
id,
delta,
controller,
})
);
// reset pendding flag
penddingRef.current = false;
})();
}, [controller, dispatch, editor, id]);
const sync = useCallback(() => {
// set pendding flag
penddingRef.current = true;
debounceUpdate();
}, [debounceUpdate]);
update();
}, [update]);
const receive = useCallback(
(delta: TextDelta[]) => {
(delta: TextDelta[], setValue: (children: Descendant[]) => void) => {
// if pendding, do nothing
if (penddingRef.current) return;
@ -123,18 +119,14 @@ function useUpdateDelta(id: string, editor: Editor) {
const isSame = isSameDelta(delta, localDelta);
if (isSame) return;
Transforms.deselect(editor);
const slateValue = deltaToSlateValue(delta);
editor.children = slateValue;
setValue(slateValue);
},
[editor]
);
useEffect(() => {
return () => {
debounceUpdate.cancel();
};
});
return {
sync,
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 { EditableProps } from 'slate-react/dist/components/editable';
import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useAppDispatch } from '$app/stores/store';
import { rangeSelectionActions } from '$app_reducers/document/slice';
import { TextSelection } from '$app/interfaces/document';
import { ReactEditor } from 'slate-react';
import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
import { getCollapsedRange } from '$app/utils/document/blocks/common';
import { getEditorEndPoint, selectionIsForward } from '$app/utils/document/blocks/text/delta';
import { getNodeEndSelection } 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) {
const { rangeRef, currentSelection } = useSubscribeRangeSelection(id);
@ -16,15 +16,21 @@ export function useTextSelections(id: string, editor: ReactEditor) {
useEffect(() => {
if (!rangeRef.current) return;
const { isDragging, focus, anchor } = rangeRef.current;
if (isDragging || anchor?.id !== focus?.id || !currentSelection || !Range.isCollapsed(currentSelection as BaseRange))
if (!currentSelection) {
ReactEditor.deselect(editor);
ReactEditor.blur(editor);
return;
}
const { isDragging, focus } = rangeRef.current;
if (isDragging || focus?.id !== id) return;
if (!ReactEditor.isFocused(editor)) {
ReactEditor.focus(editor);
}
Transforms.select(editor, currentSelection);
}, [currentSelection, editor, rangeRef]);
if (!isEqual(editor.selection, currentSelection)) {
Transforms.select(editor, currentSelection);
}
}, [currentSelection, editor, id, rangeRef]);
const decorate: EditableProps['decorate'] = useCallback(
(entry: [Node, Path]) => {
@ -48,48 +54,6 @@ export function useTextSelections(id: string, editor: ReactEditor) {
[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(
(lastActiveSelection: Range) => {
const selection = lastActiveSelection as TextSelection;
@ -102,12 +66,33 @@ export function useTextSelections(id: string, editor: ReactEditor) {
ReactEditor.deselect(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 {
decorate,
onMouseDown,
onMouseMove,
onMouseUp,
onBlur,
onMouseMove,
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
*/
export const blockConfig: Record<
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;
};
}
> = {
export const blockConfig: Record<string, BlockConfig> = {
[BlockType.TextBlock]: {
canAddChild: true,
defaultData: {
@ -169,5 +134,49 @@ export const blockConfig: Record<
* ```
*/
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 { RegionGrid } from '$app/utils/region_grid';
import { ReactEditor } from "slate-react";
import { ReactEditor } from 'slate-react';
export enum BlockType {
PageBlock = 'page',
@ -11,6 +11,7 @@ export enum BlockType {
NumberedListBlock = 'numbered_list',
ToggleListBlock = 'toggle_list',
CodeBlock = 'code',
EquationBlock = 'math_equation',
EmbedBlock = 'embed',
QuoteBlock = 'quote',
CalloutBlock = 'callout',
@ -87,7 +88,7 @@ export interface NestedBlock<Type = any> {
}
export interface TextDelta {
insert: string;
attributes?: Record<string, string | boolean>;
attributes?: Record<string, string | boolean | undefined>;
}
export enum BlockActionType {
@ -131,16 +132,21 @@ export interface DocumentState {
children: Record<string, string[]>;
}
export interface RectSelectionState {
selection: string[];
isDragging: boolean;
}
export interface RangeSelectionState {
isDragging?: boolean,
anchor?: PointState,
focus?: PointState,
anchor?: PointState;
focus?: PointState;
isForward?: boolean;
isDragging: boolean;
selection: string[];
}
export interface PointState {
id: string,
selection: TextSelection
id: string;
selection: TextSelection;
}
export enum ChangeType {
@ -161,3 +167,62 @@ export interface BlockPBValue {
}
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 { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
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
@ -14,8 +15,8 @@ import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/block
*/
export const backspaceNodeThunk = createAsyncThunk(
'document/backspaceNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload;
async (payload: { id: string; controller: DocumentController; editor: ReactEditor }, thunkAPI) => {
const { id, controller, editor } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
@ -33,6 +34,7 @@ export const backspaceNodeThunk = createAsyncThunk(
// merge to previous line when parent is root
if (parentIsRoot || nextNodeId) {
// merge to previous line
ReactEditor.deselect(editor);
await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true }));
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 { createAsyncThunk } from '@reduxjs/toolkit';
import { setCursorBeforeThunk } from '../../cursor';
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 { ReactEditor } from "slate-react";
import { ReactEditor } from 'slate-react';
export const splitNodeThunk = createAsyncThunk(
'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 { DocumentState, RangeSelectionState, TextSelection } from '$app/interfaces/document';
import { DocumentState, TextSelection } from '$app/interfaces/document';
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 { RootState } from '$app/stores/store';
import { getNodesInRange } from '$app/utils/document/blocks/common';
const amendAnchorNodeThunk = createAsyncThunk(
'document/amendAnchorNode',
@ -15,22 +17,18 @@ const amendAnchorNodeThunk = createAsyncThunk(
const { id } = payload;
const { getState, dispatch } = thunkAPI;
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;
const isCollapsed = focusNode?.id === id && anchorNode?.id === id;
if (isCollapsed) return;
const selection = anchorNode.selection;
const isForward = selectionIsForward(selection);
const node = nodes[id];
const focus = isForward
? getNodeEndSelection(node.data.delta).anchor
: {
path: [0, 0],
offset: 0,
};
const focus = isForward ? getNodeEndSelection(node.data.delta).anchor : getNodeBeginSelection().anchor;
if (isEqual(focus, selection.focus)) return;
const newSelection = {
anchor: selection.anchor,
@ -58,29 +56,64 @@ export const syncRangeSelectionThunk = createAsyncThunk(
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 updateRange = {
focus: {
id,
selection,
},
};
const isAnchor = range.anchor?.id === id;
if (isAnchor) {
if (!isDragging && range.anchor?.id === id) {
Object.assign(updateRange, {
anchor: {
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));
const anchorId = range.anchor?.id;
if (!isAnchor && anchorId) {
// more than one node is selected
if (anchorId && anchorId !== id) {
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 { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { parseValue, matchChange } from '$app/utils/document/subscribe';
import { getNodesInRange } from '$app/utils/document/blocks/common';
const initialState: DocumentState = {
nodes: {},
children: {},
};
const rectSelectionInitialState: string[] = [];
const rectSelectionInitialState: RectSelectionState = {
selection: [],
isDragging: false,
};
const rangeSelectionInitialState: RangeSelectionState = {};
const rangeSelectionInitialState: RangeSelectionState = {
isDragging: false,
selection: [],
};
export const documentSlice = createSlice({
name: 'document',
@ -35,7 +48,6 @@ export const documentSlice = createSlice({
state.nodes = nodes;
state.children = children;
},
/**
This function listens for changes in the data layer triggered by the data API,
and updates the UI state accordingly.
@ -67,14 +79,18 @@ export const rectSelectionSlice = createSlice({
reducers: {
// update block selections
updateSelections: (state, action: PayloadAction<string[]>) => {
return action.payload;
state.selection = action.payload;
},
// set block selected
setSelectionById: (state, action: PayloadAction<string>) => {
const id = action.payload;
if (state.includes(id)) return;
state.push(id);
if (state.selection.includes(id)) return;
state.selection = [...state.selection, id];
},
setDragging: (state, action: PayloadAction<boolean>) => {
state.isDragging = action.payload;
},
},
});
@ -83,13 +99,27 @@ export const rangeSelectionSlice = createSlice({
name: 'documentRangeSelection',
initialState: rangeSelectionInitialState,
reducers: {
setRange: (state, action: PayloadAction<RangeSelectionState>) => {
setRange: (
state,
action: PayloadAction<{
anchor?: PointState;
focus?: PointState;
}>
) => {
return {
...state,
...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) => {
return rangeSelectionInitialState;
},

View File

@ -28,7 +28,7 @@ import 'prismjs/components/prism-php';
import 'prismjs/components/prism-sql';
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 = (
token: string | Prism.Token,

View File

@ -151,9 +151,54 @@ export function getCollapsedRange(id: string, selection: TextSelection): RangeSe
anchor: clone(point),
focus: clone(point),
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(
id: string,
range: {
@ -163,17 +208,13 @@ export function nodeInRange(
isForward: boolean,
document: DocumentState
) {
const { startId, endId } = range;
let currentId = startId;
while (currentId && currentId !== id && currentId !== endId) {
if (isForward) {
currentId = getNextLineId(document, currentId) || '';
} else {
currentId = getPrevLineId(document, currentId) || '';
let match = false;
iterateNodes(range, isForward, document, (nodeId) => {
if (nodeId === id) {
match = true;
return true;
}
}
if (currentId === id) {
return true;
}
return false;
return false;
});
return match;
}

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 * as Y from 'yjs';
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
* form 0 to point
@ -290,7 +370,8 @@ export function getPointOfCurrentLineBeginning(editor: Editor) {
return beginPoint;
}
export function selectionIsForward(selection: TextSelection) {
export function selectionIsForward(selection: TextSelection | null) {
if (!selection) return false;
const { anchor, focus } = selection;
if (!anchor || !focus) return false;
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 { toggleFormat } from './format';
import { Editor, Range } from 'slate';
import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
@ -13,16 +12,6 @@ const HOTKEYS: Record<string, string> = {
'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) {
const isBackspaceKey = isHotkey('backspace', event);
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 { DocumentControllerContext } from '../stores/effects/document/document_controller';
const theme = createTheme({
const muiTheme = createTheme({
typography: {
fontFamily: ['Poppins'].join(','),
fontSize: 14,
},
palette: {
primary: {
main: '#00BCF0',
},
},
});
@ -14,7 +20,7 @@ export const DocumentPage = () => {
if (!documentId || !documentData || !controller) return null;
return (
<ThemeProvider theme={theme}>
<ThemeProvider theme={muiTheme}>
<DocumentControllerContext.Provider value={controller}>
<Root documentData={documentData} />
</DocumentControllerContext.Provider>

View File

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

View File

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