Bläddra i källkod

feat: add left tool when hover on block

qinluhe 2 år sedan
förälder
incheckning
7781b912f0

+ 19 - 5
frontend/appflowy_tauri/src/appflowy_app/block_editor/view/block_position.ts

@@ -8,7 +8,6 @@ export class BlockPositionManager {
   constructor(container: HTMLDivElement) {
     this.container = container;
     this.regionGrid = new RegionGrid(container.offsetHeight);
-    
   }
 
   isInViewport(nodeId: string) {
@@ -21,12 +20,9 @@ export class BlockPositionManager {
       this.updateBlockPosition(blockId);
       this.viewportBlocks.add(blockId);
     }
-    
     return {
       unobserve: () => {
-        if (blockId) {
-          this.viewportBlocks.delete(blockId);
-        }
+        if (blockId) this.viewportBlocks.delete(blockId);
       },
     }
   }
@@ -66,6 +62,24 @@ export class BlockPositionManager {
     return this.regionGrid.getIntersectBlocks(startX, startY, endX, endY);
   }
 
+  getViewportBlockByPoint(x: number, y: number): BlockPosition | null {
+    let blockPosition: BlockPosition | null = null;
+    this.viewportBlocks.forEach(id => {
+      this.updateBlockPosition(id);
+      const block = this.blockPositions.get(id);
+      if (!block) return;
+      
+      if (block.x + block.width - 1 >= x &&
+        block.y + block.height - 1 >= y && block.y <= y) {
+          if (!blockPosition || block.y > blockPosition.y) {
+            blockPosition = block;
+          }
+      }
+    });
+    return blockPosition;
+  }
+
+
   destroy() {
     this.container = null;
   }

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

@@ -1,5 +1,5 @@
 import FormatButton from './FormatButton';
-import Portal from './Portal';
+import Portal from '../block/BlockPortal';
 import { TreeNode } from '$app/block_editor/view/tree_node';
 import { useHoveringToolbar } from './index.hooks';
 

+ 14 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/Overlay.tsx

@@ -0,0 +1,14 @@
+import React, { useState } from 'react';
+import BlockSideTools from '../BlockSideTools';
+import BlockSelection from '../BlockSelection';
+import { BlockEditor } from '@/appflowy_app/block_editor';
+
+export default function Overlay({ blockEditor, container }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
+  const [isDragging, setDragging] = useState(false);
+  return (
+    <>
+      {isDragging ? null : <BlockSideTools blockEditor={blockEditor} container={container} />}
+      <BlockSelection onDragging={setDragging} blockEditor={blockEditor} container={container} />
+    </>
+  );
+}

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/index.tsx

@@ -4,7 +4,7 @@ import { withErrorBoundary } from 'react-error-boundary';
 import ListFallbackComponent from './ListFallbackComponent';
 import BlockListTitle from './BlockListTitle';
 import BlockComponent from '../BlockComponent';
-import BlockSelection from '../BlockSelection';
+import Overlay from './Overlay';
 
 function BlockList(props: BlockListProps) {
   const { root, rowVirtualizer, parentRef, blockEditor } = useBlockList(props);
@@ -46,7 +46,7 @@ function BlockList(props: BlockListProps) {
           ) : null}
         </div>
       </div>
-      {parentRef.current ? <BlockSelection blockEditor={blockEditor} container={parentRef.current} /> : null}
+      {parentRef.current ? <Overlay container={parentRef.current} blockEditor={blockEditor} /> : null}
     </div>
   );
 }

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/Portal.tsx → frontend/appflowy_tauri/src/appflowy_app/components/block/BlockPortal/index.tsx

@@ -1,9 +1,9 @@
 import ReactDOM from 'react-dom';
 
-const Portal = ({ blockId, children }: { blockId: string; children: JSX.Element }) => {
+const BlockPortal = ({ blockId, children }: { blockId: string; children: JSX.Element }) => {
   const root = document.querySelectorAll(`[data-block-id=${blockId}] > .block-overlay`)[0];
 
   return typeof document === 'object' && root ? ReactDOM.createPortal(children, root) : null;
 };
 
-export default Portal;
+export default BlockPortal;

+ 23 - 7
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockSelection/BlockSelection.hooks.tsx

@@ -1,13 +1,26 @@
 import { BlockEditor } from '@/appflowy_app/block_editor';
 import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
 
-export function useBlockSelection({ container, blockEditor }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
+export function useBlockSelection({
+  container,
+  blockEditor,
+  onDragging,
+}: {
+  container: HTMLDivElement;
+  blockEditor: BlockEditor;
+  onDragging?: (_isDragging: boolean) => void;
+}) {
   const blockPositionManager = blockEditor.renderTree.blockPositionManager;
+  const ref = useRef<HTMLDivElement | null>(null);
 
   const [isDragging, setDragging] = useState(false);
   const pointRef = useRef<number[]>([]);
   const startScrollTopRef = useRef<number>(0);
 
+  useEffect(() => {
+    onDragging?.(isDragging);
+  }, [isDragging]);
+
   const [rect, setRect] = useState<{
     startX: number;
     startY: number;
@@ -89,6 +102,7 @@ export function useBlockSelection({ container, blockEditor }: { container: HTMLD
     (e: MouseEvent) => {
       if (!isDragging || !blockPositionManager) return;
       e.preventDefault();
+      e.stopPropagation();
       calcIntersectBlocks(e.clientX, e.clientY);
 
       const { top, bottom } = container.getBoundingClientRect();
@@ -119,19 +133,21 @@ export function useBlockSelection({ container, blockEditor }: { container: HTMLD
   );
 
   useEffect(() => {
-    window.addEventListener('mousedown', handleDragStart);
-    window.addEventListener('mousemove', handleDraging);
-    window.addEventListener('mouseup', handleDragEnd);
+    if (!ref.current) return;
+    document.addEventListener('mousedown', handleDragStart);
+    document.addEventListener('mousemove', handleDraging);
+    document.addEventListener('mouseup', handleDragEnd);
 
     return () => {
-      window.removeEventListener('mousedown', handleDragStart);
-      window.removeEventListener('mousemove', handleDraging);
-      window.removeEventListener('mouseup', handleDragEnd);
+      document.removeEventListener('mousedown', handleDragStart);
+      document.removeEventListener('mousemove', handleDraging);
+      document.removeEventListener('mouseup', handleDragEnd);
     };
   }, [handleDragStart, handleDragEnd, handleDraging]);
 
   return {
     isDragging,
     style,
+    ref,
   };
 }

+ 12 - 3
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockSelection/index.tsx

@@ -2,14 +2,23 @@ import { useBlockSelection } from './BlockSelection.hooks';
 import { BlockEditor } from '$app/block_editor';
 import React from 'react';
 
-function BlockSelection({ container, blockEditor }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
-  const { isDragging, style } = useBlockSelection({
+function BlockSelection({
+  container,
+  blockEditor,
+  onDragging,
+}: {
+  container: HTMLDivElement;
+  blockEditor: BlockEditor;
+  onDragging?: (_isDragging: boolean) => void;
+}) {
+  const { isDragging, style, ref } = useBlockSelection({
     container,
     blockEditor,
+    onDragging,
   });
 
   return (
-    <div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
+    <div ref={ref} className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
       {isDragging ? <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} /> : null}
     </div>
   );

+ 64 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockSideTools/BlockSideTools.hooks.tsx

@@ -0,0 +1,64 @@
+import { BlockEditor } from '@/appflowy_app/block_editor';
+import { BlockType } from '@/appflowy_app/interfaces';
+import { debounce } from '@/appflowy_app/utils/tool';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+export function useBlockSideTools({ blockEditor, container }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
+  const [hoverBlock, setHoverBlock] = useState<string>();
+  const ref = useRef<HTMLDivElement | null>(null);
+
+  const handleMouseMove = useCallback((e: MouseEvent) => {
+    const { clientX, clientY } = e;
+    const x = clientX;
+    const y = clientY + container.scrollTop;
+    const block = blockEditor.renderTree.blockPositionManager?.getViewportBlockByPoint(x, y);
+
+    if (!block) {
+      setHoverBlock('');
+    } else {
+      const node = blockEditor.renderTree.getTreeNode(block.id)!;
+      if ([BlockType.ColumnBlock].includes(node.type)) {
+        setHoverBlock('');
+      } else {
+        setHoverBlock(block.id);
+      }
+    }
+  }, []);
+
+  const debounceMove = useMemo(() => debounce(handleMouseMove, 30), [handleMouseMove]);
+
+  useEffect(() => {
+    const el = ref.current;
+    if (!el) return;
+    if (!hoverBlock) {
+      el.style.opacity = '0';
+      el.style.zIndex = '-1';
+    } else {
+      el.style.opacity = '1';
+      el.style.zIndex = '1';
+      const node = blockEditor.renderTree.getTreeNode(hoverBlock);
+      el.style.top = '3px';
+      if (node?.type === BlockType.HeadingBlock) {
+        if (node.data.level === 1) {
+          el.style.top = '8px';
+        } else if (node.data.level === 2) {
+          el.style.top = '6px';
+        } else {
+          el.style.top = '5px';
+        }
+      }
+    }
+  }, [hoverBlock]);
+
+  useEffect(() => {
+    container.addEventListener('mousemove', debounceMove);
+    return () => {
+      container.removeEventListener('mousemove', debounceMove);
+    };
+  }, [debounceMove]);
+
+  return {
+    hoverBlock,
+    ref,
+  };
+}

+ 37 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockSideTools/index.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+import { useBlockSideTools } from './BlockSideTools.hooks';
+import { BlockEditor } from '@/appflowy_app/block_editor';
+import AddIcon from '@mui/icons-material/Add';
+import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
+import Portal from '../BlockPortal';
+import { IconButton } from '@mui/material';
+
+const sx = { height: 24, width: 24 };
+
+export default function BlockSideTools(props: { container: HTMLDivElement; blockEditor: BlockEditor }) {
+  const { hoverBlock, ref } = useBlockSideTools(props);
+
+  if (!hoverBlock) return null;
+  return (
+    <Portal blockId={hoverBlock}>
+      <div
+        ref={ref}
+        style={{
+          opacity: 0,
+        }}
+        className='z-1 absolute left-[-50px] inline-flex h-[calc(1.5em_+_3px)] transition-opacity duration-500'
+        onMouseDown={(e) => {
+          // prevent toolbar from taking focus away from editor
+          e.preventDefault();
+        }}
+      >
+        <IconButton sx={sx}>
+          <AddIcon />
+        </IconButton>
+        <IconButton sx={sx}>
+          <DragIndicatorIcon />
+        </IconButton>
+      </div>
+    </Portal>
+  );
+}

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

@@ -21,7 +21,7 @@ export default function TextBlock({
   const { showGroups } = toolbarProps || toolbarDefaultProps;
 
   return (
-    <div {...props} className={`${props.className} py-1`}>
+    <div {...props} className={`${props.className || ''} py-1`}>
       <Slate editor={editor} onChange={onChange} value={value}>
         {showGroups.length > 0 && <HoveringToolbar node={node} blockId={node.id} />}
         <Editable

+ 15 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts

@@ -9,6 +9,21 @@ export function debounce(fn: (...args: any[]) => void, delay: number) {
   }
 }
 
+export function throttle(fn: (...args: any[]) => void, delay: number, immediate = true) {
+  let timeout: NodeJS.Timeout | null = null
+  return (...args: any[]) => {
+    if (!timeout) {
+      timeout = setTimeout(() => {
+        timeout = null
+        // eslint-disable-next-line prefer-spread
+        !immediate && fn.apply(undefined, args)
+      }, delay)
+      // eslint-disable-next-line prefer-spread
+      immediate && fn.apply(undefined, args)
+    }
+  }
+}
+
 export function get(obj: any, path: string[], defaultValue?: any) {
   let value = obj;
   for (const prop of path) {