346 lines
		
	
	
		
			9.0 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			346 lines
		
	
	
		
			9.0 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
/*
 | 
						|
 * Copyright 2025 coze-dev Authors
 | 
						|
 *
 | 
						|
 * Licensed under the Apache License, Version 2.0 (the "License");
 | 
						|
 * you may not use this file except in compliance with the License.
 | 
						|
 * You may obtain a copy of the License at
 | 
						|
 *
 | 
						|
 *     http://www.apache.org/licenses/LICENSE-2.0
 | 
						|
 *
 | 
						|
 * Unless required by applicable law or agreed to in writing, software
 | 
						|
 * distributed under the License is distributed on an "AS IS" BASIS,
 | 
						|
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
						|
 * See the License for the specific language governing permissions and
 | 
						|
 * limitations under the License.
 | 
						|
 */
 | 
						|
 | 
						|
import { useEffect, useRef, useState, type MutableRefObject } from 'react';
 | 
						|
 | 
						|
import { defer } from 'lodash-es';
 | 
						|
import { useEventCallback } from '@coze-common/chat-hooks';
 | 
						|
 | 
						|
import { isTouchDevice } from '../utils/is-touch-device';
 | 
						|
import { getSelectionData } from '../utils/get-selection-data';
 | 
						|
import { getRectData } from '../utils/get-rect-data';
 | 
						|
import {
 | 
						|
  Direction,
 | 
						|
  type GrabPosition,
 | 
						|
  type SelectionData,
 | 
						|
} from '../types/selection';
 | 
						|
 | 
						|
const MAX_WIDTH = 40;
 | 
						|
const TIMEOUT = 100;
 | 
						|
 | 
						|
interface GrabParams {
 | 
						|
  /**
 | 
						|
   * Select the Ref of the target container
 | 
						|
   */
 | 
						|
  contentRef: MutableRefObject<HTMLDivElement | null>;
 | 
						|
  /**
 | 
						|
   * Floating Menu Ref
 | 
						|
   */
 | 
						|
  floatMenuRef: MutableRefObject<HTMLDivElement | null> | undefined;
 | 
						|
  /**
 | 
						|
   * Select the callback for the event
 | 
						|
   */
 | 
						|
  onSelectChange: (selectionData: SelectionData | null) => void;
 | 
						|
  /**
 | 
						|
   * Callback of location information
 | 
						|
   */
 | 
						|
  onPositionChange: (position: GrabPosition | null) => void;
 | 
						|
  /**
 | 
						|
   * Resize/Scroll/Wheel is the time of throttling
 | 
						|
   */
 | 
						|
  resizeThrottleTime?: number;
 | 
						|
}
 | 
						|
 | 
						|
// eslint-disable-next-line @coze-arch/max-line-per-function, max-lines-per-function
 | 
						|
export const useGrab = ({
 | 
						|
  contentRef,
 | 
						|
  floatMenuRef,
 | 
						|
  onSelectChange,
 | 
						|
  onPositionChange,
 | 
						|
}: GrabParams) => {
 | 
						|
  const timeoutRef = useRef<number>();
 | 
						|
 | 
						|
  /**
 | 
						|
   * Selection object storage (for internal flow state of hooks)
 | 
						|
   */
 | 
						|
  const selection = useRef<Selection | null>(null);
 | 
						|
 | 
						|
  /**
 | 
						|
   * Data on the final calculation result of the constituency
 | 
						|
   */
 | 
						|
  const selectionData = useRef<SelectionData | null>(null);
 | 
						|
 | 
						|
  /**
 | 
						|
   * In Scrolling
 | 
						|
   */
 | 
						|
  const [isScrolling, setIsScrolling] = useState(false);
 | 
						|
 | 
						|
  /**
 | 
						|
   * Scrolling timer
 | 
						|
   */
 | 
						|
  const scrollingTimer = useRef<number | null>(null);
 | 
						|
 | 
						|
  /**
 | 
						|
   * Is there SelectionData (for optimizing mount logic)
 | 
						|
   */
 | 
						|
  const hasSelectionData = useRef(false);
 | 
						|
 | 
						|
  /**
 | 
						|
   * Clear internal data + trigger callback
 | 
						|
   */
 | 
						|
  const clearSelection = () => {
 | 
						|
    onSelectChange(null);
 | 
						|
    onPositionChange(null);
 | 
						|
    selection.current?.removeAllRanges();
 | 
						|
    selection.current = null;
 | 
						|
    setIsScrolling(false);
 | 
						|
    hasSelectionData.current = false;
 | 
						|
    if (scrollingTimer.current) {
 | 
						|
      clearTimeout(scrollingTimer.current);
 | 
						|
    }
 | 
						|
  };
 | 
						|
 | 
						|
  /**
 | 
						|
   * Handle screen changes Scroll + Resize + Wheel + SelectionChange (mobile device)
 | 
						|
   */
 | 
						|
  const handleScreenChange = () => {
 | 
						|
    // Get Constituency
 | 
						|
    const innerSelection = window.getSelection();
 | 
						|
 | 
						|
    const { direction = Direction.Unknown } = selectionData.current ?? {};
 | 
						|
 | 
						|
    // If the selection is empty, return
 | 
						|
    if (!innerSelection) {
 | 
						|
      onSelectChange(null);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const rectData = getRectData({ selection: innerSelection });
 | 
						|
 | 
						|
    if (!rectData) {
 | 
						|
      onPositionChange(null);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Default use to get the location information of the last line of the selection (both Forward cases)
 | 
						|
    const rangeRect = Array.from(rectData.rangeRects).at(
 | 
						|
      direction === Direction.Backward ? 0 : -1,
 | 
						|
    );
 | 
						|
 | 
						|
    // Returns if the last line of selection information is incorrect
 | 
						|
    if (!rangeRect) {
 | 
						|
      onPositionChange(null);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    let [x, y] = [0, 0];
 | 
						|
    // If the selection is selected from front to back, it is displayed at the end of the last line, otherwise it is displayed at the beginning
 | 
						|
    if (direction === Direction.Backward) {
 | 
						|
      x = rangeRect.left;
 | 
						|
      y = rangeRect.top + rangeRect.height;
 | 
						|
    } else {
 | 
						|
      x = rangeRect.x + rangeRect.width;
 | 
						|
      y = rangeRect.y + rangeRect.height;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Added a logic to avoid the screen
 | 
						|
     */
 | 
						|
    const position = {
 | 
						|
      x: x > screen.width - MAX_WIDTH ? x - MAX_WIDTH : x,
 | 
						|
      y:
 | 
						|
        y > screen.height - MAX_WIDTH
 | 
						|
          ? y - MAX_WIDTH
 | 
						|
          : rangeRect.y + rangeRect.height,
 | 
						|
    };
 | 
						|
 | 
						|
    onPositionChange(position);
 | 
						|
  };
 | 
						|
 | 
						|
  /**
 | 
						|
   * Smart processing screen changes, there is a timer + scroll notification logic
 | 
						|
   */
 | 
						|
  const handleSmartScreenChange = useEventCallback(() => {
 | 
						|
    if (scrollingTimer.current) {
 | 
						|
      clearTimeout(scrollingTimer.current);
 | 
						|
    }
 | 
						|
 | 
						|
    setIsScrolling(true);
 | 
						|
    scrollingTimer.current = setTimeout(() => {
 | 
						|
      handleScreenChange();
 | 
						|
      setIsScrolling(false);
 | 
						|
    }, TIMEOUT);
 | 
						|
  });
 | 
						|
 | 
						|
  /**
 | 
						|
   * Handling the logic of getting the selection
 | 
						|
   */
 | 
						|
  const handleGetSelection = () => {
 | 
						|
    if (!contentRef.current) {
 | 
						|
      onSelectChange(null);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Get Constituency
 | 
						|
    // eslint-disable-next-line @typescript-eslint/naming-convention -- internal variable
 | 
						|
    const _selection = window.getSelection();
 | 
						|
 | 
						|
    // If the selection is empty, return
 | 
						|
    if (!_selection) {
 | 
						|
      onSelectChange(null);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    selection.current = _selection;
 | 
						|
 | 
						|
    // Get constituency data
 | 
						|
    // eslint-disable-next-line @typescript-eslint/naming-convention -- internal variable
 | 
						|
    const _selectionData = getSelectionData({
 | 
						|
      selection: _selection,
 | 
						|
    });
 | 
						|
 | 
						|
    // Hide Floating Button if selection is empty
 | 
						|
    if (!_selectionData || !_selectionData.nodesAncestorIsMessageBox) {
 | 
						|
      onSelectChange(null);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Set display and location information
 | 
						|
    selectionData.current = _selectionData;
 | 
						|
    hasSelectionData.current = Boolean(_selectionData);
 | 
						|
 | 
						|
    handleScreenChange();
 | 
						|
    onSelectChange(_selectionData);
 | 
						|
  };
 | 
						|
 | 
						|
  /**
 | 
						|
   * The action of raising the mouse
 | 
						|
   */
 | 
						|
  const handleMouseUp = useEventCallback(() => {
 | 
						|
    clearTimeout(timeoutRef.current);
 | 
						|
    timeoutRef.current = defer(handleGetSelection);
 | 
						|
  });
 | 
						|
 | 
						|
  /**
 | 
						|
   * The action of keyboard pressing
 | 
						|
   */
 | 
						|
  const handleKeyDown = useEventCallback((e: KeyboardEvent) => {
 | 
						|
    const forbiddenKeyboardSelect = () => {
 | 
						|
      if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
 | 
						|
        onSelectChange(null);
 | 
						|
      }
 | 
						|
    };
 | 
						|
 | 
						|
    if (selectionData.current) {
 | 
						|
      forbiddenKeyboardSelect();
 | 
						|
 | 
						|
      if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
 | 
						|
        handleGetSelection();
 | 
						|
      }
 | 
						|
    }
 | 
						|
  });
 | 
						|
 | 
						|
  /**
 | 
						|
   * Mouse press.
 | 
						|
   */
 | 
						|
  const handleMouseDown = useEventCallback((e: MouseEvent) => {
 | 
						|
    // Check if there is a constituency, and the target of the click event is not in the constituency
 | 
						|
 | 
						|
    if (!contentRef.current || !floatMenuRef || !floatMenuRef?.current) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const { clientX, clientY } = e;
 | 
						|
 | 
						|
    const floatMenuRect = floatMenuRef.current.getBoundingClientRect();
 | 
						|
 | 
						|
    const isInFloatMenu =
 | 
						|
      clientY >= floatMenuRect.top &&
 | 
						|
      clientY <= floatMenuRect.bottom &&
 | 
						|
      clientX >= floatMenuRect.left &&
 | 
						|
      clientX <= floatMenuRect.right;
 | 
						|
 | 
						|
    if (isInFloatMenu) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    clearSelection();
 | 
						|
  });
 | 
						|
 | 
						|
  useEffect(() => {
 | 
						|
    if (!hasSelectionData.current) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Listen for mouse down events
 | 
						|
    window.addEventListener('mousedown', handleMouseDown);
 | 
						|
 | 
						|
    return () => {
 | 
						|
      window.removeEventListener('mousedown', handleMouseDown);
 | 
						|
    };
 | 
						|
  }, [hasSelectionData.current]);
 | 
						|
 | 
						|
  // When visible, mount the listening event to optimize listening
 | 
						|
  useEffect(() => {
 | 
						|
    if (!hasSelectionData.current) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    window.addEventListener('resize', handleSmartScreenChange);
 | 
						|
    window.addEventListener('wheel', handleSmartScreenChange);
 | 
						|
    window.addEventListener('scroll', handleSmartScreenChange);
 | 
						|
    window.addEventListener('keydown', handleKeyDown);
 | 
						|
 | 
						|
    if (isTouchDevice()) {
 | 
						|
      window.addEventListener('selectionchange', handleSmartScreenChange);
 | 
						|
    }
 | 
						|
 | 
						|
    return () => {
 | 
						|
      window.removeEventListener('resize', handleSmartScreenChange);
 | 
						|
      window.removeEventListener('wheel', handleSmartScreenChange);
 | 
						|
      window.removeEventListener('scroll', handleSmartScreenChange);
 | 
						|
      window.removeEventListener('keydown', handleKeyDown);
 | 
						|
      window.removeEventListener('selectionchange', handleSmartScreenChange);
 | 
						|
    };
 | 
						|
  }, [hasSelectionData.current]);
 | 
						|
 | 
						|
  // mount monitor on target
 | 
						|
  useEffect(() => {
 | 
						|
    const target = contentRef.current;
 | 
						|
 | 
						|
    if (!target) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Monitor selection-related mouse lift events
 | 
						|
    target.addEventListener('pointerup', handleMouseUp);
 | 
						|
 | 
						|
    if (isTouchDevice()) {
 | 
						|
      target.addEventListener('selectionchange', handleMouseUp);
 | 
						|
    }
 | 
						|
 | 
						|
    return () => {
 | 
						|
      target.removeEventListener('pointerup', handleMouseUp);
 | 
						|
      target.removeEventListener('selectionchange', handleMouseUp);
 | 
						|
    };
 | 
						|
  }, [contentRef.current]);
 | 
						|
 | 
						|
  return {
 | 
						|
    /**
 | 
						|
     * Clear built-in state and selection
 | 
						|
     */
 | 
						|
    clearSelection,
 | 
						|
    /**
 | 
						|
     * Is it scrolling?
 | 
						|
     */
 | 
						|
    isScrolling,
 | 
						|
    /**
 | 
						|
     * Recalculate the selection position
 | 
						|
     */
 | 
						|
    computePosition: handleSmartScreenChange,
 | 
						|
  };
 | 
						|
};
 |