From d02b8c609bab0a1593cc2ad97ce2f380384b8e85 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Thu, 8 Jun 2023 12:17:00 +0800 Subject: [PATCH] Copy and paste appflowy editor data (#2714) * feat: copy and paste appflowy editor data * fix: review suggestion --- .../BlockSelection/RangeKeyDown.hooks.ts | 31 ++- .../document/CodeBlock/useKeyDown.ts | 2 +- .../components/document/Overlay/index.tsx | 4 + .../document/TextBlock/useKeyDown.ts | 13 +- .../_shared/CopyPasteHooks/useCopy.ts | 37 ++++ .../_shared/CopyPasteHooks/usePaste.ts | 36 ++++ .../_shared/EditorHooks/useCommonKeyEvents.ts | 17 ++ .../document/_shared/SlateEditor/useEditor.ts | 27 ++- .../_shared/SlateEditor/useSlateYjs.ts | 18 +- .../constants/document/copy_paste.ts | 5 + .../constants/document/keyboard.ts | 10 + .../src/appflowy_app/interfaces/document.ts | 14 +- .../effects/document/document_controller.ts | 2 +- .../document/async-actions/blocks/merge.ts | 11 +- .../document/async-actions/copyPaste.ts | 202 ++++++++++++++++++ .../reducers/document/async-actions/format.ts | 12 +- .../reducers/document/async-actions/range.ts | 38 ++-- .../src/appflowy_app/utils/document/action.ts | 93 +++++--- .../appflowy_app/utils/document/copy_paste.ts | 82 +++++++ .../src/appflowy_app/utils/document/delta.ts | 56 ++++- .../src/appflowy_app/utils/document/format.ts | 28 +++ .../src/appflowy_app/utils/document/node.ts | 5 +- 22 files changed, 631 insertions(+), 112 deletions(-) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/constants/document/copy_paste.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copyPaste.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts index feac345444..8e8eb67659 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts @@ -8,6 +8,8 @@ import isHotkey from 'is-hotkey'; import { deleteRangeAndInsertEnterThunk } from '$app_reducers/document/async-actions/range'; import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks'; import { isPrintableKeyEvent } from '$app/utils/document/action'; +import { toggleFormatThunk } from '$app_reducers/document/async-actions/format'; +import { isFormatHotkey, parseFormat } from '$app/utils/document/format'; export function useRangeKeyDown() { const rangeRef = useRangeRef(); @@ -33,7 +35,7 @@ export function useRangeKeyDown() { { // handle char input canHandle: (e: KeyboardEvent) => { - return isPrintableKeyEvent(e); + return isPrintableKeyEvent(e) && !e.shiftKey && !e.ctrlKey && !e.metaKey; }, handler: (e: KeyboardEvent) => { if (!controller) return; @@ -94,11 +96,26 @@ export function useRangeKeyDown() { ); }, }, + { + // handle format shortcuts + canHandle: isFormatHotkey, + handler: (e: KeyboardEvent) => { + if (!controller) return; + const format = parseFormat(e); + if (!format) return; + dispatch( + toggleFormatThunk({ + format, + controller, + }) + ); + }, + }, ], [controller, dispatch] ); - const onKeyDown = useCallback( + const onKeyDownCapture = useCallback( (e: KeyboardEvent) => { if (!rangeRef.current) { return; @@ -108,12 +125,16 @@ export function useRangeKeyDown() { return; } e.stopPropagation(); - e.preventDefault(); const filteredEvents = interceptEvents.filter((event) => event.canHandle(e)); - filteredEvents.forEach((event) => event.handler(e)); + const lastIndex = filteredEvents.length - 1; + if (lastIndex < 0) { + return; + } + const lastEvent = filteredEvents[lastIndex]; + lastEvent?.handler(e); }, [interceptEvents, rangeRef] ); - return onKeyDown; + return onKeyDownCapture; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts index ad4c586a26..5f98622ae9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts @@ -14,8 +14,8 @@ export function useKeyDown(id: string) { const customEvents = useMemo(() => { return [ ...commonKeyEvents, - { + // rewrite only shift + enter key and no other key is pressed canHandle: (e: React.KeyboardEvent) => { return isHotkey(Keyboard.keys.SHIFT_ENTER, e); }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx index 5a5c6f60ea..4280e429f9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx @@ -3,8 +3,12 @@ import BlockSideToolbar from '../BlockSideToolbar'; import BlockSelection from '../BlockSelection'; import TextActionMenu from '$app/components/document/TextActionMenu'; import BlockSlash from '$app/components/document/BlockSlash'; +import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy'; +import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste'; export default function Overlay({ container }: { container: HTMLDivElement }) { + useCopy(container); + usePaste(container); return ( <> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts index d55afbf3c1..166fe6bee8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts @@ -20,7 +20,7 @@ export function useKeyDown(id: string) { return [ ...commonKeyEvents, { - // Prevent all enter key + // Prevent all enter key unless it be rewritten canHandle: (e: React.KeyboardEvent) => { return e.key === Keyboard.keys.ENTER; }, @@ -29,7 +29,7 @@ export function useKeyDown(id: string) { }, }, { - // handle enter key and no other key is pressed + // rewrite only enter key and no other key is pressed canHandle: (e: React.KeyboardEvent) => { return isHotkey(Keyboard.keys.ENTER, e); }, @@ -43,9 +43,8 @@ export function useKeyDown(id: string) { ); }, }, - { - // Prevent tab key from indenting + // Prevent all tab key unless it be rewritten canHandle: (e: React.KeyboardEvent) => { return e.key === Keyboard.keys.TAB; }, @@ -54,7 +53,7 @@ export function useKeyDown(id: string) { }, }, { - // handle tab key and no other key is pressed + // rewrite only tab key and no other key is pressed canHandle: (e: React.KeyboardEvent) => { return isHotkey(Keyboard.keys.TAB, e); }, @@ -69,7 +68,7 @@ export function useKeyDown(id: string) { }, }, { - // handle shift + tab key and no other key is pressed + // rewrite only shift+tab key and no other key is pressed canHandle: (e: React.KeyboardEvent) => { return isHotkey(Keyboard.keys.SHIFT_TAB, e); }, @@ -83,14 +82,12 @@ export function useKeyDown(id: string) { ); }, }, - ...turnIntoEvents, ]; }, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { - e.stopPropagation(); const filteredEvents = interceptEvents.filter((event) => event.canHandle(e)); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts new file mode 100644 index 0000000000..2a8eadc8f1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts @@ -0,0 +1,37 @@ +import { useCallback, useContext, useEffect } from 'react'; +import { copyThunk } from '$app_reducers/document/async-actions/copyPaste'; +import { useAppDispatch } from '$app/stores/store'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { BlockCopyData } from '$app/interfaces/document'; +import { clipboardTypes } from '$app/constants/document/copy_paste'; + +export function useCopy(container: HTMLDivElement) { + const dispatch = useAppDispatch(); + const controller = useContext(DocumentControllerContext); + + const handleCopyCapture = useCallback( + (e: ClipboardEvent) => { + if (!controller) return; + e.stopPropagation(); + e.preventDefault(); + const setClipboardData = (data: BlockCopyData) => { + e.clipboardData?.setData(clipboardTypes.JSON, data.json); + e.clipboardData?.setData(clipboardTypes.TEXT, data.text); + e.clipboardData?.setData(clipboardTypes.HTML, data.html); + }; + dispatch( + copyThunk({ + setClipboardData, + }) + ); + }, + [controller, dispatch] + ); + + useEffect(() => { + container.addEventListener('copy', handleCopyCapture, true); + return () => { + container.removeEventListener('copy', handleCopyCapture, true); + }; + }, [container, handleCopyCapture]); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts new file mode 100644 index 0000000000..d560976c5f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts @@ -0,0 +1,36 @@ +import { useCallback, useContext, useEffect } from 'react'; +import { useAppDispatch } from '$app/stores/store'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { pasteThunk } from '$app_reducers/document/async-actions/copyPaste'; +import { clipboardTypes } from '$app/constants/document/copy_paste'; + +export function usePaste(container: HTMLDivElement) { + const dispatch = useAppDispatch(); + const controller = useContext(DocumentControllerContext); + + const handlePasteCapture = useCallback( + (e: ClipboardEvent) => { + if (!controller) return; + e.stopPropagation(); + e.preventDefault(); + dispatch( + pasteThunk({ + controller, + data: { + json: e.clipboardData?.getData(clipboardTypes.JSON) || '', + text: e.clipboardData?.getData(clipboardTypes.TEXT) || '', + html: e.clipboardData?.getData(clipboardTypes.HTML) || '', + }, + }) + ); + }, + [controller, dispatch] + ); + + useEffect(() => { + container.addEventListener('paste', handlePasteCapture, true); + return () => { + container.removeEventListener('paste', handlePasteCapture, true); + }; + }, [container, handlePasteCapture]); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts index 41c0758922..f4239ddcd7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts @@ -10,6 +10,8 @@ import { useContext, useMemo } from 'react'; import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { useAppDispatch } from '$app/stores/store'; +import { isFormatHotkey, parseFormat } from '$app/utils/document/format'; +import { toggleFormatThunk } from '$app_reducers/document/async-actions/format'; export function useCommonKeyEvents(id: string) { const { focused, caretRef } = useFocused(id); @@ -73,6 +75,21 @@ export function useCommonKeyEvents(id: string) { dispatch(rightActionForBlockThunk({ id })); }, }, + { + // handle format shortcuts + canHandle: isFormatHotkey, + handler: (e: React.KeyboardEvent) => { + if (!controller) return; + const format = parseFormat(e); + if (!format) return; + dispatch( + toggleFormatThunk({ + format, + controller, + }) + ); + }, + }, ]; }, [caretRef, controller, dispatch, focused, id]); return commonKeyEvents; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts index ba1bb6af14..66517b6143 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts @@ -1,19 +1,19 @@ -import { EditorProps } from "$app/interfaces/document"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { ReactEditor } from "slate-react"; -import { BaseRange, Descendant, Editor, NodeEntry, Range, Selection } from "slate"; +import { EditorProps } from '$app/interfaces/document'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { ReactEditor } from 'slate-react'; +import { BaseRange, Descendant, Editor, NodeEntry, Range, Selection, Transforms } from 'slate'; import { converToIndexLength, convertToDelta, convertToSlateSelection, indent, - outdent -} from "$app/utils/document/slate_editor"; -import { focusNodeByIndex } from "$app/utils/document/node"; -import { Keyboard } from "$app/constants/document/keyboard"; -import Delta from "quill-delta"; -import isHotkey from "is-hotkey"; -import { useSlateYjs } from "$app/components/document/_shared/SlateEditor/useSlateYjs"; + outdent, +} from '$app/utils/document/slate_editor'; +import { focusNodeByIndex } from '$app/utils/document/node'; +import { Keyboard } from '$app/constants/document/keyboard'; +import Delta from 'quill-delta'; +import isHotkey from 'is-hotkey'; +import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs'; export function useEditor({ onChange, @@ -109,7 +109,6 @@ export function useEditor({ [editor, onKeyDown, isCodeBlock] ); - const onBlur = useCallback( (_event: React.FocusEvent) => { editor.deselect(); @@ -122,10 +121,9 @@ export function useEditor({ const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children); if (!slateSelection) return; const isFocused = ReactEditor.isFocused(editor); - if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return; - focusNodeByIndex(ref.current, selection.index, selection.length); + Transforms.select(editor, slateSelection); }, [editor, selection]); return { @@ -139,4 +137,3 @@ export function useEditor({ onBlur, }; } - diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts index dd696efb44..f18384d0f1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts @@ -1,16 +1,16 @@ -import Delta from "quill-delta"; -import { useEffect, useMemo, useRef } from "react"; -import * as Y from "yjs"; -import { convertToSlateValue } from "$app/utils/document/slate_editor"; -import { slateNodesToInsertDelta, withYjs, YjsEditor } from "@slate-yjs/core"; -import { withReact } from "slate-react"; -import { createEditor } from "slate"; +import Delta from 'quill-delta'; +import { useEffect, useMemo, useRef } from 'react'; +import * as Y from 'yjs'; +import { convertToSlateValue } from '$app/utils/document/slate_editor'; +import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core'; +import { withReact } from 'slate-react'; +import { createEditor } from 'slate'; export function useSlateYjs({ delta }: { delta?: Delta }) { const yTextRef = useRef(); const sharedType = useMemo(() => { const yDoc = new Y.Doc(); - const sharedType = yDoc.get("content", Y.XmlText) as Y.XmlText; + const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText; const value = convertToSlateValue(delta || new Delta()); const insertDelta = slateNodesToInsertDelta(value); sharedType.applyDelta(insertDelta); @@ -40,4 +40,4 @@ export function useSlateYjs({ delta }: { delta?: Delta }) { }, [delta, editor]); return editor; -} \ No newline at end of file +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/copy_paste.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/copy_paste.ts new file mode 100644 index 0000000000..9ff02141d9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/copy_paste.ts @@ -0,0 +1,5 @@ +export const clipboardTypes = { + JSON: 'application/json', + TEXT: 'text/plain', + HTML: 'text/html', +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts index 7fc04d42df..2b1c8011ce 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts @@ -28,5 +28,15 @@ export const Keyboard = { Space: ' ', Reduce: '-', BackQuote: '`', + FORMAT: { + BOLD: 'Mod+b', + ITALIC: 'Mod+i', + UNDERLINE: 'Mod+u', + STRIKE: 'Mod+Shift+s', + CODE: 'Mod+Shift+c', + }, + COPY: 'Mod+c', + CUT: 'Mod+x', + PASTE: 'Mod+v', }, }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index 1bdda2c086..1fd766ad43 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -3,6 +3,12 @@ import { BlockActionTypePB } from '@/services/backend'; import { Sources } from 'quill'; import React from 'react'; +export interface DocumentBlockJSON { + type: BlockType; + data: BlockData; + children: DocumentBlockJSON[]; +} + export interface RangeStatic { id: string; length: number; @@ -12,7 +18,7 @@ export interface RangeStatic { export enum BlockType { PageBlock = 'page', HeadingBlock = 'heading', - TextBlock = 'text', + TextBlock = 'paragraph', TodoListBlock = 'todo_list', BulletedListBlock = 'bulleted_list', NumberedListBlock = 'numbered_list', @@ -252,3 +258,9 @@ export interface EditorProps { onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void; onKeyDown?: (event: React.KeyboardEvent) => void; } + +export interface BlockCopyData { + json: string; + text: string; + html: string; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts index 6158b4aabd..dedef13044 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts @@ -1,4 +1,4 @@ -import { DocumentData, Node } from '@/appflowy_app/interfaces/document'; +import { DocumentBlockJSON, DocumentData, Node } from '@/appflowy_app/interfaces/document'; import { createContext } from 'react'; import { DocumentBackendService } from './document_bd_svc'; import { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts index 6350efdc55..4bcea874ab 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts @@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro import { DocumentState } from '$app/interfaces/document'; import Delta from 'quill-delta'; import { blockConfig } from '$app/constants/document/config'; +import { getMoveChildrenActions } from '$app/utils/document/action'; /** * Merge two blocks @@ -33,12 +34,12 @@ export const mergeDeltaThunk = createAsyncThunk( const actions = [updateAction]; // move children - const config = blockConfig[target.type]; const children = state.children[source.children].map((id) => state.nodes[id]); - const targetParentId = config.canAddChild ? target.id : target.parent; - if (!targetParentId) return; - const targetPrevId = targetParentId === target.id ? '' : target.id; - const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId); + const moveActions = getMoveChildrenActions({ + controller, + children, + target, + }); actions.push(...moveActions); // delete current block const deleteAction = controller.getDeleteAction(source); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copyPaste.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copyPaste.ts new file mode 100644 index 0000000000..48c22daaac --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copyPaste.ts @@ -0,0 +1,202 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { RootState } from '$app/stores/store'; +import { getMiddleIds, getMoveChildrenActions, getStartAndEndIdsByRange } from '$app/utils/document/action'; +import { BlockCopyData, BlockType, DocumentBlockJSON } from '$app/interfaces/document'; +import Delta from 'quill-delta'; +import { getDeltaByRange } from '$app/utils/document/delta'; +import { deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions/range'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { + generateBlocks, + getAppendBlockDeltaAction, + getCopyBlock, + getInsertBlockActions, +} from '$app/utils/document/copy_paste'; +import { rangeActions } from '$app_reducers/document/slice'; + +export const copyThunk = createAsyncThunk< + void, + { + setClipboardData: (data: BlockCopyData) => void; + } +>('document/copy', async (payload, thunkAPI) => { + const { getState } = thunkAPI; + const { setClipboardData } = payload; + const state = getState() as RootState; + const { document, documentRange } = state; + const startAndEndIds = getStartAndEndIdsByRange(documentRange); + if (startAndEndIds.length === 0) return; + const result: DocumentBlockJSON[] = []; + if (startAndEndIds.length === 1) { + // copy single block + const id = startAndEndIds[0]; + const node = document.nodes[id]; + const nodeDelta = new Delta(node.data.delta); + const range = documentRange.ranges[id] || { index: 0, length: 0 }; + const isFull = range.index === 0 && range.length === nodeDelta.length(); + if (isFull) { + result.push(getCopyBlock(id, document, documentRange)); + } else { + result.push({ + type: BlockType.TextBlock, + children: [], + data: { + delta: getDeltaByRange(nodeDelta, range).ops, + }, + }); + } + } else { + // copy multiple blocks + const copyIds: string[] = []; + const [startId, endId] = startAndEndIds; + const middleIds = getMiddleIds(document, startId, endId); + copyIds.push(startId, ...middleIds, endId); + const map = new Map(); + copyIds.forEach((id) => { + const block = getCopyBlock(id, document, documentRange); + map.set(id, block); + const node = document.nodes[id]; + const parent = node.parent; + if (parent && map.has(parent)) { + map.get(parent)!.children.push(block); + } else { + result.push(block); + } + }); + } + setClipboardData({ + json: JSON.stringify(result), + // TODO: implement plain text and html + text: '', + html: '', + }); +}); + +/** + * Paste data to document + * 1. delete range blocks + * 2. if current block is empty text block, insert paste data below current block and delete current block + * 3. otherwise: + * 3.1 split current block, before part merge the first block of paste data and update current block + * 3.2 after part append to the last block of paste data + * 3.3 move the first block children of paste data to current block + * 3.4 delete the first block of paste data + */ +export const pasteThunk = createAsyncThunk< + void, + { + data: BlockCopyData; + controller: DocumentController; + } +>('document/paste', async (payload, thunkAPI) => { + const { getState, dispatch } = thunkAPI; + const { data, controller } = payload; + // delete range blocks + await dispatch(deleteRangeAndInsertThunk({ controller })); + + let pasteData; + if (data.json) { + pasteData = JSON.parse(data.json) as DocumentBlockJSON[]; + } else if (data.text) { + // TODO: implement plain text + } else if (data.html) { + // TODO: implement html + } + if (!pasteData) return; + const { document, documentRange } = getState() as RootState; + const { caret } = documentRange; + if (!caret) return; + const currentBlock = document.nodes[caret.id]; + if (!currentBlock.parent) return; + const pasteBlocks = generateBlocks(pasteData, currentBlock.parent); + const currentBlockDelta = new Delta(currentBlock.data.delta); + const type = currentBlock.type; + const actions = getInsertBlockActions(pasteBlocks, currentBlock.id, controller); + const firstPasteBlock = pasteBlocks[0]; + const firstPasteBlockChildren = pasteBlocks.filter((block) => block.parent === firstPasteBlock.id); + + const lastPasteBlock = pasteBlocks[pasteBlocks.length - 1]; + if (type === BlockType.TextBlock && currentBlockDelta.length() === 0) { + // move current block children to first paste block + const children = document.children[currentBlock.children].map((id) => document.nodes[id]); + const firstPasteBlockLastChild = + firstPasteBlockChildren.length > 0 ? firstPasteBlockChildren[firstPasteBlockChildren.length - 1] : undefined; + const prevId = firstPasteBlockLastChild ? firstPasteBlockLastChild.id : undefined; + const moveChildrenActions = getMoveChildrenActions({ + target: firstPasteBlock, + children, + controller, + prevId, + }); + actions.push(...moveChildrenActions); + // delete current block + actions.push(controller.getDeleteAction(currentBlock)); + await controller.applyActions(actions); + // set caret to the end of the last paste block + dispatch( + rangeActions.setCaret({ + id: lastPasteBlock.id, + index: new Delta(lastPasteBlock.data.delta).length(), + length: 0, + }) + ); + return; + } + + // split current block + const currentBeforeDelta = getDeltaByRange(currentBlockDelta, { index: 0, length: caret.index }); + const currentAfterDelta = getDeltaByRange(currentBlockDelta, { + index: caret.index, + length: currentBlockDelta.length() - caret.index, + }); + + let newCaret; + const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta); + const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta); + let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta); + if (firstPasteBlock.id !== lastPasteBlock.id) { + // update the last block of paste data + actions.push(getAppendBlockDeltaAction(lastPasteBlock, currentAfterDelta, false, controller)); + newCaret = { + id: lastPasteBlock.id, + index: lastPasteBlockDelta.length(), + length: 0, + }; + } else { + newCaret = { + id: currentBlock.id, + index: mergeDelta.length(), + length: 0, + }; + mergeDelta = mergeDelta.concat(currentAfterDelta); + } + + // update current block and merge the first block of paste data + actions.push( + controller.getUpdateAction({ + ...currentBlock, + data: { + ...currentBlock.data, + delta: mergeDelta.ops, + }, + }) + ); + + // move the first block children of paste data to current block + if (firstPasteBlockChildren.length > 0) { + const moveChildrenActions = getMoveChildrenActions({ + target: currentBlock, + children: firstPasteBlockChildren, + controller, + }); + actions.push(...moveChildrenActions); + } + + // delete first block of paste data + actions.push(controller.getDeleteAction(firstPasteBlock)); + await controller.applyActions(actions); + // set caret to the end of the last paste block + if (!newCaret) return; + + dispatch(rangeActions.setCaret(newCaret)); +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts index 11d2b234f8..b60b06e8cc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts @@ -3,7 +3,6 @@ import { RootState } from '$app/stores/store'; import { TextAction } from '$app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import Delta from 'quill-delta'; -import { rangeActions } from '$app_reducers/document/slice'; export const getFormatActiveThunk = createAsyncThunk( 'document/getFormatActive', @@ -29,12 +28,17 @@ export const getFormatActiveThunk = createAsyncThunk( export const toggleFormatThunk = createAsyncThunk( 'document/toggleFormat', - async (payload: { format: TextAction; controller: DocumentController; isActive: boolean }, thunkAPI) => { + async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => { const { getState, dispatch } = thunkAPI; - const { format, controller, isActive } = payload; + const { format, controller } = payload; + let isActive = payload.isActive; + if (isActive === undefined) { + const { payload: active } = await dispatch(getFormatActiveThunk(format)); + isActive = !!active; + } const state = getState() as RootState; const { document } = state; - const { ranges, caret } = state.documentRange; + const { ranges } = state.documentRange; const toggle = (delta: Delta, format: TextAction) => { const newOps = delta.ops.map((op) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts index ae6a8db291..8892ec0891 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts @@ -1,15 +1,15 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { RootState } from '$app/stores/store'; import { rangeActions } from '$app_reducers/document/slice'; -import { getNextLineId } from '$app/utils/document/block'; import Delta from 'quill-delta'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { getAfterMergeCaretByRange, getInsertEnterNodeAction, getMergeEndDeltaToStartActionsByRange, + getMiddleIds, getMiddleIdsByRange, - getStartAndEndDeltaExpectRange, + getStartAndEndExtentDelta, } from '$app/utils/document/action'; import { RangeState, SplitRelationship } from '$app/interfaces/document'; import { blockConfig } from '$app/constants/document/config'; @@ -74,25 +74,19 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload: const startId = isForward ? anchor.id : focus.id; const endId = isForward ? focus.id : anchor.id; - let currentId: string | undefined = startId; - while (currentId && currentId !== endId) { - const nextId = getNextLineId(state.document, currentId); - if (nextId && nextId !== endId) { - const node = state.document.nodes[nextId]; + const middleIds = getMiddleIds(state.document, startId, endId); + middleIds.forEach((id) => { + const node = state.document.nodes[id]; - if (!node || !node.data.delta) return; - const delta = new Delta(node.data.delta); + if (!node || !node.data.delta) return; + const delta = new Delta(node.data.delta); + const rangeStatic = { + index: 0, + length: delta.length(), + }; - // set full range - const rangeStatic = { - index: 0, - length: delta.length(), - }; - - ranges[nextId] = rangeStatic; - } - currentId = nextId; - } + ranges[id] = rangeStatic; + }); dispatch(rangeActions.setRanges(ranges)); }); @@ -110,6 +104,8 @@ export const deleteRangeAndInsertThunk = createAsyncThunk( const { getState, dispatch } = thunkAPI; const state = getState() as RootState; const rangeState = state.documentRange; + // if no range, just return + if (rangeState.caret && rangeState.caret.length === 0) return; const actions = []; // get merge actions const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta); @@ -153,11 +149,11 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk( const rangeState = state.documentRange; const actions = []; - const { startDelta, endDelta, endNode, startNode } = getStartAndEndDeltaExpectRange(state) || {}; + const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(state) || {}; if (!startDelta || !endDelta || !endNode || !startNode) return; // get middle nodes - const middleIds = getMiddleIdsByRange(rangeState, state.document); + const middleIds = getMiddleIds(state.document, startNode.id, endNode.id); let newStartDelta = new Delta(startDelta); let caret = null; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts index 39bba2a494..888ba64f07 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts @@ -15,6 +15,8 @@ import { blockConfig } from '$app/constants/document/config'; import { caretInBottomEdgeByDelta, caretInTopEdgeByDelta, + getAfterExtentDeltaByRange, + getBeofreExtentDeltaByRange, getDeltaText, getIndexRelativeEnter, getLastLineIndex, @@ -22,25 +24,34 @@ import { transformIndexToPrevLine, } from '$app/utils/document/delta'; -export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) { - const { anchor, focus } = rangeState; - if (!anchor || !focus) return; - if (anchor.id === focus.id) return; - const isForward = anchor.point.y < focus.point.y; - // get all ids between anchor and focus - const amendIds = []; - const startId = isForward ? anchor.id : focus.id; - const endId = isForward ? focus.id : anchor.id; - +export function getMiddleIds(document: DocumentState, startId: string, endId: string) { + const middleIds = []; let currentId: string | undefined = startId; while (currentId && currentId !== endId) { const nextId = getNextLineId(document, currentId); if (nextId && nextId !== endId) { - amendIds.push(nextId); + middleIds.push(nextId); } currentId = nextId; } - return amendIds; + return middleIds; +} + +export function getStartAndEndIdsByRange(rangeState: RangeState) { + const { anchor, focus } = rangeState; + if (!anchor || !focus) return []; + if (anchor.id === focus.id) return [anchor.id]; + const isForward = anchor.point.y < focus.point.y; + const startId = isForward ? anchor.id : focus.id; + const endId = isForward ? focus.id : anchor.id; + return [startId, endId]; +} + +export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) { + const ids = getStartAndEndIdsByRange(rangeState); + if (ids.length < 2) return; + const [startId, endId] = ids; + return getMiddleIds(document, startId, endId); } export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) { @@ -61,42 +72,40 @@ export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: }; } -export function getStartAndEndDeltaExpectRange(state: RootState) { +export function getStartAndEndExtentDelta(state: RootState) { const rangeState = state.documentRange; - const { anchor, focus, ranges } = rangeState; - if (!anchor || !focus) return; - if (anchor.id === focus.id) return; - - const isForward = anchor.point.y < focus.point.y; - const startId = isForward ? anchor.id : focus.id; - const endId = isForward ? focus.id : anchor.id; - + const ids = getStartAndEndIdsByRange(rangeState); + if (ids.length === 0) return; + const startId = ids[0]; + const endId = ids[ids.length - 1]; + const { ranges } = rangeState; // get start and end delta const startRange = ranges[startId]; const endRange = ranges[endId]; if (!startRange || !endRange) return; const startNode = state.document.nodes[startId]; - let startDelta = new Delta(startNode.data.delta); - startDelta = startDelta.slice(0, startRange.index); + const startNodeDelta = new Delta(startNode.data.delta); + const startBeforeExtentDelta = getBeofreExtentDeltaByRange(startNodeDelta, startRange); const endNode = state.document.nodes[endId]; - let endDelta = new Delta(endNode.data.delta); - endDelta = endDelta.slice(endRange.index + endRange.length); + const endNodeDelta = new Delta(endNode.data.delta); + const endAfterExtentDelta = getAfterExtentDeltaByRange(endNodeDelta, endRange); return { startNode, endNode, - startDelta, - endDelta, + startDelta: startBeforeExtentDelta, + endDelta: endAfterExtentDelta, }; } + export function getMergeEndDeltaToStartActionsByRange( state: RootState, controller: DocumentController, insertDelta?: Delta ) { const actions = []; - const { startDelta, endDelta, endNode, startNode } = getStartAndEndDeltaExpectRange(state) || {}; + const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(state) || {}; if (!startDelta || !endDelta || !endNode || !startNode) return; // merge start and end nodes const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta); @@ -109,6 +118,14 @@ export function getMergeEndDeltaToStartActionsByRange( }) ); if (endNode.id !== startNode.id) { + const children = state.document.children[endNode.children].map((id) => state.document.nodes[id]); + + const moveChildrenActions = getMoveChildrenActions({ + target: startNode, + children, + controller, + }); + actions.push(...moveChildrenActions); // delete end node actions.push(controller.getDeleteAction(endNode)); } @@ -116,6 +133,26 @@ export function getMergeEndDeltaToStartActionsByRange( return actions; } +export function getMoveChildrenActions({ + target, + children, + controller, + prevId = '', +}: { + target: NestedBlock; + children: NestedBlock[]; + controller: DocumentController; + prevId?: string; +}) { + // move children + const config = blockConfig[target.type]; + const targetParentId = config.canAddChild ? target.id : target.parent; + if (!targetParentId) return []; + const targetPrevId = targetParentId === target.id ? prevId : target.id; + const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId); + return moveActions; +} + export function getInsertEnterNodeFields(sourceNode: NestedBlock) { if (!sourceNode.parent) return; const parentId = sourceNode.parent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts new file mode 100644 index 0000000000..3004070b54 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts @@ -0,0 +1,82 @@ +import { BlockData, DocumentBlockJSON, DocumentState, NestedBlock, RangeState } from '$app/interfaces/document'; +import { getDeltaByRange } from '$app/utils/document/delta'; +import Delta from 'quill-delta'; +import { generateId } from '$app/utils/document/block'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { blockConfig } from '$app/constants/document/config'; + +export function getCopyData( + node: NestedBlock, + range: { + index: number; + length: number; + } +): BlockData { + const nodeDeltaOps = node.data.delta; + if (!nodeDeltaOps) { + return { + ...node.data, + }; + } + const delta = getDeltaByRange(new Delta(node.data.delta), range); + return { + ...node.data, + delta: delta.ops, + }; +} + +export function getCopyBlock(id: string, document: DocumentState, documentRange: RangeState): DocumentBlockJSON { + const node = document.nodes[id]; + const range = documentRange.ranges[id] || { index: 0, length: 0 }; + const copyData = getCopyData(node, range); + return { + type: node.type, + data: copyData, + children: [], + }; +} + +export function generateBlocks(data: DocumentBlockJSON[], parentId: string) { + const blocks: NestedBlock[] = []; + function dfs(data: DocumentBlockJSON[], parentId: string) { + data.forEach((item) => { + const block = { + id: generateId(), + type: item.type, + data: item.data, + parent: parentId, + children: generateId(), + }; + blocks.push(block); + if (item.children) { + dfs(item.children, block.id); + } + }); + } + dfs(data, parentId); + return blocks; +} + +export function getInsertBlockActions(blocks: NestedBlock[], prevId: string, controller: DocumentController) { + return blocks.map((block, index) => { + const prevBlockId = index === 0 ? prevId : blocks[index - 1].id; + return controller.getInsertAction(block, prevBlockId); + }); +} + +export function getAppendBlockDeltaAction( + block: NestedBlock, + appendDelta: Delta, + isForward: boolean, + controller: DocumentController +) { + const nodeDelta = new Delta(block.data.delta); + const mergeDelta = isForward ? appendDelta.concat(nodeDelta) : nodeDelta.concat(appendDelta); + return controller.getUpdateAction({ + ...block, + data: { + ...block.data, + delta: mergeDelta.ops, + }, + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts index 5821683373..5ca8dae7bb 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts @@ -1,10 +1,10 @@ -import Delta from "quill-delta"; +import Delta from 'quill-delta'; export function getDeltaText(delta: Delta) { const text = delta - .filter((op) => typeof op.insert === "string") + .filter((op) => typeof op.insert === 'string') .map((op) => op.insert) - .join(""); + .join(''); return text; } @@ -12,7 +12,7 @@ export function caretInTopEdgeByDelta(delta: Delta, index: number) { const text = getDeltaText(delta.slice(0, index)); if (!text) return true; - const firstLine = text.split("\n")[0]; + const firstLine = text.split('\n')[0]; return index <= firstLine.length; } @@ -20,14 +20,14 @@ export function caretInBottomEdgeByDelta(delta: Delta, index: number) { const text = getDeltaText(delta.slice(index)); if (!text) return true; - return !text.includes("\n"); + return !text.includes('\n'); } export function getLineByIndex(delta: Delta, index: number) { const beforeText = getDeltaText(delta.slice(0, index)); const afterText = getDeltaText(delta.slice(index)); - const beforeLines = beforeText.split("\n"); - const afterLines = afterText.split("\n"); + const beforeLines = beforeText.split('\n'); + const afterLines = afterText.split('\n'); const startLineText = beforeLines[beforeLines.length - 1]; const currentLineText = startLineText + afterLines[0]; @@ -39,7 +39,7 @@ export function getLineByIndex(delta: Delta, index: number) { export function transformIndexToPrevLine(delta: Delta, index: number) { const text = getDeltaText(delta.slice(0, index)); - const lines = text.split("\n"); + const lines = text.split('\n'); if (lines.length < 2) return 0; const prevLineText = lines[lines.length - 2]; const transformedIndex = index - prevLineText.length - 1; @@ -59,13 +59,47 @@ export function transformIndexToNextLine(delta: Delta, index: number) { export function getIndexRelativeEnter(delta: Delta, index: number) { const text = getDeltaText(delta.slice(0, index)); - const beforeLines = text.split("\n"); + const beforeLines = text.split('\n'); const beforeLineText = beforeLines[beforeLines.length - 1]; return beforeLineText.length; } export function getLastLineIndex(delta: Delta) { const text = getDeltaText(delta); - const lastIndex = text.lastIndexOf("\n"); + const lastIndex = text.lastIndexOf('\n'); return lastIndex === -1 ? 0 : lastIndex + 1; -} \ No newline at end of file +} + +export function getDeltaByRange( + delta: Delta, + range: { + index: number; + length: number; + } +) { + const start = range.index; + const end = range.index + range.length; + return new Delta(delta.slice(start, end)); +} + +export function getBeofreExtentDeltaByRange( + delta: Delta, + range: { + index: number; + length: number; + } +) { + const start = range.index; + return new Delta(delta.slice(0, start)); +} + +export function getAfterExtentDeltaByRange( + delta: Delta, + range: { + index: number; + length: number; + } +) { + const start = range.index + range.length; + return new Delta(delta.slice(start)); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts new file mode 100644 index 0000000000..d156b6a066 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts @@ -0,0 +1,28 @@ +import isHotkey from 'is-hotkey'; +import { Keyboard } from '$app/constants/document/keyboard'; +import { TextAction } from '$app/interfaces/document'; + +export function isFormatHotkey(e: KeyboardEvent | React.KeyboardEvent) { + return ( + isHotkey(Keyboard.keys.FORMAT.BOLD, e) || + isHotkey(Keyboard.keys.FORMAT.ITALIC, e) || + isHotkey(Keyboard.keys.FORMAT.UNDERLINE, e) || + isHotkey(Keyboard.keys.FORMAT.STRIKE, e) || + isHotkey(Keyboard.keys.FORMAT.CODE, e) + ); +} + +export function parseFormat(e: KeyboardEvent | React.KeyboardEvent) { + if (isHotkey(Keyboard.keys.FORMAT.BOLD, e)) { + return TextAction.Bold; + } else if (isHotkey(Keyboard.keys.FORMAT.ITALIC, e)) { + return TextAction.Italic; + } else if (isHotkey(Keyboard.keys.FORMAT.UNDERLINE, e)) { + return TextAction.Underline; + } else if (isHotkey(Keyboard.keys.FORMAT.STRIKE, e)) { + return TextAction.Strikethrough; + } else if (isHotkey(Keyboard.keys.FORMAT.CODE, e)) { + return TextAction.Code; + } + return null; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts index 90e31085c1..28378d43a2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts @@ -145,7 +145,7 @@ export function isPointInBlock(target: HTMLElement | null) { export function findTextNode( node: Element, - index: number, + index: number ): { node?: Node; offset?: number; @@ -191,7 +191,6 @@ export function focusNodeByIndex(node: Element, index: number, length: number) { selection?.addRange(range); } - export function getNodeTextBoxByBlockId(blockId: string) { const node = getNode(blockId); return node?.querySelector(`[role="textbox"]`); @@ -229,4 +228,4 @@ export function findParent(node: Element, parentSelector: string) { parentNode = parentNode.parentElement; } return null; -} \ No newline at end of file +}