mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Support divider block and callout block (#2457)
* feat: divider block * feat: callout block
This commit is contained in:
parent
96c058db9b
commit
ba8cbe170c
frontend/appflowy_tauri
package.jsonpnpm-lock.yaml
src/appflowy_app
components/document
BlockHorizontalToolbar
BlockSelection
CalloutBlock
DividerBlock
Node
TextBlock
_shared
constants/document
interfaces
stores/reducers/document/async-actions
utils/document/blocks
@ -15,6 +15,8 @@
|
||||
"tauri:dev": "tauri dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@mui/icons-material": "^5.11.11",
|
||||
@ -24,6 +26,7 @@
|
||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"events": "^3.3.0",
|
||||
"google-protobuf": "^3.21.2",
|
||||
"i18next": "^22.4.10",
|
||||
|
@ -1,6 +1,8 @@
|
||||
lockfileVersion: 5.4
|
||||
|
||||
specifiers:
|
||||
'@emoji-mart/data': ^1.1.2
|
||||
'@emoji-mart/react': ^1.1.1
|
||||
'@emotion/react': ^11.10.6
|
||||
'@emotion/styled': ^11.10.6
|
||||
'@mui/icons-material': ^5.11.11
|
||||
@ -23,6 +25,7 @@ specifiers:
|
||||
'@vitejs/plugin-react': ^3.0.0
|
||||
autoprefixer: ^10.4.13
|
||||
dayjs: ^1.11.7
|
||||
emoji-mart: ^5.5.2
|
||||
eslint: ^8.34.0
|
||||
eslint-plugin-react: ^7.32.2
|
||||
eslint-plugin-react-hooks: ^4.6.0
|
||||
@ -60,6 +63,8 @@ specifiers:
|
||||
yjs: ^13.5.51
|
||||
|
||||
dependencies:
|
||||
'@emoji-mart/data': 1.1.2
|
||||
'@emoji-mart/react': 1.1.1_kyrnz3vmphzqyjjk2ivrm6bcsu
|
||||
'@emotion/react': 11.10.6_pmekkgnqduwlme35zpnqhenc34
|
||||
'@emotion/styled': 11.10.6_oouaibmszuch5k64ms7uxp2aia
|
||||
'@mui/icons-material': 5.11.11_ao76n7r2cajsoyr3cbwrn7geoi
|
||||
@ -69,6 +74,7 @@ dependencies:
|
||||
'@tanstack/react-virtual': 3.0.0-beta.54_react@18.2.0
|
||||
'@tauri-apps/api': 1.2.0
|
||||
dayjs: 1.11.7
|
||||
emoji-mart: 5.5.2
|
||||
events: 3.3.0
|
||||
google-protobuf: 3.21.2
|
||||
i18next: 22.4.10
|
||||
@ -467,6 +473,20 @@ packages:
|
||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
||||
dev: false
|
||||
|
||||
/@emoji-mart/data/1.1.2:
|
||||
resolution: {integrity: sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg==}
|
||||
dev: false
|
||||
|
||||
/@emoji-mart/react/1.1.1_kyrnz3vmphzqyjjk2ivrm6bcsu:
|
||||
resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==}
|
||||
peerDependencies:
|
||||
emoji-mart: ^5.2
|
||||
react: ^16.8 || ^17 || ^18
|
||||
dependencies:
|
||||
emoji-mart: 5.5.2
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@emotion/babel-plugin/11.10.6:
|
||||
resolution: {integrity: sha512-p2dAqtVrkhSa7xz1u/m9eHYdLi+en8NowrmXeF/dKtJpU8lCWli8RUAati7NcSl0afsBott48pdnANuD0wh9QQ==}
|
||||
dependencies:
|
||||
@ -2308,6 +2328,10 @@ packages:
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/emoji-mart/5.5.2:
|
||||
resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
|
||||
dev: false
|
||||
|
||||
/emoji-regex/8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
dev: false
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { toggleFormat, isFormatActive } from '$app/utils/document/slate/format';
|
||||
import { toggleFormat, isFormatActive } from '$app/utils/document/blocks/text/format';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useFocused, useSlate } from 'slate-react';
|
||||
import { calcToolbarPosition } from '$app/utils/document/slate/toolbar';
|
||||
import { calcToolbarPosition } from '$app/utils/document/blocks/text/toolbar';
|
||||
export function useHoveringToolbar(id: string) {
|
||||
const editor = useSlate();
|
||||
const inFocus = useFocused();
|
||||
|
@ -133,14 +133,16 @@ export function useBlockSelection({
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
document.addEventListener('mousedown', handleDragStart);
|
||||
document.addEventListener('mousemove', handleDraging);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
const doc = document.getElementById('appflowy-block-doc');
|
||||
if (!doc) return;
|
||||
doc.addEventListener('mousedown', handleDragStart);
|
||||
doc.addEventListener('mousemove', handleDraging);
|
||||
doc.addEventListener('mouseup', handleDragEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleDragStart);
|
||||
document.removeEventListener('mousemove', handleDraging);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
doc.removeEventListener('mousedown', handleDragStart);
|
||||
doc.removeEventListener('mousemove', handleDraging);
|
||||
doc.removeEventListener('mouseup', handleDragEnd);
|
||||
};
|
||||
}, [handleDragStart, handleDragEnd, handleDraging]);
|
||||
|
||||
|
@ -0,0 +1,48 @@
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import emojiData, { EmojiMartData, Emoji } from '@emoji-mart/data';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
||||
|
||||
export function useCalloutBlock(nodeId: string) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
const open = useMemo(() => Boolean(anchorEl), [anchorEl]);
|
||||
const id = useMemo(() => (open ? 'emoji-popover' : undefined), [open]);
|
||||
const dispatch = useAppDispatch();
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
|
||||
const closeEmojiSelect = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const openEmojiSelect = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const onEmojiSelect = useCallback(
|
||||
(emoji: { native: string }) => {
|
||||
if (!controller) return;
|
||||
console.log('emoji', emoji.native);
|
||||
void dispatch(
|
||||
updateNodeDataThunk({
|
||||
id: nodeId,
|
||||
controller,
|
||||
data: {
|
||||
icon: emoji.native,
|
||||
},
|
||||
})
|
||||
);
|
||||
closeEmojiSelect();
|
||||
},
|
||||
[controller, dispatch, nodeId, closeEmojiSelect]
|
||||
);
|
||||
|
||||
return {
|
||||
anchorEl,
|
||||
closeEmojiSelect,
|
||||
openEmojiSelect,
|
||||
open,
|
||||
id,
|
||||
onEmojiSelect,
|
||||
};
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
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 emojiData from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
|
||||
|
||||
export default function CalloutBlock({
|
||||
node,
|
||||
childIds,
|
||||
}: {
|
||||
node: NestedBlock<BlockType.CalloutBlock>;
|
||||
childIds?: string[];
|
||||
}) {
|
||||
const { openEmojiSelect, open, closeEmojiSelect, id, anchorEl, onEmojiSelect } = useCalloutBlock(node.id);
|
||||
|
||||
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={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
|
||||
<IconButton
|
||||
aria-describedby={id}
|
||||
onClick={openEmojiSelect}
|
||||
className={`m-0 h-[100%] w-[100%] rounded-full p-0 transition`}
|
||||
>
|
||||
{node.data.icon}
|
||||
</IconButton>
|
||||
<Popover
|
||||
id={id}
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={closeEmojiSelect}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<Picker searchPosition={'static'} locale={'en'} autoFocus data={emojiData} onEmojiSelect={onEmojiSelect} />
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<div>
|
||||
<TextBlock node={node} />
|
||||
</div>
|
||||
<NodeChildren childIds={childIds} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
export default function DividerBlock() {
|
||||
return (
|
||||
<div className={`flex h-[1em] w-[100%] items-center justify-center`}>
|
||||
<div className={'h-[1px] w-[100%] bg-shade-5'} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -13,6 +13,8 @@ import QuoteBlock from '$app/components/document/QuoteBlock';
|
||||
import BulletedListBlock from '$app/components/document/BulletedListBlock';
|
||||
import NumberedListBlock from '$app/components/document/NumberedListBlock';
|
||||
import ToggleListBlock from '$app/components/document/ToggleListBlock';
|
||||
import DividerBlock from '$app/components/document/DividerBlock';
|
||||
import CalloutBlock from '$app/components/document/CalloutBlock';
|
||||
|
||||
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { node, childIds, isSelected, ref } = useNode(id);
|
||||
@ -40,6 +42,12 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
|
||||
case BlockType.ToggleListBlock: {
|
||||
return <ToggleListBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
case BlockType.DividerBlock: {
|
||||
return <DividerBlock />;
|
||||
}
|
||||
case BlockType.CalloutBlock: {
|
||||
return <CalloutBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<Alert severity='info' className='mb-2'>
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
canHandleUpKey,
|
||||
onHandleEnterKey,
|
||||
triggerHotkey,
|
||||
} from '$app/utils/document/slate/hotkey';
|
||||
} from '$app/utils/document/blocks/text/hotkey';
|
||||
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
|
||||
import { useActions } from './Actions.hooks';
|
||||
|
||||
|
@ -3,10 +3,10 @@ import { BlockData, BlockType, TextBlockKeyEventHandlerParams } from '$app/inter
|
||||
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
|
||||
import { turnToBlockThunk, turnToDividerBlockThunk } from '$app_reducers/document/async-actions';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { Editor } from 'slate';
|
||||
import { getBeforeRangeAt } from '$app/utils/document/slate/text';
|
||||
import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
|
||||
import {
|
||||
getHeadingDataFromEditor,
|
||||
getQuoteDataFromEditor,
|
||||
@ -14,27 +14,29 @@ import {
|
||||
getBulletedDataFromEditor,
|
||||
getNumberedListDataFromEditor,
|
||||
getToggleListDataFromEditor,
|
||||
getCalloutDataFromEditor,
|
||||
} from '$app/utils/document/blocks';
|
||||
|
||||
const blockDataFactoryMap: Record<string, (editor: Editor) => BlockData<any> | undefined> = {
|
||||
[BlockType.HeadingBlock]: getHeadingDataFromEditor,
|
||||
[BlockType.TodoListBlock]: getTodoListDataFromEditor,
|
||||
[BlockType.QuoteBlock]: getQuoteDataFromEditor,
|
||||
[BlockType.BulletedListBlock]: getBulletedDataFromEditor,
|
||||
[BlockType.NumberedListBlock]: getNumberedListDataFromEditor,
|
||||
[BlockType.ToggleListBlock]: getToggleListDataFromEditor,
|
||||
};
|
||||
import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
|
||||
|
||||
export function useTurnIntoBlock(id: string) {
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const turnIntoBlockEvents = useMemo(() => {
|
||||
return Object.entries(blockDataFactoryMap).map(([type, getData]) => {
|
||||
const spaceTriggerEvents = Object.entries({
|
||||
[BlockType.HeadingBlock]: getHeadingDataFromEditor,
|
||||
[BlockType.TodoListBlock]: getTodoListDataFromEditor,
|
||||
[BlockType.QuoteBlock]: getQuoteDataFromEditor,
|
||||
[BlockType.BulletedListBlock]: getBulletedDataFromEditor,
|
||||
[BlockType.NumberedListBlock]: getNumberedListDataFromEditor,
|
||||
[BlockType.ToggleListBlock]: getToggleListDataFromEditor,
|
||||
[BlockType.CalloutBlock]: getCalloutDataFromEditor,
|
||||
}).map(([type, getData]) => {
|
||||
const blockType = type as BlockType;
|
||||
const triggerKey = keyBoardEventKeyMap.Space;
|
||||
return {
|
||||
triggerEventKey: keyBoardEventKeyMap.Space,
|
||||
canHandle: canHandle(blockType),
|
||||
canHandle: canHandle(blockType, triggerKey),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
if (!controller) return;
|
||||
const [_event, editor] = args;
|
||||
@ -43,7 +45,20 @@ export function useTurnIntoBlock(id: string) {
|
||||
dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
return [
|
||||
...spaceTriggerEvents,
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Reduce,
|
||||
canHandle: canHandle(BlockType.DividerBlock, keyBoardEventKeyMap.Reduce),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
if (!controller) return;
|
||||
const [_event, editor] = args;
|
||||
const delta = getDeltaAfterSelection(editor) || [];
|
||||
dispatch(turnToDividerBlockThunk({ id, controller, delta }));
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [controller, dispatch, id]);
|
||||
|
||||
return {
|
||||
@ -51,7 +66,7 @@ export function useTurnIntoBlock(id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function canHandle(type: BlockType) {
|
||||
function canHandle(type: BlockType, triggerKey: string) {
|
||||
const config = blockConfig[type];
|
||||
|
||||
const regex = config.markdownRegexps;
|
||||
@ -62,16 +77,16 @@ function canHandle(type: BlockType) {
|
||||
|
||||
return (...args: TextBlockKeyEventHandlerParams) => {
|
||||
const [event, editor] = args;
|
||||
const isSpaceKey = event.key === keyBoardEventKeyMap.Space;
|
||||
const isTrigger = event.key === triggerKey;
|
||||
const selection = editor.selection;
|
||||
|
||||
if (!isSpaceKey || !selection) {
|
||||
if (!isTrigger || !selection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flag = Editor.string(editor, getBeforeRangeAt(editor, selection)).trim();
|
||||
if (flag === null) return false;
|
||||
|
||||
return regex.some((r) => r.test(flag));
|
||||
return regex.some((r) => r.test(`${flag}${triggerKey}`));
|
||||
};
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import Leaf from './Leaf';
|
||||
import { useTextBlock } from './TextBlock.hooks';
|
||||
import BlockHorizontalToolbar from '../BlockHorizontalToolbar';
|
||||
import React from 'react';
|
||||
import { NestedBlock } from '$app/interfaces/document';
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import NodeChildren from '$app/components/document/Node/NodeChildren';
|
||||
|
||||
function TextBlock({
|
||||
|
@ -11,7 +11,8 @@ import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store';
|
||||
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
|
||||
import { deltaToSlateValue, getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
|
||||
import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
|
||||
import { isSameDelta } from '$app/utils/document/blocks/text';
|
||||
|
||||
import { isSameDelta } from '$app/utils/document/blocks/text/delta';
|
||||
|
||||
export function useTextInput(id: string) {
|
||||
const dispatch = useAppDispatch();
|
||||
@ -175,7 +176,7 @@ function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
|
||||
const children = getDeltaFromSlateNodes(editor.children);
|
||||
|
||||
// the path always has 2 elements,
|
||||
// because the slate node is a two-dimensional array
|
||||
// because the text node is a two-dimensional array
|
||||
const index = path[1];
|
||||
// It is possible that the current selection is out of range
|
||||
if (children[index].insert.length < offset) {
|
||||
|
@ -58,7 +58,7 @@ export const blockConfig: Record<
|
||||
/**
|
||||
* # or ## or ###
|
||||
*/
|
||||
markdownRegexps: [/^(#{1,3})$/],
|
||||
markdownRegexps: [/^(#{1,3})(\s)+$/],
|
||||
},
|
||||
[BlockType.TodoListBlock]: {
|
||||
canAddChild: true,
|
||||
@ -73,7 +73,7 @@ export const blockConfig: Record<
|
||||
/**
|
||||
* -[] or -[x] or -[ ] or [] or [x] or [ ]
|
||||
*/
|
||||
markdownRegexps: [/^((-)?\[(x|\s)?\])$/],
|
||||
markdownRegexps: [/^((-)?\[(x|\s)?\])(\s)+$/],
|
||||
},
|
||||
[BlockType.BulletedListBlock]: {
|
||||
canAddChild: true,
|
||||
@ -88,7 +88,7 @@ export const blockConfig: Record<
|
||||
/**
|
||||
* - or + or *
|
||||
*/
|
||||
markdownRegexps: [/^(\s*[-+*])$/],
|
||||
markdownRegexps: [/^(\s*[-+*])(\s)+$/],
|
||||
},
|
||||
[BlockType.NumberedListBlock]: {
|
||||
canAddChild: true,
|
||||
@ -104,7 +104,7 @@ export const blockConfig: Record<
|
||||
* 1. or 2. or 3.
|
||||
* a. or b. or c.
|
||||
*/
|
||||
markdownRegexps: [/^(\s*[\d|a-zA-Z]+\.)$/],
|
||||
markdownRegexps: [/^(\s*[\d|a-zA-Z]+\.)(\s)+$/],
|
||||
},
|
||||
[BlockType.QuoteBlock]: {
|
||||
canAddChild: true,
|
||||
@ -119,7 +119,22 @@ export const blockConfig: Record<
|
||||
/**
|
||||
* " or “ or ”
|
||||
*/
|
||||
markdownRegexps: [/^("|“|”)$/],
|
||||
markdownRegexps: [/^("|“|”)(\s)+$/],
|
||||
},
|
||||
[BlockType.CalloutBlock]: {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
icon: 'bulb',
|
||||
},
|
||||
splitProps: {
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
nextLineBlockType: BlockType.TextBlock,
|
||||
},
|
||||
/**
|
||||
* [!TIP] or [!INFO] or [!WARNING] or [!DANGER]
|
||||
*/
|
||||
markdownRegexps: [/^(\[!)(TIP|INFO|WARNING|DANGER)(\])(\s)+$/],
|
||||
},
|
||||
[BlockType.ToggleListBlock]: {
|
||||
canAddChild: true,
|
||||
@ -134,8 +149,16 @@ export const blockConfig: Record<
|
||||
/**
|
||||
* >
|
||||
*/
|
||||
markdownRegexps: [/^(>)$/],
|
||||
markdownRegexps: [/^(>)(\s)+$/],
|
||||
},
|
||||
[BlockType.DividerBlock]: {
|
||||
canAddChild: false,
|
||||
/**
|
||||
* ---
|
||||
*/
|
||||
markdownRegexps: [/^(-{3,})$/],
|
||||
},
|
||||
|
||||
[BlockType.CodeBlock]: {
|
||||
canAddChild: false,
|
||||
/**
|
||||
|
@ -7,4 +7,5 @@ export const keyBoardEventKeyMap = {
|
||||
Left: 'ArrowLeft',
|
||||
Right: 'ArrowRight',
|
||||
Space: ' ',
|
||||
Reduce: '-',
|
||||
};
|
||||
|
@ -42,10 +42,16 @@ export interface QuoteBlockData extends TextBlockData {
|
||||
size: 'default' | 'large';
|
||||
}
|
||||
|
||||
export interface CalloutBlockData extends TextBlockData {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface TextBlockData {
|
||||
delta: TextDelta[];
|
||||
}
|
||||
|
||||
export interface DividerBlockData {}
|
||||
|
||||
export type PageBlockData = TextBlockData;
|
||||
|
||||
export type BlockData<Type> = Type extends BlockType.HeadingBlock
|
||||
@ -62,7 +68,13 @@ export type BlockData<Type> = Type extends BlockType.HeadingBlock
|
||||
? NumberedListBlockData
|
||||
: Type extends BlockType.ToggleListBlock
|
||||
? ToggleListBlockData
|
||||
: TextBlockData;
|
||||
: Type extends BlockType.DividerBlock
|
||||
? DividerBlockData
|
||||
: Type extends BlockType.CalloutBlock
|
||||
? CalloutBlockData
|
||||
: Type extends BlockType.TextBlock
|
||||
? TextBlockData
|
||||
: any;
|
||||
|
||||
export interface NestedBlock<Type = any> {
|
||||
id: string;
|
||||
|
@ -1,22 +1,28 @@
|
||||
import { DocumentState } from '$app/interfaces/document';
|
||||
import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { newTextBlock } from '$app/utils/document/blocks/text';
|
||||
import { newBlock } from '$app/utils/document/blocks/common';
|
||||
|
||||
export const insertAfterNodeThunk = createAsyncThunk(
|
||||
'document/insertAfterNode',
|
||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||
const { controller } = payload;
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
async (payload: { id: string; controller: DocumentController; data?: BlockData<any>; type?: BlockType }, thunkAPI) => {
|
||||
const {
|
||||
controller,
|
||||
type = BlockType.TextBlock,
|
||||
data = {
|
||||
delta: [],
|
||||
},
|
||||
} = payload;
|
||||
const { getState } = thunkAPI;
|
||||
const state = getState() as { document: DocumentState };
|
||||
const node = state.document.nodes[payload.id];
|
||||
if (!node) return;
|
||||
const parentId = node.parent;
|
||||
if (!parentId) return;
|
||||
// create new node
|
||||
const newNode = newTextBlock(parentId, {
|
||||
delta: [],
|
||||
});
|
||||
const newNode = newBlock<any>(type, parentId, data);
|
||||
await controller.applyActions([controller.getInsertAction(newNode, node.id)]);
|
||||
|
||||
return newNode.id;
|
||||
}
|
||||
);
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
getNodeBeginSelection,
|
||||
getNodeEndSelection,
|
||||
getStartLineSelectionByOffset,
|
||||
} from '$app/utils/document/slate/text';
|
||||
} from '$app/utils/document/blocks/text/delta';
|
||||
import { getNextLineId, getPrevLineId } from '$app/utils/document/blocks/common';
|
||||
|
||||
export const setCursorBeforeThunk = createAsyncThunk(
|
||||
@ -43,13 +43,15 @@ export const setCursorPreLineThunk = createAsyncThunk(
|
||||
const state = (getState() as { document: DocumentState }).document;
|
||||
const prevId = getPrevLineId(state, id);
|
||||
if (!prevId) return;
|
||||
const prevLineNode = state.nodes[prevId];
|
||||
|
||||
// if prev line have no delta, just set block is selected
|
||||
if (!prevLineNode.data.delta) {
|
||||
dispatch(documentActions.setSelectionById(prevId));
|
||||
return;
|
||||
let prevLineNode = state.nodes[prevId];
|
||||
// Find the prev line that has delta
|
||||
while (prevLineNode && !prevLineNode.data.delta) {
|
||||
const id = getPrevLineId(state, prevLineNode.id);
|
||||
if (!id) return;
|
||||
prevLineNode = state.nodes[id];
|
||||
}
|
||||
if (!prevLineNode) return;
|
||||
|
||||
// whatever the selection is, set cursor to the end of prev line when focusEnd is true
|
||||
if (focusEnd) {
|
||||
@ -76,14 +78,16 @@ export const setCursorNextLineThunk = createAsyncThunk(
|
||||
const node = state.nodes[id];
|
||||
const nextId = getNextLineId(state, id);
|
||||
if (!nextId) return;
|
||||
const nextLineNode = state.nodes[nextId];
|
||||
const delta = nextLineNode.data.delta;
|
||||
// if next line have no delta, just set block is selected
|
||||
if (!delta) {
|
||||
dispatch(documentActions.setSelectionById(nextId));
|
||||
return;
|
||||
let nextLineNode = state.nodes[nextId];
|
||||
// Find the next line that has delta
|
||||
while (nextLineNode && !nextLineNode.data.delta) {
|
||||
const id = getNextLineId(state, nextLineNode.id);
|
||||
if (!id) return;
|
||||
nextLineNode = state.nodes[id];
|
||||
}
|
||||
if (!nextLineNode) return;
|
||||
|
||||
const delta = nextLineNode.data.delta;
|
||||
// whatever the selection is, set cursor to the start of next line when focusStart is true
|
||||
if (focusStart) {
|
||||
await dispatch(setCursorBeforeThunk({ id: nextLineNode.id }));
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { BlockData, BlockType, DocumentState, NestedBlock } from '$app/interfaces/document';
|
||||
import { BlockData, BlockType, DocumentState, NestedBlock, TextDelta } from '$app/interfaces/document';
|
||||
import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { newBlock } from '$app/utils/document/blocks/common';
|
||||
import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
|
||||
|
||||
/**
|
||||
* transform to block
|
||||
@ -47,3 +48,29 @@ export const turnToBlockThunk = createAsyncThunk(
|
||||
await dispatch(setCursorBeforeThunk({ id: block.id }));
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* turn to divider block
|
||||
* 1. insert text block with delta after current block
|
||||
* 2. turn current block to divider block
|
||||
*/
|
||||
export const turnToDividerBlockThunk = createAsyncThunk(
|
||||
'document/turnToDividerBlock',
|
||||
async (payload: { id: string; controller: DocumentController; delta: TextDelta[] }, thunkAPI) => {
|
||||
const { id, controller, delta } = payload;
|
||||
const { dispatch } = thunkAPI;
|
||||
const { payload: newNodeId } = await dispatch(
|
||||
insertAfterNodeThunk({
|
||||
id,
|
||||
controller,
|
||||
type: BlockType.TextBlock,
|
||||
data: {
|
||||
delta,
|
||||
},
|
||||
})
|
||||
);
|
||||
if (!newNodeId) return;
|
||||
await dispatch(turnToBlockThunk({ id, type: BlockType.DividerBlock, controller, data: {} }));
|
||||
dispatch(setCursorBeforeThunk({ id: newNodeId as string }));
|
||||
}
|
||||
);
|
||||
|
@ -3,7 +3,7 @@ import { Descendant, Editor, Element, Text } from 'slate';
|
||||
import { BlockPB } from '@/services/backend';
|
||||
import { Log } from '$app/utils/log';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getAfterRangeAt } from '$app/utils/document/slate/text';
|
||||
import { getAfterRangeAt } from '$app/utils/document/blocks/text/delta';
|
||||
|
||||
export function deltaToSlateValue(delta: TextDelta[]) {
|
||||
const slateNode = {
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { Editor } from 'slate';
|
||||
import {
|
||||
BulletListBlockData,
|
||||
CalloutBlockData,
|
||||
HeadingBlockData,
|
||||
NumberedListBlockData,
|
||||
TodoListBlockData,
|
||||
ToggleListBlockData,
|
||||
} from '$app/interfaces/document';
|
||||
import { getBeforeRangeAt } from '$app/utils/document/slate/text';
|
||||
import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
|
||||
import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
|
||||
|
||||
/**
|
||||
@ -94,3 +95,26 @@ export function getToggleListDataFromEditor(editor: Editor): ToggleListBlockData
|
||||
collapsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* get callout data from editor, only support markdown
|
||||
*/
|
||||
export function getCalloutDataFromEditor(editor: Editor): CalloutBlockData | undefined {
|
||||
const delta = getDeltaAfterSelection(editor);
|
||||
if (!delta) return;
|
||||
const selection = editor.selection;
|
||||
if (!selection) return;
|
||||
const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
|
||||
const tag = hashTags.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
|
||||
if (!tag) return;
|
||||
const iconMap: Record<string, string> = {
|
||||
TIP: '💡',
|
||||
INFO: '❗',
|
||||
WARNING: '⚠️',
|
||||
DANGER: '‼️',
|
||||
};
|
||||
return {
|
||||
delta,
|
||||
icon: iconMap[tag],
|
||||
};
|
||||
}
|
||||
|
@ -1,16 +0,0 @@
|
||||
import { BlockType, NestedBlock, TextBlockData, TextDelta } from '$app/interfaces/document';
|
||||
import { newBlock } from '$app/utils/document/blocks/common';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export function newTextBlock(parentId: string, data: TextBlockData): NestedBlock {
|
||||
return newBlock<BlockType.TextBlock>(BlockType.TextBlock, parentId, data);
|
||||
}
|
||||
|
||||
export function isSameDelta(referDelta: TextDelta[], delta: TextDelta[]) {
|
||||
const ydoc = new Y.Doc();
|
||||
const yText = ydoc.getText('1');
|
||||
const yTextRefer = ydoc.getText('2');
|
||||
yText.applyDelta(delta);
|
||||
yTextRefer.applyDelta(referDelta);
|
||||
return JSON.stringify(yText.toDelta()) === JSON.stringify(yTextRefer.toDelta());
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import { Editor, Element, Text, Location } from 'slate';
|
||||
import { Editor, Element, Location, Text } from 'slate';
|
||||
import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export function getDelta(editor: Editor, at: Location): TextDelta[] {
|
||||
const baseElement = Editor.fragment(editor, at)[0] as Element;
|
||||
@ -198,3 +199,12 @@ export function clonePoint(point: SelectionPoint): SelectionPoint {
|
||||
offset: point.offset,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSameDelta(referDelta: TextDelta[], delta: TextDelta[]) {
|
||||
const ydoc = new Y.Doc();
|
||||
const yText = ydoc.getText('1');
|
||||
const yTextRefer = ydoc.getText('2');
|
||||
yText.applyDelta(delta);
|
||||
yTextRefer.applyDelta(referDelta);
|
||||
return JSON.stringify(yText.toDelta()) === JSON.stringify(yTextRefer.toDelta());
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { toggleFormat } from './format';
|
||||
import { Editor, Range } from 'slate';
|
||||
import { clonePoint, getAfterRangeAt, getBeforeRangeAt, getDelta, pointInBegin, pointInEnd } from './text';
|
||||
import { clonePoint, getAfterRangeAt, getBeforeRangeAt, getDelta, pointInBegin, pointInEnd } from './delta';
|
||||
import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
|
||||
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
|
||||
|
Loading…
Reference in New Issue
Block a user