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:
parent
f04d64a191
commit
f23c6098a7
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 { useAppDispatch } from '$app/stores/store';
|
||||
import { rectSelectionActions } from "@/appflowy_app/stores/reducers/document/slice";
|
||||
import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/slice';
|
||||
import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks';
|
||||
import { setRectSelectionThunk } from "$app_reducers/document/async-actions/rect_selection";
|
||||
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
|
||||
import { isPointInBlock } from '$app/utils/document/blocks/selection';
|
||||
|
||||
export function useBlockSelection({
|
||||
container,
|
||||
onDragging,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
onDragging?: (_isDragging: boolean) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const disaptch = useAppDispatch();
|
||||
export function useBlockRectSelection({ container }: { container: HTMLDivElement }) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isDragging, setDragging] = useState(false);
|
||||
const startPointRef = useRef<number[]>([]);
|
||||
@ -20,8 +14,8 @@ export function useBlockSelection({
|
||||
const { getIntersectedBlockIds } = useNodesRect(container);
|
||||
|
||||
useEffect(() => {
|
||||
onDragging?.(isDragging);
|
||||
}, [isDragging, onDragging]);
|
||||
dispatch(rectSelectionActions.setDragging(isDragging));
|
||||
}, [dispatch, isDragging]);
|
||||
|
||||
const [rect, setRect] = useState<{
|
||||
startX: number;
|
||||
@ -45,17 +39,6 @@ export function useBlockSelection({
|
||||
};
|
||||
}, [container.scrollLeft, container.scrollTop, rect]);
|
||||
|
||||
const isPointInBlock = useCallback((target: HTMLElement | null) => {
|
||||
let node = target;
|
||||
while (node) {
|
||||
if (node.getAttribute('data-block-id')) {
|
||||
return true;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isPointInBlock(e.target as HTMLElement)) {
|
||||
@ -74,7 +57,7 @@ export function useBlockSelection({
|
||||
endY: startY,
|
||||
});
|
||||
},
|
||||
[container.scrollLeft, container.scrollTop, isPointInBlock]
|
||||
[container.scrollLeft, container.scrollTop]
|
||||
);
|
||||
|
||||
const updateSelctionsByPoint = useCallback(
|
||||
@ -92,9 +75,9 @@ export function useBlockSelection({
|
||||
};
|
||||
const blockIds = getIntersectedBlockIds(newRect);
|
||||
setRect(newRect);
|
||||
disaptch(setRectSelectionThunk(blockIds));
|
||||
dispatch(setRectSelectionThunk(blockIds));
|
||||
},
|
||||
[container.scrollLeft, container.scrollTop, disaptch, getIntersectedBlockIds, isDragging]
|
||||
[container.scrollLeft, container.scrollTop, dispatch, getIntersectedBlockIds, isDragging]
|
||||
);
|
||||
|
||||
const handleDraging = useCallback(
|
||||
@ -119,7 +102,7 @@ export function useBlockSelection({
|
||||
const handleDragEnd = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
|
||||
disaptch(rectSelectionActions.updateSelections([]));
|
||||
dispatch(rectSelectionActions.updateSelections([]));
|
||||
return;
|
||||
}
|
||||
if (!isDragging) return;
|
||||
@ -128,11 +111,10 @@ export function useBlockSelection({
|
||||
setDragging(false);
|
||||
setRect(null);
|
||||
},
|
||||
[disaptch, isDragging, isPointInBlock, updateSelctionsByPoint]
|
||||
[dispatch, isDragging, updateSelctionsByPoint]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
document.addEventListener('mousedown', handleDragStart);
|
||||
document.addEventListener('mousemove', handleDraging);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
@ -147,6 +129,5 @@ export function useBlockSelection({
|
||||
return {
|
||||
isDragging,
|
||||
style,
|
||||
ref,
|
||||
};
|
||||
}
|
@ -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 BlockRectSelection from '$app/components/document/BlockSelection/BlockRectSelection';
|
||||
import { useBlockRangeSelection } from '$app/components/document/BlockSelection/BlockRangeSelection.hooks';
|
||||
|
||||
function BlockSelection({
|
||||
container,
|
||||
onDragging,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
onDragging?: (_isDragging: boolean) => void;
|
||||
}) {
|
||||
const { isDragging, style, ref } = useBlockSelection({
|
||||
container,
|
||||
onDragging,
|
||||
});
|
||||
|
||||
function BlockSelection({ container }: { container: HTMLDivElement }) {
|
||||
useBlockRangeSelection(container);
|
||||
return (
|
||||
<div ref={ref} className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
|
||||
{isDragging ? <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} /> : null}
|
||||
<div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
|
||||
<BlockRectSelection container={container} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -5,13 +5,17 @@ import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
|
||||
import Portal from '../BlockPortal';
|
||||
import { IconButton } from '@mui/material';
|
||||
import BlockMenu from '../BlockMenu';
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
|
||||
const sx = { height: 24, width: 24 };
|
||||
|
||||
export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
|
||||
const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
|
||||
const isDragging = useAppSelector(
|
||||
(state) => state.documentRangeSelection.isDragging || state.documentRectSelection.isDragging
|
||||
);
|
||||
|
||||
if (!nodeId) return null;
|
||||
if (!nodeId || isDragging) return null;
|
||||
return (
|
||||
<>
|
||||
<Portal blockId={nodeId}>
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import TextBlock from '$app/components/document/TextBlock';
|
||||
import NodeChildren from '$app/components/document/Node/NodeChildren';
|
||||
import { IconButton, Popover } from '@mui/material';
|
||||
import { IconButton } from '@mui/material';
|
||||
import emojiData from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
|
||||
import Popover from '@mui/material/Popover';
|
||||
|
||||
export default function CalloutBlock({
|
||||
node,
|
||||
@ -17,7 +18,7 @@ export default function CalloutBlock({
|
||||
|
||||
return (
|
||||
<div className={'my-1 flex rounded border border-solid border-main-accent bg-main-secondary p-4'}>
|
||||
<div className={'w-[1.5em]'}>
|
||||
<div className={'w-[1.5em]'} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
|
||||
<IconButton
|
||||
aria-describedby={id}
|
||||
@ -27,8 +28,9 @@ export default function CalloutBlock({
|
||||
{node.data.icon}
|
||||
</IconButton>
|
||||
<Popover
|
||||
id={id}
|
||||
className={'border-none bg-transparent shadow-none'}
|
||||
anchorEl={anchorEl}
|
||||
disableAutoFocus={true}
|
||||
open={open}
|
||||
onClose={closeEmojiSelect}
|
||||
anchorOrigin={{
|
||||
|
@ -8,6 +8,7 @@ interface CodeLeafProps extends RenderLeafProps {
|
||||
underlined?: boolean;
|
||||
strikethrough?: boolean;
|
||||
prism_token?: string;
|
||||
selectionHighlighted?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@ -27,8 +28,15 @@ export const CodeLeaf = (props: CodeLeafProps) => {
|
||||
newChildren = <u>{newChildren}</u>;
|
||||
}
|
||||
|
||||
const className = [
|
||||
'token',
|
||||
leaf.prism_token && leaf.prism_token,
|
||||
leaf.strikethrough && 'line-through',
|
||||
leaf.selectionHighlighted && 'bg-main-secondary',
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<span {...attributes} className={`token ${leaf.prism_token} ${leaf.strikethrough ? `line-through` : ''}`}>
|
||||
<span {...attributes} className={className.join(' ')}>
|
||||
{newChildren}
|
||||
</span>
|
||||
);
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import { useCodeBlock } from './CodeBlock.hooks';
|
||||
import { Editable, Slate } from 'slate-react';
|
||||
import BlockHorizontalToolbar from '$app/components/document/BlockHorizontalToolbar';
|
||||
import React from 'react';
|
||||
import { CodeLeaf, CodeBlockElement } from './elements';
|
||||
import SelectLanguage from './SelectLanguage';
|
||||
@ -23,10 +22,13 @@ export default function CodeBlock({
|
||||
<SelectLanguage id={id} language={language} />
|
||||
</div>
|
||||
<Slate editor={editor} onChange={onChange} value={value}>
|
||||
<BlockHorizontalToolbar id={id} />
|
||||
<Editable
|
||||
{...rest}
|
||||
decorate={(entry) => decorateCodeFunc(entry, language)}
|
||||
decorate={(entry) => {
|
||||
const codeRange = decorateCodeFunc(entry, language);
|
||||
const range = rest.decorate(entry);
|
||||
return [...range, ...codeRange];
|
||||
}}
|
||||
renderLeaf={CodeLeaf}
|
||||
renderElement={CodeBlockElement}
|
||||
placeholder={placeholder || 'Please enter some text...'}
|
||||
|
@ -1,13 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import BlockSideToolbar from '../BlockSideToolbar';
|
||||
import BlockSelection from '../BlockSelection';
|
||||
import TextActionMenu from '$app/components/document/TextActionMenu';
|
||||
|
||||
export default function Overlay({ container }: { container: HTMLDivElement }) {
|
||||
const [isDragging, setDragging] = useState(false);
|
||||
return (
|
||||
<>
|
||||
{isDragging ? null : <BlockSideToolbar container={container} />}
|
||||
<BlockSelection onDragging={setDragging} container={container} />
|
||||
<BlockSideToolbar container={container} />
|
||||
<TextActionMenu container={container} />
|
||||
<BlockSelection container={container} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -19,18 +19,11 @@ function Root({ documentData }: { documentData: DocumentData }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id='appflowy-block-doc'
|
||||
className='h-[100%] overflow-hidden'
|
||||
onKeyDown={(e) => {
|
||||
// prevent backspace from going back
|
||||
if (e.key === 'Backspace') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
|
||||
</div>
|
||||
<>
|
||||
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
|
||||
<VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
|
||||
import { iconSize } from '$app/constants/document/toolbar';
|
||||
export const iconSize = { width: 18, height: 18 };
|
||||
|
||||
export default function FormatIcon({ icon }: { icon: string }) {
|
||||
switch (icon) {
|
@ -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;
|
||||
};
|
||||
}
|
||||
const Leaf = ({
|
||||
attributes,
|
||||
children,
|
||||
leaf,
|
||||
}: LeafProps) => {
|
||||
const Leaf = ({ attributes, children, leaf }: LeafProps) => {
|
||||
let newChildren = children;
|
||||
if (leaf.bold) {
|
||||
newChildren = <strong>{children}</strong>;
|
||||
}
|
||||
|
||||
if (leaf.code) {
|
||||
newChildren = <code className='rounded-sm bg-[#F2FCFF] p-1'>{newChildren}</code>;
|
||||
}
|
||||
|
||||
if (leaf.italic) {
|
||||
newChildren = <em>{newChildren}</em>;
|
||||
}
|
||||
@ -32,16 +24,14 @@ const Leaf = ({
|
||||
newChildren = <u>{newChildren}</u>;
|
||||
}
|
||||
|
||||
let className = "";
|
||||
if (leaf.strikethrough) {
|
||||
className += "line-through";
|
||||
}
|
||||
if (leaf.selectionHighlighted) {
|
||||
className += " bg-main-secondary";
|
||||
}
|
||||
const className = [
|
||||
leaf.strikethrough && 'line-through',
|
||||
leaf.selectionHighlighted && 'bg-main-secondary',
|
||||
leaf.code && 'bg-main-selector',
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<span {...attributes} className={className}>
|
||||
<span {...attributes} className={className.join(' ')}>
|
||||
{newChildren}
|
||||
</span>
|
||||
);
|
||||
|
@ -2,20 +2,23 @@ import { Editor } from 'slate';
|
||||
import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
|
||||
import { triggerHotkey } from '$app/utils/document/blocks/text/hotkey';
|
||||
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
||||
export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const defaultTextInputEvents = useDefaultTextInputEvents(id);
|
||||
const isFocusCurrentNode = useAppSelector((state) => {
|
||||
const { anchor, focus } = state.documentRangeSelection;
|
||||
if (!anchor || !focus) return false;
|
||||
return anchor.id === id && focus.id === id;
|
||||
});
|
||||
|
||||
const { turnIntoBlockEvents } = useTurnIntoBlock(id);
|
||||
|
||||
@ -84,18 +87,20 @@ export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isFocusCurrentNode) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
// This is list of key events that can be handled by TextBlock
|
||||
const keyEvents = [...events, ...turnIntoBlockEvents];
|
||||
|
||||
const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
|
||||
if (matchKeys.length === 0) {
|
||||
triggerHotkey(event, editor);
|
||||
return;
|
||||
}
|
||||
|
||||
matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
|
||||
},
|
||||
[editor, events, turnIntoBlockEvents]
|
||||
[editor, events, turnIntoBlockEvents, isFocusCurrentNode]
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { Slate, Editable } from 'slate-react';
|
||||
import Leaf from './Leaf';
|
||||
import { useTextBlock } from './TextBlock.hooks';
|
||||
import BlockHorizontalToolbar from '../BlockHorizontalToolbar';
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { NestedBlock } from '$app/interfaces/document';
|
||||
import NodeChildren from '$app/components/document/Node/NodeChildren';
|
||||
|
||||
@ -23,7 +22,6 @@ function TextBlock({
|
||||
<>
|
||||
<div className={`px-1 py-[2px] ${className}`}>
|
||||
<Slate editor={editor} onChange={onChange} value={value}>
|
||||
{/*<BlockHorizontalToolbar id={node.id} />*/}
|
||||
<Editable
|
||||
{...rest}
|
||||
renderLeaf={(leafProps) => <Leaf {...leafProps} />}
|
||||
|
@ -2,7 +2,7 @@ import { useAppSelector } from '@/appflowy_app/stores/store';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document';
|
||||
import { nodeInRange } from '$app/utils/document/blocks/common';
|
||||
import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta';
|
||||
import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
|
||||
|
||||
/**
|
||||
* Subscribe node information
|
||||
@ -18,7 +18,7 @@ export function useSubscribeNode(id: string) {
|
||||
});
|
||||
|
||||
const isSelected = useAppSelector<boolean>((state) => {
|
||||
return state.documentRectSelection.includes(id) || false;
|
||||
return state.documentRectSelection.selection.includes(id) || false;
|
||||
});
|
||||
|
||||
// Memoize the node and its children
|
||||
@ -50,6 +50,7 @@ export function useSubscribeRangeSelection(id: string) {
|
||||
if (range.focus?.id === id) {
|
||||
return range.focus.selection;
|
||||
}
|
||||
|
||||
return getAmendInRangeNodeSelection(id, range, state.document);
|
||||
});
|
||||
|
||||
@ -60,17 +61,17 @@ export function useSubscribeRangeSelection(id: string) {
|
||||
}
|
||||
|
||||
function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) {
|
||||
if (!range.anchor || !range.focus || range.anchor.id === range.focus.id) {
|
||||
if (!range.anchor || !range.focus || range.anchor.id === range.focus.id || range.isForward === undefined) {
|
||||
return null;
|
||||
}
|
||||
const isForward = selectionIsForward(range.anchor.selection);
|
||||
|
||||
const isNodeInRange = nodeInRange(
|
||||
id,
|
||||
{
|
||||
startId: range.anchor.id,
|
||||
endId: range.focus.id,
|
||||
},
|
||||
isForward,
|
||||
range.isForward,
|
||||
document
|
||||
);
|
||||
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
canHandleUpKey,
|
||||
} from '$app/utils/document/blocks/text/hotkey';
|
||||
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
|
||||
import { ReactEditor } from "slate-react";
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
||||
export function useDefaultTextInputEvents(id: string) {
|
||||
const dispatch = useAppDispatch();
|
||||
@ -81,11 +81,11 @@ export function useDefaultTextInputEvents(id: string) {
|
||||
triggerEventKey: keyBoardEventKeyMap.Backspace,
|
||||
canHandle: canHandleBackspaceKey,
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
const [e, _] = args;
|
||||
const [e, editor] = args;
|
||||
e.preventDefault();
|
||||
void (async () => {
|
||||
if (!controller) return;
|
||||
await dispatch(backspaceNodeThunk({ id, controller }));
|
||||
await dispatch(backspaceNodeThunk({ id, controller, editor }));
|
||||
})();
|
||||
},
|
||||
},
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createEditor, Descendant, Editor } from 'slate';
|
||||
import { createEditor, Descendant, Editor, Transforms } from 'slate';
|
||||
import { withReact } from 'slate-react';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@ -8,12 +8,12 @@ import { useAppDispatch } from '$app/stores/store';
|
||||
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
|
||||
import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common';
|
||||
import { isSameDelta } from '$app/utils/document/blocks/text/delta';
|
||||
import { debounce } from '$app/utils/tool';
|
||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks';
|
||||
|
||||
export function useTextInput(id: string) {
|
||||
const { node } = useSubscribeNode(id);
|
||||
|
||||
const [editor] = useState(() => withReact(createEditor()));
|
||||
const isComposition = useRef(false);
|
||||
const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor);
|
||||
@ -24,16 +24,15 @@ export function useTextInput(id: string) {
|
||||
}
|
||||
return node.data.delta;
|
||||
}, [node]);
|
||||
const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
|
||||
|
||||
const { sync, receive } = useUpdateDelta(id, editor);
|
||||
|
||||
const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
|
||||
|
||||
// Update the editor's value when the node's delta changes.
|
||||
useEffect(() => {
|
||||
// If composition is in progress, do nothing.
|
||||
if (isComposition.current) return;
|
||||
receive(delta);
|
||||
receive(delta, setValue);
|
||||
}, [delta, receive]);
|
||||
|
||||
// Update the node's delta when the editor's value changes.
|
||||
@ -88,33 +87,30 @@ function useUpdateDelta(id: string, editor: Editor) {
|
||||
const dispatch = useAppDispatch();
|
||||
const penddingRef = useRef(false);
|
||||
|
||||
// when user input, update the node's delta after 200ms
|
||||
const debounceUpdate = useMemo(() => {
|
||||
return debounce(() => {
|
||||
if (!controller) return;
|
||||
const delta = slateValueToDelta(editor.children);
|
||||
void (async () => {
|
||||
await dispatch(
|
||||
updateNodeDeltaThunk({
|
||||
id,
|
||||
delta,
|
||||
controller,
|
||||
})
|
||||
);
|
||||
// reset pendding flag
|
||||
penddingRef.current = false;
|
||||
})();
|
||||
}, 200);
|
||||
const update = useCallback(() => {
|
||||
if (!controller) return;
|
||||
const delta = slateValueToDelta(editor.children);
|
||||
void (async () => {
|
||||
await dispatch(
|
||||
updateNodeDeltaThunk({
|
||||
id,
|
||||
delta,
|
||||
controller,
|
||||
})
|
||||
);
|
||||
// reset pendding flag
|
||||
penddingRef.current = false;
|
||||
})();
|
||||
}, [controller, dispatch, editor, id]);
|
||||
|
||||
const sync = useCallback(() => {
|
||||
// set pendding flag
|
||||
penddingRef.current = true;
|
||||
debounceUpdate();
|
||||
}, [debounceUpdate]);
|
||||
update();
|
||||
}, [update]);
|
||||
|
||||
const receive = useCallback(
|
||||
(delta: TextDelta[]) => {
|
||||
(delta: TextDelta[], setValue: (children: Descendant[]) => void) => {
|
||||
// if pendding, do nothing
|
||||
if (penddingRef.current) return;
|
||||
|
||||
@ -123,18 +119,14 @@ function useUpdateDelta(id: string, editor: Editor) {
|
||||
const isSame = isSameDelta(delta, localDelta);
|
||||
if (isSame) return;
|
||||
|
||||
Transforms.deselect(editor);
|
||||
const slateValue = deltaToSlateValue(delta);
|
||||
editor.children = slateValue;
|
||||
setValue(slateValue);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debounceUpdate.cancel();
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sync,
|
||||
receive,
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { MouseEventHandler, useCallback, useEffect } from 'react';
|
||||
import { MouseEvent, useCallback, useEffect, useRef } from 'react';
|
||||
import { BaseRange, Editor, Node, Path, Range, Transforms } from 'slate';
|
||||
import { EditableProps } from 'slate-react/dist/components/editable';
|
||||
import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { rangeSelectionActions } from '$app_reducers/document/slice';
|
||||
import { TextSelection } from '$app/interfaces/document';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
|
||||
import { getCollapsedRange } from '$app/utils/document/blocks/common';
|
||||
import { getEditorEndPoint, selectionIsForward } from '$app/utils/document/blocks/text/delta';
|
||||
import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
|
||||
import { slateValueToDelta } from '$app/utils/document/blocks/common';
|
||||
import { isEqual } from '$app/utils/tool';
|
||||
|
||||
export function useTextSelections(id: string, editor: ReactEditor) {
|
||||
const { rangeRef, currentSelection } = useSubscribeRangeSelection(id);
|
||||
@ -16,15 +16,21 @@ export function useTextSelections(id: string, editor: ReactEditor) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!rangeRef.current) return;
|
||||
const { isDragging, focus, anchor } = rangeRef.current;
|
||||
if (isDragging || anchor?.id !== focus?.id || !currentSelection || !Range.isCollapsed(currentSelection as BaseRange))
|
||||
if (!currentSelection) {
|
||||
ReactEditor.deselect(editor);
|
||||
ReactEditor.blur(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
const { isDragging, focus } = rangeRef.current;
|
||||
if (isDragging || focus?.id !== id) return;
|
||||
if (!ReactEditor.isFocused(editor)) {
|
||||
ReactEditor.focus(editor);
|
||||
}
|
||||
Transforms.select(editor, currentSelection);
|
||||
}, [currentSelection, editor, rangeRef]);
|
||||
if (!isEqual(editor.selection, currentSelection)) {
|
||||
Transforms.select(editor, currentSelection);
|
||||
}
|
||||
}, [currentSelection, editor, id, rangeRef]);
|
||||
|
||||
const decorate: EditableProps['decorate'] = useCallback(
|
||||
(entry: [Node, Path]) => {
|
||||
@ -48,48 +54,6 @@ export function useTextSelections(id: string, editor: ReactEditor) {
|
||||
[editor, currentSelection]
|
||||
);
|
||||
|
||||
const onMouseDown: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(e) => {
|
||||
const range = getCollapsedRange(id, editor.selection as TextSelection);
|
||||
dispatch(
|
||||
rangeSelectionActions.setRange({
|
||||
...range,
|
||||
isDragging: true,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, editor, id]
|
||||
);
|
||||
|
||||
const onMouseMove: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(e) => {
|
||||
if (!rangeRef.current) return;
|
||||
const { isDragging, anchor } = rangeRef.current;
|
||||
if (!isDragging || !anchor || ReactEditor.isFocused(editor)) return;
|
||||
|
||||
const isForward = selectionIsForward(anchor.selection);
|
||||
if (!isForward) {
|
||||
Transforms.select(editor, getEditorEndPoint(editor));
|
||||
}
|
||||
ReactEditor.focus(editor);
|
||||
},
|
||||
[editor, rangeRef]
|
||||
);
|
||||
|
||||
const onMouseUp: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(e) => {
|
||||
if (!rangeRef.current) return;
|
||||
const { isDragging } = rangeRef.current;
|
||||
if (!isDragging) return;
|
||||
dispatch(
|
||||
rangeSelectionActions.setRange({
|
||||
isDragging: false,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, rangeRef]
|
||||
);
|
||||
|
||||
const setLastActiveSelection = useCallback(
|
||||
(lastActiveSelection: Range) => {
|
||||
const selection = lastActiveSelection as TextSelection;
|
||||
@ -102,12 +66,33 @@ export function useTextSelections(id: string, editor: ReactEditor) {
|
||||
ReactEditor.deselect(editor);
|
||||
}, [editor]);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!rangeRef.current) return;
|
||||
const { isDragging, isForward, anchor } = rangeRef.current;
|
||||
if (!isDragging || !anchor) return;
|
||||
if (ReactEditor.isFocused(editor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchor.id === id) {
|
||||
Transforms.select(editor, anchor.selection);
|
||||
} else if (!isForward) {
|
||||
const endSelection = getNodeEndSelection(slateValueToDelta(editor.children));
|
||||
Transforms.select(editor, {
|
||||
anchor: endSelection.anchor,
|
||||
focus: editor.selection?.focus || endSelection.focus,
|
||||
});
|
||||
}
|
||||
ReactEditor.focus(editor);
|
||||
},
|
||||
[editor, id, rangeRef]
|
||||
);
|
||||
|
||||
return {
|
||||
decorate,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onBlur,
|
||||
onMouseMove,
|
||||
setLastActiveSelection,
|
||||
};
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
export const blockConfig: Record<
|
||||
string,
|
||||
{
|
||||
/**
|
||||
* Whether the block can have children
|
||||
*/
|
||||
canAddChild: boolean;
|
||||
/**
|
||||
* The regexps that will be used to match the markdown flag
|
||||
*/
|
||||
markdownRegexps?: RegExp[];
|
||||
|
||||
/**
|
||||
* The default data of the block
|
||||
*/
|
||||
defaultData?: BlockData<any>;
|
||||
|
||||
/**
|
||||
* The props that will be passed to the text split function
|
||||
*/
|
||||
splitProps?: {
|
||||
/**
|
||||
* The relationship between the next line block and the current block
|
||||
*/
|
||||
nextLineRelationShip: SplitRelationship;
|
||||
/**
|
||||
* The type of the next line block
|
||||
*/
|
||||
nextLineBlockType: BlockType;
|
||||
};
|
||||
}
|
||||
> = {
|
||||
export const blockConfig: Record<string, BlockConfig> = {
|
||||
[BlockType.TextBlock]: {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
@ -169,5 +134,49 @@ export const blockConfig: Record<
|
||||
* ```
|
||||
*/
|
||||
markdownRegexps: [/^(```)$/],
|
||||
|
||||
textActionMenuProps: {
|
||||
excludeItems: [TextAction.Code],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultTextActionProps: TextActionMenuProps = {
|
||||
customItems: [
|
||||
TextAction.Turn,
|
||||
TextAction.Bold,
|
||||
TextAction.Italic,
|
||||
TextAction.Underline,
|
||||
TextAction.Strikethrough,
|
||||
TextAction.Code,
|
||||
TextAction.Equation,
|
||||
],
|
||||
excludeItems: [],
|
||||
};
|
||||
|
||||
export const multiLineTextActionProps: TextActionMenuProps = {
|
||||
customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
|
||||
};
|
||||
|
||||
export const multiLineTextActionGroups = [
|
||||
[
|
||||
TextAction.Bold,
|
||||
TextAction.Italic,
|
||||
TextAction.Underline,
|
||||
TextAction.Strikethrough,
|
||||
TextAction.Code,
|
||||
TextAction.Equation,
|
||||
],
|
||||
];
|
||||
|
||||
export const textActionGroups = [
|
||||
[TextAction.Turn],
|
||||
[
|
||||
TextAction.Bold,
|
||||
TextAction.Italic,
|
||||
TextAction.Underline,
|
||||
TextAction.Strikethrough,
|
||||
TextAction.Code,
|
||||
TextAction.Equation,
|
||||
],
|
||||
];
|
||||
|
@ -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 { RegionGrid } from '$app/utils/region_grid';
|
||||
import { ReactEditor } from "slate-react";
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
||||
export enum BlockType {
|
||||
PageBlock = 'page',
|
||||
@ -11,6 +11,7 @@ export enum BlockType {
|
||||
NumberedListBlock = 'numbered_list',
|
||||
ToggleListBlock = 'toggle_list',
|
||||
CodeBlock = 'code',
|
||||
EquationBlock = 'math_equation',
|
||||
EmbedBlock = 'embed',
|
||||
QuoteBlock = 'quote',
|
||||
CalloutBlock = 'callout',
|
||||
@ -87,7 +88,7 @@ export interface NestedBlock<Type = any> {
|
||||
}
|
||||
export interface TextDelta {
|
||||
insert: string;
|
||||
attributes?: Record<string, string | boolean>;
|
||||
attributes?: Record<string, string | boolean | undefined>;
|
||||
}
|
||||
|
||||
export enum BlockActionType {
|
||||
@ -131,16 +132,21 @@ export interface DocumentState {
|
||||
children: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface RectSelectionState {
|
||||
selection: string[];
|
||||
isDragging: boolean;
|
||||
}
|
||||
export interface RangeSelectionState {
|
||||
isDragging?: boolean,
|
||||
anchor?: PointState,
|
||||
focus?: PointState,
|
||||
anchor?: PointState;
|
||||
focus?: PointState;
|
||||
isForward?: boolean;
|
||||
isDragging: boolean;
|
||||
selection: string[];
|
||||
}
|
||||
|
||||
|
||||
export interface PointState {
|
||||
id: string,
|
||||
selection: TextSelection
|
||||
id: string;
|
||||
selection: TextSelection;
|
||||
}
|
||||
|
||||
export enum ChangeType {
|
||||
@ -161,3 +167,62 @@ export interface BlockPBValue {
|
||||
}
|
||||
|
||||
export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, ReactEditor & Editor];
|
||||
|
||||
export enum SplitRelationship {
|
||||
NextSibling,
|
||||
FirstChild,
|
||||
}
|
||||
export enum TextAction {
|
||||
Turn = 'turn',
|
||||
Bold = 'bold',
|
||||
Italic = 'italic',
|
||||
Underline = 'underlined',
|
||||
Strikethrough = 'strikethrough',
|
||||
Code = 'code',
|
||||
Equation = 'equation',
|
||||
}
|
||||
export interface TextActionMenuProps {
|
||||
/**
|
||||
* The custom items that will be covered in the default items
|
||||
*/
|
||||
customItems?: TextAction[];
|
||||
/**
|
||||
* The items that will be excluded from the default items
|
||||
*/
|
||||
excludeItems?: TextAction[];
|
||||
}
|
||||
|
||||
export interface BlockConfig {
|
||||
/**
|
||||
* Whether the block can have children
|
||||
*/
|
||||
canAddChild: boolean;
|
||||
/**
|
||||
* The regexps that will be used to match the markdown flag
|
||||
*/
|
||||
markdownRegexps?: RegExp[];
|
||||
|
||||
/**
|
||||
* The default data of the block
|
||||
*/
|
||||
defaultData?: BlockData<any>;
|
||||
|
||||
/**
|
||||
* The props that will be passed to the text split function
|
||||
*/
|
||||
splitProps?: {
|
||||
/**
|
||||
* The relationship between the next line block and the current block
|
||||
*/
|
||||
nextLineRelationShip: SplitRelationship;
|
||||
/**
|
||||
* The type of the next line block
|
||||
*/
|
||||
nextLineBlockType: BlockType;
|
||||
};
|
||||
|
||||
/**
|
||||
* The props that will be passed to the text action menu
|
||||
*/
|
||||
textActionMenuProps?: TextActionMenuProps;
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { outdentNodeThunk } from './outdent';
|
||||
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
|
||||
import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/blocks/text/merge';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
||||
/**
|
||||
* 1. If current node is not text block, turn it to text block
|
||||
@ -14,8 +15,8 @@ import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/block
|
||||
*/
|
||||
export const backspaceNodeThunk = createAsyncThunk(
|
||||
'document/backspaceNode',
|
||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||
const { id, controller } = payload;
|
||||
async (payload: { id: string; controller: DocumentController; editor: ReactEditor }, thunkAPI) => {
|
||||
const { id, controller, editor } = payload;
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const state = (getState() as { document: DocumentState }).document;
|
||||
const node = state.nodes[id];
|
||||
@ -33,6 +34,7 @@ export const backspaceNodeThunk = createAsyncThunk(
|
||||
// merge to previous line when parent is root
|
||||
if (parentIsRoot || nextNodeId) {
|
||||
// merge to previous line
|
||||
ReactEditor.deselect(editor);
|
||||
await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true }));
|
||||
return;
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { DocumentState } from '$app/interfaces/document';
|
||||
import { DocumentState, SplitRelationship } from '$app/interfaces/document';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { setCursorBeforeThunk } from '../../cursor';
|
||||
import { newBlock } from '$app/utils/document/blocks/common';
|
||||
import { blockConfig, SplitRelationship } from '$app/constants/document/config';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta';
|
||||
import { ReactEditor } from "slate-react";
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
||||
export const splitNodeThunk = createAsyncThunk(
|
||||
'document/splitNode',
|
||||
|
@ -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 { DocumentState, RangeSelectionState, TextSelection } from '$app/interfaces/document';
|
||||
import { DocumentState, TextSelection } from '$app/interfaces/document';
|
||||
import { rangeSelectionActions } from '$app_reducers/document/slice';
|
||||
import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta';
|
||||
import { getNodeBeginSelection, getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
|
||||
import { isEqual } from '$app/utils/tool';
|
||||
import { RootState } from '$app/stores/store';
|
||||
import { getNodesInRange } from '$app/utils/document/blocks/common';
|
||||
|
||||
const amendAnchorNodeThunk = createAsyncThunk(
|
||||
'document/amendAnchorNode',
|
||||
@ -15,22 +17,18 @@ const amendAnchorNodeThunk = createAsyncThunk(
|
||||
const { id } = payload;
|
||||
const { getState, dispatch } = thunkAPI;
|
||||
const nodes = (getState() as { document: DocumentState }).document.nodes;
|
||||
const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection;
|
||||
const { anchor: anchorNode, isDragging, focus: focusNode } = range;
|
||||
|
||||
const state = getState() as RootState;
|
||||
const { isDragging, isForward, ...range } = state.documentRangeSelection;
|
||||
const { anchor: anchorNode, focus: focusNode } = range;
|
||||
|
||||
if (!isDragging || !anchorNode || anchorNode.id !== id) return;
|
||||
const isCollapsed = focusNode?.id === id && anchorNode?.id === id;
|
||||
if (isCollapsed) return;
|
||||
|
||||
const selection = anchorNode.selection;
|
||||
const isForward = selectionIsForward(selection);
|
||||
const node = nodes[id];
|
||||
const focus = isForward
|
||||
? getNodeEndSelection(node.data.delta).anchor
|
||||
: {
|
||||
path: [0, 0],
|
||||
offset: 0,
|
||||
};
|
||||
const focus = isForward ? getNodeEndSelection(node.data.delta).anchor : getNodeBeginSelection().anchor;
|
||||
if (isEqual(focus, selection.focus)) return;
|
||||
const newSelection = {
|
||||
anchor: selection.anchor,
|
||||
@ -58,29 +56,64 @@ export const syncRangeSelectionThunk = createAsyncThunk(
|
||||
thunkAPI
|
||||
) => {
|
||||
const { getState, dispatch } = thunkAPI;
|
||||
const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection;
|
||||
const state = getState() as RootState;
|
||||
const range = state.documentRangeSelection;
|
||||
const isDragging = range.isDragging;
|
||||
|
||||
const { id, selection } = payload;
|
||||
|
||||
const updateRange = {
|
||||
focus: {
|
||||
id,
|
||||
selection,
|
||||
},
|
||||
};
|
||||
const isAnchor = range.anchor?.id === id;
|
||||
if (isAnchor) {
|
||||
|
||||
if (!isDragging && range.anchor?.id === id) {
|
||||
Object.assign(updateRange, {
|
||||
anchor: {
|
||||
id,
|
||||
selection,
|
||||
selection: { ...selection },
|
||||
},
|
||||
});
|
||||
dispatch(rangeSelectionActions.setRange(updateRange));
|
||||
return;
|
||||
}
|
||||
if (!range.anchor || range.anchor.id === id) {
|
||||
Object.assign(updateRange, {
|
||||
anchor: {
|
||||
id,
|
||||
selection: {
|
||||
anchor: !range.anchor ? selection.anchor : range.anchor.selection.anchor,
|
||||
focus: selection.focus,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(rangeSelectionActions.setRange(updateRange));
|
||||
|
||||
const anchorId = range.anchor?.id;
|
||||
if (!isAnchor && anchorId) {
|
||||
// more than one node is selected
|
||||
if (anchorId && anchorId !== id) {
|
||||
dispatch(amendAnchorNodeThunk({ id: anchorId }));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const setRangeSelectionThunk = createAsyncThunk('document/setRangeSelection', async (payload, thunkAPI) => {
|
||||
const { getState, dispatch } = thunkAPI;
|
||||
const state = getState() as RootState;
|
||||
const { anchor, focus, isForward } = state.documentRangeSelection;
|
||||
const document = state.document;
|
||||
if (!anchor || !focus || isForward === undefined) return;
|
||||
const rangeIds = getNodesInRange(
|
||||
{
|
||||
startId: anchor.id,
|
||||
endId: focus.id,
|
||||
},
|
||||
isForward,
|
||||
document
|
||||
);
|
||||
dispatch(rangeSelectionActions.setSelection(rangeIds));
|
||||
});
|
||||
|
@ -1,16 +1,29 @@
|
||||
import { DocumentState, Node, RangeSelectionState } from '@/appflowy_app/interfaces/document';
|
||||
import {
|
||||
DocumentState,
|
||||
Node,
|
||||
PointState,
|
||||
RangeSelectionState,
|
||||
RectSelectionState,
|
||||
} from '@/appflowy_app/interfaces/document';
|
||||
import { BlockEventPayloadPB } from '@/services/backend';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { parseValue, matchChange } from '$app/utils/document/subscribe';
|
||||
import { getNodesInRange } from '$app/utils/document/blocks/common';
|
||||
|
||||
const initialState: DocumentState = {
|
||||
nodes: {},
|
||||
children: {},
|
||||
};
|
||||
|
||||
const rectSelectionInitialState: string[] = [];
|
||||
const rectSelectionInitialState: RectSelectionState = {
|
||||
selection: [],
|
||||
isDragging: false,
|
||||
};
|
||||
|
||||
const rangeSelectionInitialState: RangeSelectionState = {};
|
||||
const rangeSelectionInitialState: RangeSelectionState = {
|
||||
isDragging: false,
|
||||
selection: [],
|
||||
};
|
||||
|
||||
export const documentSlice = createSlice({
|
||||
name: 'document',
|
||||
@ -35,7 +48,6 @@ export const documentSlice = createSlice({
|
||||
state.nodes = nodes;
|
||||
state.children = children;
|
||||
},
|
||||
|
||||
/**
|
||||
This function listens for changes in the data layer triggered by the data API,
|
||||
and updates the UI state accordingly.
|
||||
@ -67,14 +79,18 @@ export const rectSelectionSlice = createSlice({
|
||||
reducers: {
|
||||
// update block selections
|
||||
updateSelections: (state, action: PayloadAction<string[]>) => {
|
||||
return action.payload;
|
||||
state.selection = action.payload;
|
||||
},
|
||||
|
||||
// set block selected
|
||||
setSelectionById: (state, action: PayloadAction<string>) => {
|
||||
const id = action.payload;
|
||||
if (state.includes(id)) return;
|
||||
state.push(id);
|
||||
if (state.selection.includes(id)) return;
|
||||
state.selection = [...state.selection, id];
|
||||
},
|
||||
|
||||
setDragging: (state, action: PayloadAction<boolean>) => {
|
||||
state.isDragging = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -83,13 +99,27 @@ export const rangeSelectionSlice = createSlice({
|
||||
name: 'documentRangeSelection',
|
||||
initialState: rangeSelectionInitialState,
|
||||
reducers: {
|
||||
setRange: (state, action: PayloadAction<RangeSelectionState>) => {
|
||||
setRange: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
anchor?: PointState;
|
||||
focus?: PointState;
|
||||
}>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
};
|
||||
},
|
||||
|
||||
setSelection: (state, action: PayloadAction<string[]>) => {
|
||||
state.selection = action.payload;
|
||||
},
|
||||
setDragging: (state, action: PayloadAction<boolean>) => {
|
||||
state.isDragging = action.payload;
|
||||
},
|
||||
setForward: (state, action: PayloadAction<boolean>) => {
|
||||
state.isForward = action.payload;
|
||||
},
|
||||
clearRange: (state, _: PayloadAction) => {
|
||||
return rangeSelectionInitialState;
|
||||
},
|
||||
|
@ -28,7 +28,7 @@ import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-visual-basic';
|
||||
|
||||
import { BaseRange, NodeEntry, Text, Path } from 'slate';
|
||||
import { BaseRange, NodeEntry, Text, Path, Range, Editor } from 'slate';
|
||||
|
||||
const push_string = (
|
||||
token: string | Prism.Token,
|
||||
|
@ -151,9 +151,54 @@ export function getCollapsedRange(id: string, selection: TextSelection): RangeSe
|
||||
anchor: clone(point),
|
||||
focus: clone(point),
|
||||
isDragging: false,
|
||||
selection: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function iterateNodes(
|
||||
range: {
|
||||
startId: string;
|
||||
endId: string;
|
||||
},
|
||||
isForward: boolean,
|
||||
document: DocumentState,
|
||||
callback: (nodeId?: string) => boolean
|
||||
) {
|
||||
const { startId, endId } = range;
|
||||
let currentId = startId;
|
||||
while (currentId && currentId !== endId) {
|
||||
if (isForward) {
|
||||
currentId = getNextLineId(document, currentId) || '';
|
||||
} else {
|
||||
currentId = getPrevLineId(document, currentId) || '';
|
||||
}
|
||||
if (callback(currentId)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
export function getNodesInRange(
|
||||
range: {
|
||||
startId: string;
|
||||
endId: string;
|
||||
},
|
||||
isForward: boolean,
|
||||
document: DocumentState
|
||||
) {
|
||||
const nodeIds: string[] = [];
|
||||
nodeIds.push(range.startId);
|
||||
iterateNodes(range, isForward, document, (nodeId) => {
|
||||
if (nodeId) {
|
||||
nodeIds.push(nodeId);
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
nodeIds.push(range.endId);
|
||||
return nodeIds;
|
||||
}
|
||||
|
||||
export function nodeInRange(
|
||||
id: string,
|
||||
range: {
|
||||
@ -163,17 +208,13 @@ export function nodeInRange(
|
||||
isForward: boolean,
|
||||
document: DocumentState
|
||||
) {
|
||||
const { startId, endId } = range;
|
||||
let currentId = startId;
|
||||
while (currentId && currentId !== id && currentId !== endId) {
|
||||
if (isForward) {
|
||||
currentId = getNextLineId(document, currentId) || '';
|
||||
} else {
|
||||
currentId = getPrevLineId(document, currentId) || '';
|
||||
let match = false;
|
||||
iterateNodes(range, isForward, document, (nodeId) => {
|
||||
if (nodeId === id) {
|
||||
match = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (currentId === id) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return false;
|
||||
});
|
||||
return match;
|
||||
}
|
||||
|
@ -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 * as Y from 'yjs';
|
||||
import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
|
||||
@ -14,6 +14,86 @@ export function getDelta(editor: Editor, at: Location): TextDelta[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function getBeforeRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
|
||||
const anchor = Range.start(range);
|
||||
const sliceNodes = delta.slice(0, anchor.path[1] + 1);
|
||||
const sliceEnd = sliceNodes[sliceNodes.length - 1];
|
||||
const sliceEndText = sliceEnd.insert.slice(0, anchor.offset);
|
||||
const sliceEndAttributes = sliceEnd.attributes;
|
||||
const sliceEndNode =
|
||||
sliceEndText.length > 0
|
||||
? {
|
||||
insert: sliceEndText,
|
||||
attributes: sliceEndAttributes,
|
||||
}
|
||||
: null;
|
||||
const sliceMiddleNodes = sliceNodes.slice(0, sliceNodes.length - 1);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return [...sliceMiddleNodes, sliceEndNode].filter((item) => item);
|
||||
}
|
||||
|
||||
export function getAfterRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
|
||||
const focus = Range.end(range);
|
||||
const sliceNodes = delta.slice(focus.path[1], delta.length);
|
||||
const sliceStart = sliceNodes[0];
|
||||
const sliceStartText = sliceStart.insert.slice(focus.offset);
|
||||
const sliceStartAttributes = sliceStart.attributes;
|
||||
const sliceStartNode =
|
||||
sliceStartText.length > 0
|
||||
? {
|
||||
insert: sliceStartText,
|
||||
attributes: sliceStartAttributes,
|
||||
}
|
||||
: null;
|
||||
const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return [sliceStartNode, ...sliceMiddleNodes].filter((item) => item);
|
||||
}
|
||||
|
||||
export function getRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
|
||||
const anchor = Range.start(range);
|
||||
const focus = Range.end(range);
|
||||
const sliceNodes = delta.slice(anchor.path[1], focus.path[1] + 1);
|
||||
if (anchor.path[1] === focus.path[1]) {
|
||||
return sliceNodes.map((item) => {
|
||||
const { insert, attributes } = item;
|
||||
const text = insert.slice(anchor.offset, focus.offset);
|
||||
return {
|
||||
insert: text,
|
||||
attributes,
|
||||
};
|
||||
});
|
||||
}
|
||||
const sliceStart = sliceNodes[0];
|
||||
const sliceEnd = sliceNodes[sliceNodes.length - 1];
|
||||
const sliceStartText = sliceStart.insert.slice(anchor.offset);
|
||||
const sliceEndText = sliceEnd.insert.slice(0, focus.offset);
|
||||
const sliceStartAttributes = sliceStart.attributes;
|
||||
const sliceEndAttributes = sliceEnd.attributes;
|
||||
const sliceStartNode =
|
||||
sliceStartText.length > 0
|
||||
? {
|
||||
insert: sliceStartText,
|
||||
attributes: sliceStartAttributes,
|
||||
}
|
||||
: null;
|
||||
|
||||
const sliceEndNode =
|
||||
sliceEndText.length > 0
|
||||
? {
|
||||
insert: sliceEndText,
|
||||
attributes: sliceEndAttributes,
|
||||
}
|
||||
: null;
|
||||
const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length - 1);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return [sliceStartNode, ...sliceMiddleNodes, sliceEndNode].filter((item) => item);
|
||||
}
|
||||
/**
|
||||
* get the selection between the beginning of the editor and the point
|
||||
* form 0 to point
|
||||
@ -290,7 +370,8 @@ export function getPointOfCurrentLineBeginning(editor: Editor) {
|
||||
return beginPoint;
|
||||
}
|
||||
|
||||
export function selectionIsForward(selection: TextSelection) {
|
||||
export function selectionIsForward(selection: TextSelection | null) {
|
||||
if (!selection) return false;
|
||||
const { anchor, focus } = selection;
|
||||
if (!anchor || !focus) return false;
|
||||
return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset);
|
||||
|
@ -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 { toggleFormat } from './format';
|
||||
import { Editor, Range } from 'slate';
|
||||
import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta';
|
||||
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
|
||||
@ -13,16 +12,6 @@ const HOTKEYS: Record<string, string> = {
|
||||
'mod+shift+S': 'strikethrough',
|
||||
};
|
||||
|
||||
export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
|
||||
for (const hotkey in HOTKEYS) {
|
||||
if (isHotkey(hotkey, event)) {
|
||||
event.preventDefault();
|
||||
const format = HOTKEYS[hotkey];
|
||||
toggleFormat(editor, format);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
|
||||
const isBackspaceKey = isHotkey('backspace', event);
|
||||
const selection = editor.selection;
|
||||
|
@ -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 { DocumentControllerContext } from '../stores/effects/document/document_controller';
|
||||
|
||||
const theme = createTheme({
|
||||
const muiTheme = createTheme({
|
||||
typography: {
|
||||
fontFamily: ['Poppins'].join(','),
|
||||
fontSize: 14,
|
||||
},
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#00BCF0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -14,7 +20,7 @@ export const DocumentPage = () => {
|
||||
|
||||
if (!documentId || !documentData || !controller) return null;
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<ThemeProvider theme={muiTheme}>
|
||||
<DocumentControllerContext.Provider value={controller}>
|
||||
<Root documentData={documentData} />
|
||||
</DocumentControllerContext.Provider>
|
||||
|
@ -4,3 +4,5 @@ export * from "./models/flowy-folder2";
|
||||
export * from "./models/flowy-document2";
|
||||
export * from "./models/flowy-net";
|
||||
export * from "./models/flowy-error";
|
||||
export * from "./models/flowy-config";
|
||||
|
||||
|
@ -24,7 +24,6 @@ body {
|
||||
@apply bg-[transparent]
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
@apply rounded-xl border border-gray-500 px-4 py-3;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user