mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
655
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
655
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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;
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
||||||
|
}
|
@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -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;
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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}>
|
||||||
|
@ -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={{
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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...'}
|
||||||
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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'
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// prevent backspace from going back
|
|
||||||
if (e.key === 'Backspace') {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
|
<VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -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;
|
@ -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;
|
@ -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) {
|
@ -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;
|
@ -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;
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -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;
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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 {
|
||||||
|
@ -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} />}
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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 }));
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -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,9 +87,7 @@ 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(() => {
|
|
||||||
return debounce(() => {
|
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
const delta = slateValueToDelta(editor.children);
|
const delta = slateValueToDelta(editor.children);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@ -104,17 +101,16 @@ function useUpdateDelta(id: string, editor: Editor) {
|
|||||||
// reset pendding flag
|
// reset pendding flag
|
||||||
penddingRef.current = false;
|
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,
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
if (!isEqual(editor.selection, currentSelection)) {
|
||||||
Transforms.select(editor, currentSelection);
|
Transforms.select(editor, currentSelection);
|
||||||
}, [currentSelection, editor, rangeRef]);
|
}
|
||||||
|
}, [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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -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;
|
@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
@ -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',
|
|
||||||
},
|
|
||||||
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
);
|
@ -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));
|
||||||
|
});
|
||||||
|
@ -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;
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
|
@ -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) || '';
|
|
||||||
} else {
|
|
||||||
currentId = getPrevLineId(document, currentId) || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (currentId === id) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
});
|
||||||
|
return match;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -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);
|
||||||
|
@ -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
|
|
||||||
}
|
|
@ -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;
|
||||||
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -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',
|
||||||
|
};
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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";
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user