import { CursorState } from '@getguru/slate-yjs-core'; import { RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { BaseRange, NodeMatch, Text } from 'slate'; import { getCursorRange } from '../utils/getCursorRange'; import { CaretPosition, getOverlayPosition, OverlayPosition, SelectionRect, } from '../utils/getOverlayPosition'; import { useRemoteCursorEditor } from './useRemoteCursorEditor'; import { useRemoteCursorStates } from './useRemoteCursorStates'; import { useOnResize, useRequestRerender } from './utils'; const FROZEN_EMPTY_ARRAY = Object.freeze([]); export type UseRemoteCursorOverlayPositionsOptions = { shouldGenerateOverlay?: NodeMatch; } & ( | { // Container the overlay will be rendered in. If set, all returned overlay positions // will be relative to this container and the cursor positions will be automatically // updated on container resize. containerRef?: undefined; } | { containerRef: RefObject; // Whether to refresh the cursor overlay positions on container resize. Defaults // to true. If set to 'debounced', the remote cursor positions will be updated // each animation frame. refreshOnResize?: boolean | 'debounced'; } ); export type CursorOverlayData> = CursorState & { range: BaseRange | null; caretPosition: CaretPosition | null; selectionRects: SelectionRect[]; }; export function useRemoteCursorOverlayPositions< TCursorData extends Record, TContainer extends HTMLElement = HTMLDivElement >({ containerRef, shouldGenerateOverlay, ...opts }: UseRemoteCursorOverlayPositionsOptions = {}) { const editor = useRemoteCursorEditor(); const cursorStates = useRemoteCursorStates(); const requestRerender = useRequestRerender(); const overlayPositionCache = useRef( new WeakMap() ); const [overlayPositions, setOverlayPositions] = useState< Record >({}); const refreshOnResize = 'refreshOnResize' in opts ? opts.refreshOnResize ?? true : true; useOnResize(refreshOnResize ? containerRef : undefined, () => { overlayPositionCache.current = new WeakMap(); requestRerender(refreshOnResize !== 'debounced'); }); // Update selection rects after paint // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { // We have a container ref but the ref is null => container // isn't mounted to we can't calculate the selection rects. if (containerRef && !containerRef.current) { return; } const containerRect = containerRef?.current?.getBoundingClientRect(); const xOffset = containerRect?.x ?? 0; const yOffset = containerRect?.y ?? 0; let overlayPositionsChanged = Object.keys(overlayPositions).length !== Object.keys(cursorStates).length; const updated = Object.fromEntries( Object.entries(cursorStates).map(([key, state]) => { const range = state.relativeSelection && getCursorRange(editor, state); if (!range) { return [key, FROZEN_EMPTY_ARRAY]; } const cached = overlayPositionCache.current.get(range); if (cached) { return [key, cached]; } const overlayPosition = getOverlayPosition(editor, range, { xOffset, yOffset, shouldGenerateOverlay, }); overlayPositionsChanged = true; overlayPositionCache.current.set(range, overlayPosition); return [key, overlayPosition]; }) ); if (overlayPositionsChanged) { setOverlayPositions(updated); } }); const overlayData = useMemo[]>( () => Object.entries(cursorStates).map(([clientId, state]) => { const range = state.relativeSelection && getCursorRange(editor, state); const overlayPosition = overlayPositions[clientId]; return { ...state, range, caretPosition: overlayPosition?.caretPosition ?? null, selectionRects: overlayPosition?.selectionRects ?? FROZEN_EMPTY_ARRAY, }; }), [cursorStates, editor, overlayPositions] ); const refresh = useCallback(() => { overlayPositionCache.current = new WeakMap(); requestRerender(true); }, [requestRerender]); return [overlayData, refresh] as const; }