import { Insets, Size } from "@noya-app/noya-geometry"; import { KEYBOARD_SHORTCUT_PASSTHROUGH_CLASS } from "@noya-app/noya-keymap"; import { assignRef, createBeforeInputNormalizer } from "@noya-app/react-utils"; import React, { CSSProperties, ComponentPropsWithoutRef, ForwardedRef, ReactNode, forwardRef, memo, useCallback, useEffect, useMemo, useRef, } from "react"; import { useAutomaticCanvasSize } from "../hooks/useAutomaticCanvasSize"; type MeasuredChildren = ReactNode | (({ size }: { size: Size }) => ReactNode); export interface InfiniteCanvasProps extends Omit, "onBeforeInput" | "children"> { onBeforeInput?: (e: InputEvent) => void; onChangeSize?: (size: Size, insets: Insets) => void; size?: Size; insets?: Insets; autoFocus?: boolean; children: MeasuredChildren; readOnly?: boolean; shouldHandleKeyboardEvents?: boolean; /** * When true, indicates the user is actively editing text. * On touch devices, contentEditable is only enabled when this is true, * to prevent the keyboard from appearing during touch/drag interactions. */ isEditingText?: boolean; } export const InfiniteCanvas = memo( forwardRef(function InfiniteCanvas( { onChangeSize, size, autoFocus = false, style, className: classNameProp, readOnly, onBeforeInput, shouldHandleKeyboardEvents = true, isEditingText, children, ...props }: InfiniteCanvasProps, forwardedRef: ForwardedRef ) { const containerRef = useRef(null); const { canvasSize: measuredSize } = useAutomaticCanvasSize({ containerRef, onChangeSize, }); const normalizer = useMemo(() => { if (!onBeforeInput) return undefined; return createBeforeInputNormalizer({ onBeforeInput: (e) => onBeforeInput( new InputEvent("beforeinput", { inputType: e.inputType, data: e.data ?? undefined, dataTransfer: (e.dataTransfer as DataTransfer) ?? undefined, }) ), }); }, [onBeforeInput]); const canvasSize = useMemo( () => size ?? measuredSize, [measuredSize, size] ); useEffect(() => { const handleBeforeInput = (event: InputEvent) => { // ensure descendant of container const target = event.target as HTMLElement; if (!containerRef.current?.contains(target)) return; if (normalizer) { normalizer.handleBeforeInput(event); } else { onBeforeInput?.(event); } event.preventDefault(); }; if (onBeforeInput && normalizer) { window.addEventListener("beforeinput", handleBeforeInput); const handleKeyDown = (event: KeyboardEvent) => { if (!containerRef.current?.contains(event.target as HTMLElement)) return; const handled = normalizer.handleKeyDownFallback(event); if (handled) event.preventDefault(); }; const handlePaste = (event: ClipboardEvent) => { if (!containerRef.current?.contains(event.target as HTMLElement)) return; const handled = normalizer.handlePasteFallback(event); if (handled) event.preventDefault(); }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("paste", handlePaste); return () => { window.removeEventListener("beforeinput", handleBeforeInput); window.removeEventListener("paste", handlePaste); window.removeEventListener("keydown", handleKeyDown); }; } }, [normalizer, onBeforeInput]); const className = useMemo(() => { return [ classNameProp, shouldHandleKeyboardEvents ? KEYBOARD_SHORTCUT_PASSTHROUGH_CLASS : undefined, ] .filter(Boolean) .join(" "); }, [classNameProp, shouldHandleKeyboardEvents]); // Only enable contentEditable when actively editing text. // This prevents the keyboard from appearing during touch/drag interactions // on mobile, and avoids cursor flickering during pan on desktop. const contentEditable = onBeforeInput && readOnly !== true && isEditingText; const childrenElement = useMemo(() => { if (!canvasSize) return null; return typeof children === "function" ? children({ size: canvasSize }) : children; }, [canvasSize, children]); return (
{ containerRef.current = value; assignRef(forwardedRef, value); }, [forwardedRef] )} contentEditable={contentEditable} suppressContentEditableWarning style={useMemo( (): CSSProperties => ({ position: "relative", // Prevent browser handling of touch gestures (scroll, zoom) // so the canvas can handle them via JavaScript touchAction: "none", ...style, }), [style] )} className={className} tabIndex={0} onFocus={useCallback(() => containerRef.current?.focus(), [])} data-infinite-canvas-root > {onBeforeInput && } {childrenElement}
); }) ); /** * We need a placeholder to be able to handle beforeinput events on the container. */ const Placeholder = memo(function Placeholder() { return ( a ); });