浏览代码

feat: support toggle list (#2456)

Kilu.He 2 年之前
父节点
当前提交
12151d1f3b

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/BulletedListBlock/index.tsx

@@ -8,7 +8,7 @@ function BulletedListBlock({ node, childIds }: { node: NestedBlock<BlockType.Bul
   return (
     <>
       <div className={'flex'}>
-        <div className={`relative flex h-[calc(1.5em_+_2px)] min-w-[24px] select-none items-center`}>
+        <div className={`relative flex h-[calc(1.5em_+_2px)] min-w-[1.5em] select-none items-center px-1`}>
           <Circle sx={{ width: 8, height: 8 }} />
         </div>
         <div className={'flex-1'}>

+ 7 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx

@@ -5,12 +5,14 @@ import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallback
 import TextBlock from '../TextBlock';
 import { NodeContext } from '../_shared/SubscribeNode.hooks';
 import { BlockType } from '$app/interfaces/document';
+import { Alert } from '@mui/material';
+
 import HeadingBlock from '$app/components/document/HeadingBlock';
 import TodoListBlock from '$app/components/document/TodoListBlock';
 import QuoteBlock from '$app/components/document/QuoteBlock';
 import BulletedListBlock from '$app/components/document/BulletedListBlock';
 import NumberedListBlock from '$app/components/document/NumberedListBlock';
-import { Alert } from '@mui/material';
+import ToggleListBlock from '$app/components/document/ToggleListBlock';
 
 function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
   const { node, childIds, isSelected, ref } = useNode(id);
@@ -35,6 +37,9 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
       case BlockType.NumberedListBlock: {
         return <NumberedListBlock node={node} childIds={childIds} />;
       }
+      case BlockType.ToggleListBlock: {
+        return <ToggleListBlock node={node} childIds={childIds} />;
+      }
       default:
         return (
           <Alert severity='info' className='mb-2'>
@@ -48,7 +53,7 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
 
   return (
     <NodeContext.Provider value={node}>
-      <div {...props} ref={ref} data-block-id={node.id} className={`relative px-1  ${props.className}`}>
+      <div {...props} ref={ref} data-block-id={node.id} className={`relative ${props.className}`}>
         {renderBlock()}
         <div className='block-overlay' />
         {isSelected ? (

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/index.tsx

@@ -11,7 +11,7 @@ function NumberedListBlock({ node, childIds }: { node: NestedBlock<BlockType.Num
     <>
       <div className={'flex'}>
         <div
-          className={`relative flex h-[calc(1.5em_+_4px)] min-w-[24px] select-none items-center whitespace-nowrap text-center`}
+          className={`relative flex h-[calc(1.5em_+_4px)] min-w-[1.5em] select-none  items-center whitespace-nowrap px-1 text-left`}
         >
           {index}.
         </div>

+ 3 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts

@@ -13,6 +13,7 @@ import {
   getTodoListDataFromEditor,
   getBulletedDataFromEditor,
   getNumberedListDataFromEditor,
+  getToggleListDataFromEditor,
 } from '$app/utils/document/blocks';
 
 const blockDataFactoryMap: Record<string, (editor: Editor) => BlockData<any> | undefined> = {
@@ -20,7 +21,8 @@ const blockDataFactoryMap: Record<string, (editor: Editor) => BlockData<any> | u
   [BlockType.TodoListBlock]: getTodoListDataFromEditor,
   [BlockType.QuoteBlock]: getQuoteDataFromEditor,
   [BlockType.BulletedListBlock]: getBulletedDataFromEditor,
-  [BlockType.NumberedListBlock]: getNumberedListDataFromEditor
+  [BlockType.NumberedListBlock]: getNumberedListDataFromEditor,
+  [BlockType.ToggleListBlock]: getToggleListDataFromEditor,
 };
 
 export function useTurnIntoBlock(id: string) {

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx

@@ -21,7 +21,7 @@ function TextBlock({
 
   return (
     <>
-      <div {...props} className={`py-[2px]${className}`}>
+      <div {...props} className={`px-1 py-[2px]${className}`}>
         <Slate editor={editor} onChange={onChange} value={value}>
           <BlockHorizontalToolbar id={node.id} />
           <Editable

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/index.tsx

@@ -21,7 +21,7 @@ export default function TodoListBlock({
   return (
     <>
       <div className={'flex'} onKeyDownCapture={handleShortcut}>
-        <div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
+        <div className={'flex h-[calc(1.5em_+_2px)] w-[1.5em] select-none items-center justify-start px-1'}>
           <div className={'relative flex h-4 w-4 items-center justify-start transition'}>
             <div>{checked ? <EditorCheckSvg /> : <EditorUncheckSvg />}</div>
             <input

+ 38 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/ToggleListBlock.hooks.ts

@@ -0,0 +1,38 @@
+import { useAppDispatch } from '$app/stores/store';
+import { useCallback, useContext } from 'react';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/text/update';
+import { BlockData, BlockType } from '$app/interfaces/document';
+import isHotkey from 'is-hotkey';
+
+export function useToggleListBlock(id: string, data: BlockData<BlockType.ToggleListBlock>) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+  const toggleCollapsed = useCallback(() => {
+    if (!controller) return;
+    void dispatch(
+      updateNodeDataThunk({
+        id,
+        controller,
+        data: {
+          collapsed: !data.collapsed,
+        },
+      })
+    );
+  }, [controller, dispatch, id, data.collapsed]);
+
+  const handleShortcut = useCallback(
+    (event: React.KeyboardEvent<HTMLDivElement>) => {
+      // Accepts mod for the classic "cmd on Mac, ctrl on Windows" use case.
+      if (isHotkey('mod+enter', event)) {
+        toggleCollapsed();
+      }
+    },
+    [toggleCollapsed]
+  );
+
+  return {
+    toggleCollapsed,
+    handleShortcut,
+  };
+}

+ 41 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/index.tsx

@@ -0,0 +1,41 @@
+import React from 'react';
+import { BlockType, NestedBlock } from '$app/interfaces/document';
+import TextBlock from '$app/components/document/TextBlock';
+import NodeChildren from '$app/components/document/Node/NodeChildren';
+import { useToggleListBlock } from '$app/components/document/ToggleListBlock/ToggleListBlock.hooks';
+import { IconButton } from '@mui/material';
+import { DropDownShowSvg } from '$app/components/_shared/svg/DropDownShowSvg';
+import Button from '@mui/material/Button';
+
+function ToggleListBlock({ node, childIds }: { node: NestedBlock<BlockType.ToggleListBlock>; childIds?: string[] }) {
+  const { toggleCollapsed, handleShortcut } = useToggleListBlock(node.id, node.data);
+  const collapsed = node.data.collapsed;
+  return (
+    <>
+      <div className={'flex'} onKeyDownCapture={handleShortcut}>
+        <div className={`relative h-[calc(1.5em_+_2px)] w-[1.5em] select-none overflow-hidden px-1`}>
+          <Button
+            variant={'text'}
+            color={'inherit'}
+            size={'small'}
+            onClick={toggleCollapsed}
+            style={{
+              minWidth: '20px',
+              padding: 0,
+            }}
+            className={`transition-transform duration-500 ${collapsed && 'rotate-[-90deg]'}`}
+          >
+            <DropDownShowSvg />
+          </Button>
+        </div>
+
+        <div className={'flex-1'}>
+          <TextBlock node={node} />
+        </div>
+      </div>
+      {!collapsed && <NodeChildren className='pl-[1.5em]' childIds={childIds} />}
+    </>
+  );
+}
+
+export default ToggleListBlock;

+ 82 - 12
frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts

@@ -1,5 +1,9 @@
-import { BlockType } from '$app/interfaces/document';
+import { BlockData, BlockType } from '$app/interfaces/document';
 
+export enum SplitRelationship {
+  NextSibling,
+  FirstChild,
+}
 /**
  * If the block type is not in the config, it will be thrown an error in development env
  */
@@ -10,23 +14,47 @@ export const blockConfig: Record<
      * Whether the block can have children
      */
     canAddChild: boolean;
-    /**
-     * the type of the block that will be split from the current block
-     */
-    splitType: BlockType;
     /**
      * The regexps that will be used to match the markdown flag
      */
     markdownRegexps?: RegExp[];
+
+    /**
+     * The default data of the block
+     */
+    defaultData?: BlockData<any>;
+
+    /**
+     * The props that will be passed to the text split function
+     */
+    splitProps?: {
+      /**
+       * The relationship between the next line block and the current block
+       */
+      nextLineRelationShip: SplitRelationship;
+      /**
+       * The type of the next line block
+       */
+      nextLineBlockType: BlockType;
+    };
   }
 > = {
   [BlockType.TextBlock]: {
     canAddChild: true,
-    splitType: BlockType.TextBlock,
+    defaultData: {
+      delta: [],
+    },
+    splitProps: {
+      nextLineRelationShip: SplitRelationship.NextSibling,
+      nextLineBlockType: BlockType.TextBlock,
+    },
   },
   [BlockType.HeadingBlock]: {
     canAddChild: false,
-    splitType: BlockType.TextBlock,
+    splitProps: {
+      nextLineRelationShip: SplitRelationship.NextSibling,
+      nextLineBlockType: BlockType.TextBlock,
+    },
     /**
      * # or ## or ###
      */
@@ -34,7 +62,14 @@ export const blockConfig: Record<
   },
   [BlockType.TodoListBlock]: {
     canAddChild: true,
-    splitType: BlockType.TodoListBlock,
+    defaultData: {
+      delta: [],
+      checked: false,
+    },
+    splitProps: {
+      nextLineRelationShip: SplitRelationship.NextSibling,
+      nextLineBlockType: BlockType.TodoListBlock,
+    },
     /**
      * -[] or -[x] or -[ ] or [] or [x] or [ ]
      */
@@ -42,7 +77,14 @@ export const blockConfig: Record<
   },
   [BlockType.BulletedListBlock]: {
     canAddChild: true,
-    splitType: BlockType.BulletedListBlock,
+    defaultData: {
+      delta: [],
+      format: 'default',
+    },
+    splitProps: {
+      nextLineRelationShip: SplitRelationship.NextSibling,
+      nextLineBlockType: BlockType.BulletedListBlock,
+    },
     /**
      * - or + or *
      */
@@ -50,7 +92,14 @@ export const blockConfig: Record<
   },
   [BlockType.NumberedListBlock]: {
     canAddChild: true,
-    splitType: BlockType.NumberedListBlock,
+    defaultData: {
+      delta: [],
+      format: 'default',
+    },
+    splitProps: {
+      nextLineRelationShip: SplitRelationship.NextSibling,
+      nextLineBlockType: BlockType.NumberedListBlock,
+    },
     /**
      * 1. or 2. or 3.
      * a. or b. or c.
@@ -59,15 +108,36 @@ export const blockConfig: Record<
   },
   [BlockType.QuoteBlock]: {
     canAddChild: true,
-    splitType: BlockType.TextBlock,
+    defaultData: {
+      delta: [],
+      size: 'default',
+    },
+    splitProps: {
+      nextLineRelationShip: SplitRelationship.NextSibling,
+      nextLineBlockType: BlockType.TextBlock,
+    },
     /**
      * " or “ or ”
      */
     markdownRegexps: [/^("|“|”)$/],
   },
+  [BlockType.ToggleListBlock]: {
+    canAddChild: true,
+    defaultData: {
+      delta: [],
+      collapsed: false,
+    },
+    splitProps: {
+      nextLineRelationShip: SplitRelationship.FirstChild,
+      nextLineBlockType: BlockType.TextBlock,
+    },
+    /**
+     * >
+     */
+    markdownRegexps: [/^(>)$/],
+  },
   [BlockType.CodeBlock]: {
     canAddChild: false,
-    splitType: BlockType.TextBlock,
     /**
      * ```
      */

+ 7 - 0
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -7,6 +7,7 @@ export enum BlockType {
   TodoListBlock = 'todo_list',
   BulletedListBlock = 'bulleted_list',
   NumberedListBlock = 'numbered_list',
+  ToggleListBlock = 'toggle_list',
   CodeBlock = 'code',
   EmbedBlock = 'embed',
   QuoteBlock = 'quote',
@@ -33,6 +34,10 @@ export interface NumberedListBlockData extends TextBlockData {
   format: 'default' | 'numbers' | 'letters' | 'roman_numerals';
 }
 
+export interface ToggleListBlockData extends TextBlockData {
+  collapsed: boolean;
+}
+
 export interface QuoteBlockData extends TextBlockData {
   size: 'default' | 'large';
 }
@@ -55,6 +60,8 @@ export type BlockData<Type> = Type extends BlockType.HeadingBlock
   ? BulletListBlockData
   : Type extends BlockType.NumberedListBlock
   ? NumberedListBlockData
+  : Type extends BlockType.ToggleListBlock
+  ? ToggleListBlockData
   : TextBlockData;
 
 export interface NestedBlock<Type = any> {

+ 36 - 13
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts

@@ -3,8 +3,8 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { documentActions } from '$app_reducers/document/slice';
 import { setCursorBeforeThunk } from '../../cursor';
-import { getDefaultBlockData, newBlock } from '$app/utils/document/blocks/common';
-import { blockConfig } from '$app/constants/document/config';
+import { newBlock } from '$app/utils/document/blocks/common';
+import { blockConfig, SplitRelationship } from '$app/constants/document/config';
 
 export const splitNodeThunk = createAsyncThunk(
   'document/splitNode',
@@ -18,13 +18,28 @@ export const splitNodeThunk = createAsyncThunk(
     const node = state.nodes[id];
     if (!node.parent) return;
     const children = state.children[node.children];
-    const prevId = node.id;
     const parent = state.nodes[node.parent];
 
-    const config = blockConfig[node.type];
-    const newNodeType = config.splitType;
-    const defaultData = getDefaultBlockData(newNodeType);
-    const newNode = newBlock<any>(newNodeType, parent.id, {
+    const config = blockConfig[node.type].splitProps;
+    // Here we are using the splitProps property of the blockConfig object to determine the type of the new node.
+    // if the splitProps property is not defined for the block type, we throw an error.
+    if (!config) {
+      throw new Error(`Cannot split node of type ${node.type}`);
+    }
+    const newNodeType = config.nextLineBlockType;
+    const relationShip = config.nextLineRelationShip;
+    const defaultData = blockConfig[newNodeType].defaultData;
+    // if the defaultData property is not defined for the new block type, we throw an error.
+    if (!defaultData) {
+      throw new Error(`Cannot split node of type ${node.type} to ${newNodeType}`);
+    }
+
+    // if the next line is a sibling, parent is the same as the current node, and prev is the current node.
+    // otherwise, parent is the current node, and prev is empty.
+    const newParentId = relationShip === SplitRelationship.NextSibling ? parent.id : node.id;
+    const newPrevId = relationShip === SplitRelationship.NextSibling ? node.id : '';
+
+    const newNode = newBlock<any>(newNodeType, newParentId, {
       ...defaultData,
       delta: insert,
     });
@@ -35,14 +50,22 @@ export const splitNodeThunk = createAsyncThunk(
         delta: retain,
       },
     };
-    const insertAction = controller.getInsertAction(newNode, prevId);
+    const insertAction = controller.getInsertAction(newNode, newPrevId);
     const updateAction = controller.getUpdateAction(retainNode);
-    const moveChildrenAction = controller.getMoveChildrenAction(
-      children.map((id) => state.nodes[id]),
-      newNode.id,
-      ''
-    );
+
+    // if the next line is a sibling, we need to move the children of the current node to the new node.
+    // otherwise, we don't need to do anything.
+    const moveChildrenAction =
+      relationShip === SplitRelationship.NextSibling
+        ? controller.getMoveChildrenAction(
+            children.map((id) => state.nodes[id]),
+            newNode.id,
+            ''
+          )
+        : [];
+
     await controller.applyActions([insertAction, ...moveChildrenAction, updateAction]);
+
     // update local node data
     dispatch(documentActions.updateNodeData({ id: retainNode.id, data: { delta: retain } }));
     // set cursor

+ 0 - 14
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts

@@ -119,17 +119,3 @@ export function newBlock<Type>(type: BlockType, parentId: string, data: BlockDat
     data,
   };
 }
-
-export function getDefaultBlockData(type: BlockType) {
-  switch (type) {
-    case BlockType.TodoListBlock:
-      return {
-        checked: false,
-        delta: [],
-      };
-    default:
-      return {
-        delta: [],
-      };
-  }
-}

+ 13 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts

@@ -4,6 +4,7 @@ import {
   HeadingBlockData,
   NumberedListBlockData,
   TodoListBlockData,
+  ToggleListBlockData,
 } from '$app/interfaces/document';
 import { getBeforeRangeAt } from '$app/utils/document/slate/text';
 import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
@@ -81,3 +82,15 @@ export function getNumberedListDataFromEditor(editor: Editor): NumberedListBlock
     format: 'default',
   };
 }
+
+/**
+ * get toggle_list data from editor, only support markdown
+ */
+export function getToggleListDataFromEditor(editor: Editor): ToggleListBlockData | undefined {
+  const delta = getDeltaAfterSelection(editor);
+  if (!delta) return;
+  return {
+    delta,
+    collapsed: false,
+  };
+}