Copy and paste appflowy editor data (#2714)

* feat: copy and paste appflowy editor data

* fix: review suggestion
This commit is contained in:
Kilu.He 2023-06-08 12:17:00 +08:00 committed by GitHub
parent f86a98cd51
commit d02b8c609b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 631 additions and 112 deletions

View File

@ -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;
}

View File

@ -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<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.SHIFT_ENTER, e);
},

View File

@ -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 (
<>
<BlockSideToolbar container={container} />

View File

@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
e.stopPropagation();
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));

View File

@ -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]);
}

View File

@ -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]);
}

View File

@ -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<HTMLDivElement>) => {
if (!controller) return;
const format = parseFormat(e);
if (!format) return;
dispatch(
toggleFormatThunk({
format,
controller,
})
);
},
},
];
}, [caretRef, controller, dispatch, focused, id]);
return commonKeyEvents;

View File

@ -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<HTMLDivElement>) => {
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,
};
}

View File

@ -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<Y.Text>();
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;
}
}

View File

@ -0,0 +1,5 @@
export const clipboardTypes = {
JSON: 'application/json',
TEXT: 'text/plain',
HTML: 'text/html',
};

View File

@ -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',
},
};

View File

@ -3,6 +3,12 @@ import { BlockActionTypePB } from '@/services/backend';
import { Sources } from 'quill';
import React from 'react';
export interface DocumentBlockJSON {
type: BlockType;
data: BlockData<any>;
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<HTMLDivElement>) => void;
}
export interface BlockCopyData {
json: string;
text: string;
html: string;
}

View File

@ -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 {

View File

@ -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);

View File

@ -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<string, DocumentBlockJSON>();
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));
});

View File

@ -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<boolean, TextAction>(
'document/getFormatActive',
@ -29,12 +28,17 @@ export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
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) => {

View File

@ -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;

View File

@ -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;

View File

@ -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<any> {
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,
},
});
}

View File

@ -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;
}
}
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));
}

View File

@ -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<HTMLDivElement>) {
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<HTMLDivElement>) {
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;
}

View File

@ -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;
}
}