diff --git a/CHANGELOG.md b/CHANGELOG.md index 24de31507a..93fdcf1881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,7 @@ Breaking changes in this release: - Added core mute/unmute functionality for speech-to-speech via `useRecorder` hook (silent chunks keep server connection alive), in PR [#5688](https://github.com/microsoft/BotFramework-WebChat/pull/5688), by [@pranavjoshi](https://github.com/pranavjoshi001) - 🧪 Added incremental streaming Markdown renderer for livestreaming, in PR [#5799](https://github.com/microsoft/BotFramework-WebChat/pull/5799), by [@OEvgeny](https://github.com/OEvgeny) - Fixed streaming Markdown renderer to preserve link reference definitions during incremental rendering and recover on error, in PR [#5808](https://github.com/microsoft/BotFramework-WebChat/pull/5808), by [@OEvgeny](https://github.com/OEvgeny) +- Added clipboard paste and drag-and-drop file support to both basic and fluent themes, in PR [#5829](https://github.com/microsoft/BotFramework-WebChat/pull/5829), by [@OEvgeny](https://github.com/OEvgeny) ### Changed diff --git a/__tests__/html2/basic/dragAndDrop.upload.html b/__tests__/html2/basic/dragAndDrop.upload.html new file mode 100644 index 0000000000..8ff2169e3a --- /dev/null +++ b/__tests__/html2/basic/dragAndDrop.upload.html @@ -0,0 +1,130 @@ + + + + Drag and drop file upload (basic theme) + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/basic/dragAndDrop.upload.html.snap-1.png b/__tests__/html2/basic/dragAndDrop.upload.html.snap-1.png new file mode 100644 index 0000000000..da211aff2b Binary files /dev/null and b/__tests__/html2/basic/dragAndDrop.upload.html.snap-1.png differ diff --git a/__tests__/html2/basic/dragAndDrop.upload.html.snap-2.png b/__tests__/html2/basic/dragAndDrop.upload.html.snap-2.png new file mode 100644 index 0000000000..b7a044e8a2 Binary files /dev/null and b/__tests__/html2/basic/dragAndDrop.upload.html.snap-2.png differ diff --git a/__tests__/html2/basic/dragAndDrop.upload.html.snap-3.png b/__tests__/html2/basic/dragAndDrop.upload.html.snap-3.png new file mode 100644 index 0000000000..7fe9aaa8d2 Binary files /dev/null and b/__tests__/html2/basic/dragAndDrop.upload.html.snap-3.png differ diff --git a/__tests__/html2/basic/dragAndDrop.upload.html.snap-4.png b/__tests__/html2/basic/dragAndDrop.upload.html.snap-4.png new file mode 100644 index 0000000000..7fe9aaa8d2 Binary files /dev/null and b/__tests__/html2/basic/dragAndDrop.upload.html.snap-4.png differ diff --git a/__tests__/html2/basic/pasteFile.html b/__tests__/html2/basic/pasteFile.html new file mode 100644 index 0000000000..49d8907fe2 --- /dev/null +++ b/__tests__/html2/basic/pasteFile.html @@ -0,0 +1,80 @@ + + + + Paste file into send box + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/basic/pasteFile.html.snap-1.png b/__tests__/html2/basic/pasteFile.html.snap-1.png new file mode 100644 index 0000000000..7fe9aaa8d2 Binary files /dev/null and b/__tests__/html2/basic/pasteFile.html.snap-1.png differ diff --git a/__tests__/html2/basic/pasteFile.html.snap-2.png b/__tests__/html2/basic/pasteFile.html.snap-2.png new file mode 100644 index 0000000000..ff2e130bc6 Binary files /dev/null and b/__tests__/html2/basic/pasteFile.html.snap-2.png differ diff --git a/__tests__/html2/styleOptions/deprecated.hideScrollToEndButton.html.snap-1.png b/__tests__/html2/styleOptions/deprecated.hideScrollToEndButton.html.snap-1.png new file mode 100644 index 0000000000..31cd732ab9 Binary files /dev/null and b/__tests__/html2/styleOptions/deprecated.hideScrollToEndButton.html.snap-1.png differ diff --git a/__tests__/html2/styleOptions/deprecated.hideScrollToEndButton.html.snap-2.png b/__tests__/html2/styleOptions/deprecated.hideScrollToEndButton.html.snap-2.png new file mode 100644 index 0000000000..48d7243e1d Binary files /dev/null and b/__tests__/html2/styleOptions/deprecated.hideScrollToEndButton.html.snap-2.png differ diff --git a/__tests__/html2/styleOptions/deprecated.newMessageButtonFontSize.html.snap-1.png b/__tests__/html2/styleOptions/deprecated.newMessageButtonFontSize.html.snap-1.png new file mode 100644 index 0000000000..43a8b6494b Binary files /dev/null and b/__tests__/html2/styleOptions/deprecated.newMessageButtonFontSize.html.snap-1.png differ diff --git a/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-1.png b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-1.png new file mode 100644 index 0000000000..d44011c6ff Binary files /dev/null and b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-1.png differ diff --git a/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-2.png b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-2.png new file mode 100644 index 0000000000..64201d2856 Binary files /dev/null and b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-2.png differ diff --git a/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-3.png b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-3.png new file mode 100644 index 0000000000..90c246ca12 Binary files /dev/null and b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-3.png differ diff --git a/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-4.png b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-4.png new file mode 100644 index 0000000000..938a7a20bb Binary files /dev/null and b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-4.png differ diff --git a/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-5.png b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-5.png new file mode 100644 index 0000000000..5dfbe33172 Binary files /dev/null and b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-5.png differ diff --git a/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-6.png b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-6.png new file mode 100644 index 0000000000..91c308ac86 Binary files /dev/null and b/__tests__/html2/suggestedActions/styleOptions.deprecated.html.snap-6.png differ diff --git a/packages/bundle/src/boot/actual/hook/minimal.ts b/packages/bundle/src/boot/actual/hook/minimal.ts index 7716ddba9b..4f45c9575f 100644 --- a/packages/bundle/src/boot/actual/hook/minimal.ts +++ b/packages/bundle/src/boot/actual/hook/minimal.ts @@ -23,6 +23,7 @@ export { useDisabled, useDismissNotification, useEmitTypingIndicator, + useFileDropZone, useFocus, useGetActivitiesByKey, useGetActivityByKey, @@ -100,5 +101,6 @@ export { useVoiceSelector, useVoiceState, useWebSpeechPonyfill, + type DropZoneState, type SendBoxFocusOptions } from 'botframework-webchat-component/hook.js'; diff --git a/packages/component/package.json b/packages/component/package.json index ce8b1487f7..1e43ec98b0 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -110,7 +110,7 @@ "scripts": { "build": "npm run --if-present build:pre && npm run build:run && npm run --if-present build:post", "build:post": "npm run build:post:dtsroll && npm run build:post:validate", - "build:post:dtsroll": "dtsroll ./dist/*.d.* && sed -E -i 's/^([[:space:]]*export[[:space:]]+)type[[:space:]]+(\\{[[:space:]]+type[[:space:]]+)/\\1\\2/' ./dist/*.d.ts", + "build:post:dtsroll": "dtsroll ./dist/*.d.* && sed -E -i 's/^([[:space:]]*export[[:space:]]+)type[[:space:]]+(\\{[[:space:]]+type[[:space:]]+)/\\1\\2/' ./dist/*.d.* && sed -i -E -e 's/\\{[[:space:]]*DropZoneState\\b/{ type DropZoneState/g' -e 's/\\btypeof[[:space:]]+DropZoneState\\b/DropZoneState/g' ./dist/*.d.*", "build:post:validate": "npm run build:post:validate:css && npm run build:post:validate:inject-css && npm run build:post:validate:dts", "build:post:validate:css": "vg ast-check lightning-css ./dist/*.css", "build:post:validate:dts": "vg ast-check dist-types ./dist/*.d.*", diff --git a/packages/component/src/SendBox/BasicSendBox.tsx b/packages/component/src/SendBox/BasicSendBox.tsx index 3a05d9828d..91386ea9ba 100644 --- a/packages/component/src/SendBox/BasicSendBox.tsx +++ b/packages/component/src/SendBox/BasicSendBox.tsx @@ -2,15 +2,19 @@ import { SendBoxToolbarMiddlewareProxy, hooks } from 'botframework-webchat-api'; import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; import { Constants } from 'botframework-webchat-core'; import classNames from 'classnames'; -import React, { memo } from 'react'; +import React, { memo, useCallback, type ClipboardEventHandler } from 'react'; +import { useRefFrom } from 'use-ref-from'; import { object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import useMakeThumbnail from '../hooks/useMakeThumbnail'; import useStyleSet from '../hooks/useStyleSet'; import useWebSpeechPonyfill from '../hooks/useWebSpeechPonyfill'; import useErrorMessageId from '../providers/internal/SendBox/useErrorMessageId'; +import useSubmit from '../providers/internal/SendBox/useSubmit'; import { AttachmentBar } from './AttachmentBar/index'; import DictationInterims from './DictationInterims'; +import DropZone from './DropZone'; import MicrophoneButton from './MicrophoneButton'; import SendButton from './SendButton'; import SuggestedActions from './SuggestedActions'; @@ -20,10 +24,29 @@ const { DictateState: { DICTATING, STARTING } } = Constants; -const { useDirection, useDictateState, useStyleOptions } = hooks; +const { useDirection, useDictateState, useSendBoxAttachments, useStyleOptions, useUIState } = hooks; const ROOT_STYLE = { '&.webchat__send-box': { + display: 'grid', + gridTemplateAreas: '"suggested-actions" "content"', + gridTemplateRows: 'auto 1fr', + + '& .webchat__suggested-actions': { + gridArea: 'suggested-actions', + minWidth: '0' + }, + + '& .webchat__send-box__main': { + gridArea: 'content', + minWidth: '0' + }, + + '& .webchat__drop-zone': { + gridArea: 'content', + minWidth: '0' + }, + '& .webchat__send-box__button': { flexShrink: 0 }, '& .webchat__send-box__dictation-interims': { flex: 10000 }, '& .webchat__send-box__microphone-button': { flex: 1 }, @@ -49,18 +72,61 @@ type BasicSendBoxProps = InferInput; function BasicSendBox(props: BasicSendBoxProps) { const { className } = validateProps(basicSendBoxPropsSchema, props); - const [{ sendBoxButtonAlignment }] = useStyleOptions(); + const [{ disableFileUpload, sendAttachmentOn, sendBoxButtonAlignment }] = useStyleOptions(); const [{ sendBox: sendBoxStyleSet }] = useStyleSet(); const [{ SpeechRecognition = undefined } = {}] = useWebSpeechPonyfill(); const [direction] = useDirection(); const [errorMessageId] = useErrorMessageId(); + const [sendBoxAttachments, setSendBoxAttachments] = useSendBoxAttachments(); const [speechInterimsVisible] = useSendBoxSpeechInterimsVisible(); + const [uiState] = useUIState(); + const makeThumbnail = useMakeThumbnail(); const styleToEmotionObject = useStyleToEmotionObject(); + const submit = useSubmit(); const rootClassName = styleToEmotionObject(ROOT_STYLE) + ''; + const disabled = uiState === 'disabled'; + const sendAttachmentOnRef = useRefFrom(sendAttachmentOn); + const sendBoxAttachmentsRef = useRefFrom(sendBoxAttachments); + const supportSpeechRecognition = !!SpeechRecognition; + const handleAddFiles = useCallback( + async (inputFiles: readonly File[]) => { + const newAttachments = Object.freeze( + await Promise.all( + inputFiles.map(file => + makeThumbnail(file).then(thumbnailURL => + Object.freeze({ blob: file, ...(thumbnailURL && { thumbnailURL }) }) + ) + ) + ) + ); + + setSendBoxAttachments(Object.freeze([].concat(sendBoxAttachmentsRef.current, newAttachments))); + + sendAttachmentOnRef.current === 'attach' && submit(); + }, + [makeThumbnail, sendAttachmentOnRef, sendBoxAttachmentsRef, setSendBoxAttachments, submit] + ); + + const handlePaste = useCallback( + event => { + if (disableFileUpload || disabled) { + return; + } + + const { files } = event.clipboardData; + + if (files.length) { + event.preventDefault(); + handleAddFiles(Object.freeze(Array.from(files))); + } + }, + [disabled, disableFileUpload, handleAddFiles] + ); + const buttonClassName = classNames('webchat__send-box__button', { 'webchat__send-box__button--align-bottom': sendBoxButtonAlignment === 'bottom', 'webchat__send-box__button--align-stretch': sendBoxButtonAlignment !== 'bottom' && sendBoxButtonAlignment !== 'top', @@ -74,6 +140,7 @@ function BasicSendBox(props: BasicSendBoxProps) { aria-invalid={!!errorMessageId} className={classNames('webchat__send-box', sendBoxStyleSet + '', rootClassName + '', (className || '') + '')} dir={direction} + onPaste={handlePaste} role="form" > @@ -93,6 +160,7 @@ function BasicSendBox(props: BasicSendBoxProps) { )} + {!disableFileUpload && !disabled && } ); } diff --git a/packages/component/src/SendBox/DropZone.tsx b/packages/component/src/SendBox/DropZone.tsx new file mode 100644 index 0000000000..2c3250072c --- /dev/null +++ b/packages/component/src/SendBox/DropZone.tsx @@ -0,0 +1,64 @@ +import { hooks } from 'botframework-webchat-api'; +import { useFileDropZone } from '@msinternal/botframework-webchat-react-hooks'; +import React, { memo } from 'react'; + +import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import testIds from '../testIds'; + +const { useLocalizer } = hooks; + +const DROP_ZONE_STYLE = { + '&.webchat__drop-zone': { + backdropFilter: 'blur(4px)', + backgroundColor: 'rgba(0, 0, 0, 0.08)', + border: '2px dashed #999', + borderRadius: 4, + cursor: 'copy', + display: 'grid', + gap: 8, + placeContent: 'center', + placeItems: 'center', + transition: 'background-color 0.2s ease', + + '&.webchat__drop-zone--droppable': { + backgroundColor: 'rgba(0, 120, 212, 0.15)', + borderColor: '#0078d4' + } + } +}; + +const DROP_ZONE_TEXT_STYLE = { + '&.webchat__drop-zone__text': { + fontSize: 14, + fontWeight: 600, + pointerEvents: 'none' as const + } +}; + +type DropZoneProps = Readonly<{ + onFilesAdded: (files: File[]) => void; +}>; + +function DropZone({ onFilesAdded }: DropZoneProps) { + const { dropZoneRef, dropZoneState, handleDragOver, handleDrop } = useFileDropZone(onFilesAdded); + const localize = useLocalizer(); + const styleToEmotionObject = useStyleToEmotionObject(); + const dropZoneClassName = styleToEmotionObject(DROP_ZONE_STYLE) + ''; + const dropZoneTextClassName = styleToEmotionObject(DROP_ZONE_TEXT_STYLE) + ''; + + return dropZoneState ? ( +
+ {localize('TEXT_INPUT_DROP_ZONE')} +
+ ) : null; +} + +DropZone.displayName = 'DropZone'; + +export default memo(DropZone); diff --git a/packages/component/src/Styles/StyleSet/SendBox.ts b/packages/component/src/Styles/StyleSet/SendBox.ts index 536c720d54..906658d06f 100644 --- a/packages/component/src/Styles/StyleSet/SendBox.ts +++ b/packages/component/src/Styles/StyleSet/SendBox.ts @@ -11,6 +11,7 @@ function stringifyNumericPixel(value: number | string): string { export default function createSendBoxStyle({ paddingRegular, + primaryFont, sendBoxBackground, sendBoxBorderBottom, sendBoxBorderLeft, @@ -20,6 +21,8 @@ export default function createSendBoxStyle({ }: StrictStyleOptions) { return { '&.webchat__send-box': { + fontFamily: primaryFont, + '& .webchat__send-box__button--align-bottom': { alignSelf: 'flex-end' }, '& .webchat__send-box__button--align-stretch': { alignSelf: 'stretch' }, '& .webchat__send-box__button--align-top': { alignSelf: 'flex-start' }, diff --git a/packages/component/src/boot/hook.ts b/packages/component/src/boot/hook.ts index 3cb3531284..62a6693c3e 100644 --- a/packages/component/src/boot/hook.ts +++ b/packages/component/src/boot/hook.ts @@ -79,6 +79,8 @@ export { useVoiceState } from 'botframework-webchat-api/hook.js'; +export { useFileDropZone, type DropZoneState } from '@msinternal/botframework-webchat-react-hooks'; + // #region Overrides export { // We are overwriting the `useSendFiles` hook from bf-wc-api and adding thumbnailing support. diff --git a/packages/component/src/testIds.ts b/packages/component/src/testIds.ts index 3c1bb6f4c8..ed5162d976 100644 --- a/packages/component/src/testIds.ts +++ b/packages/component/src/testIds.ts @@ -4,6 +4,7 @@ const testIds = { feedbackButton: 'feedback button', feedbackSendBox: 'feedback sendbox', sendBoxAttachmentBar: 'send box attachment bar', + sendBoxDropZone: 'send box drop zone', sendBoxAttachmentBarItem: 'send box attachment bar item', sendBoxAttachmentBarItemDeleteButton: 'send box attachment bar item delete button', sendBoxMicrophoneButton: 'send box microphone button', diff --git a/packages/fluent-theme/src/components/dropZone/DropZone.tsx b/packages/fluent-theme/src/components/dropZone/DropZone.tsx index 1523f8f232..7d4abdd190 100644 --- a/packages/fluent-theme/src/components/dropZone/DropZone.tsx +++ b/packages/fluent-theme/src/components/dropZone/DropZone.tsx @@ -1,125 +1,16 @@ -import { hooks } from 'botframework-webchat'; +import { useFileDropZone, useLocalizer } from 'botframework-webchat/hook.js'; import cx from 'classnames'; -import React, { - memo, - useCallback, - useEffect, - useRef, - useState, - type DragEventHandler, - type DragEvent as ReactDragEvent -} from 'react'; -import { useRefFrom } from 'use-ref-from'; +import React, { memo } from 'react'; import { useStyles } from '../../styles'; import testIds from '../../testIds'; import { FluentIcon } from '../icon'; import styles from './DropZone.module.css'; -const { useLocalizer } = hooks; - -const handleDragOver = (event: ReactDragEvent | DragEvent) => { - // Prevent default dragover behavior to enable drop event triggering. - // Browsers require this to fire subsequent drop events - without it, - // they would handle the drop directly (e.g., open files in new tabs). - // This is needed regardless of whether we prevent default drop behavior, - // as it ensures our dropzone receives the drop event first. If we allow - // default drop handling (by not calling preventDefault there), the browser - // will still process the drop after our event handlers complete. - event.preventDefault(); -}; - -// Notes: For files dragging from outside of browser, it only tell us if it is a "File" instead of "text/plain" or "text/uri-list". -// For images dragging inside of browser, it only tell us that it is "text/plain", "text/uri-list" and "text/html". But not "image/*". -// So we cannot allowlist what is droppable. -// We are using case-insensitive of type "files" so we can drag in WebDriver. -const isFilesTransferEvent = (event: DragEvent) => - !!event.dataTransfer?.types?.some(type => type.toLowerCase() === 'files'); - -function isDescendantOf(target: Node, ancestor: Node): boolean { - let current = target.parentNode; - - while (current) { - if (current === ancestor) { - return true; - } - - current = current.parentNode; - } - - return false; -} - -const DropZone = (props: { readonly onFilesAdded: (files: File[]) => void }) => { - const [dropZoneState, setDropZoneState] = useState(false); +const DropZone = (props: { readonly onFilesAdded: (files: readonly File[]) => void }) => { + const { dropZoneRef, dropZoneState, handleDragOver, handleDrop } = useFileDropZone(props.onFilesAdded); const classNames = useStyles(styles); - const dropZoneRef = useRef(null); const localize = useLocalizer(); - const onFilesAddedRef = useRefFrom(props.onFilesAdded); - - useEffect(() => { - let entranceCounter = 0; - - const handleDragEnter = (event: DragEvent) => { - document.addEventListener('dragover', handleDragOver); - - entranceCounter++; - - if (isFilesTransferEvent(event)) { - setDropZoneState( - dropZoneRef.current && - (event.target === dropZoneRef.current || - (event.target instanceof HTMLElement && isDescendantOf(event.target, dropZoneRef.current))) - ? 'droppable' - : 'visible' - ); - } - }; - - const handleDragLeave = () => --entranceCounter <= 0 && setDropZoneState(false); - - const handleDragEnd = () => { - document.removeEventListener('dragover', handleDragOver); - - entranceCounter = 0; - - setDropZoneState(false); - }; - - const handleDocumentDrop = (event: DragEvent) => { - if (!dropZoneRef.current?.contains(event.target as Node)) { - handleDragEnd(); - } - }; - - document.addEventListener('dragend', handleDragEnd); - document.addEventListener('dragenter', handleDragEnter); - document.addEventListener('dragleave', handleDragLeave); - document.addEventListener('drop', handleDocumentDrop); - - return () => { - document.removeEventListener('dragend', handleDragEnd); - document.removeEventListener('dragenter', handleDragEnter); - document.removeEventListener('dragleave', handleDragLeave); - document.removeEventListener('dragover', handleDragOver); - document.removeEventListener('drop', handleDocumentDrop); - }; - }, [setDropZoneState]); - - const handleDrop = useCallback>( - event => { - event.preventDefault(); - - setDropZoneState(false); - - if (!isFilesTransferEvent(event.nativeEvent)) { - return; - } - - onFilesAddedRef.current([...event.dataTransfer.files]); - }, - [onFilesAddedRef, setDropZoneState] - ); return dropZoneState ? (
{ + async (inputFiles: readonly File[]) => { const newAttachments = Object.freeze( await Promise.all( inputFiles.map(file => @@ -146,6 +147,22 @@ function SendBox(props: Props) { const handleClick = useCallback(({ currentTarget }) => currentTarget.removeAttribute('inputmode'), []); + const handlePaste = useCallback( + event => { + if (disableFileUpload || uiState === 'disabled') { + return; + } + + const { files } = event.clipboardData; + + if (files.length) { + event.preventDefault(); + handleAddFiles(Object.freeze(Array.from(files))); + } + }, + [disableFileUpload, handleAddFiles, uiState] + ); + const handleFormSubmit: FormEventHandler = useCallback( event => { event.preventDefault(); @@ -206,6 +223,7 @@ function SendBox(props: Props) { {...aria} className={cx(classNames['sendbox'], variantClassName, props.className)} data-testid={testIds.sendBoxContainer} + onPaste={handlePaste} onSubmit={handleFormSubmit} > diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index 8dc71742e5..4673f64313 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -1,3 +1,4 @@ export { default as useDebugDeps } from './useDebugDeps'; +export { default as useFileDropZone, type DropZoneState } from './useFileDropZone'; export { default as useMemoIterable } from './useMemoIterable'; export { default as useMemoWithPrevious } from './useMemoWithPrevious'; diff --git a/packages/react-hooks/src/useFileDropZone.ts b/packages/react-hooks/src/useFileDropZone.ts new file mode 100644 index 0000000000..7efc966450 --- /dev/null +++ b/packages/react-hooks/src/useFileDropZone.ts @@ -0,0 +1,139 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type DragEventHandler, + type DragEvent as ReactDragEvent +} from 'react'; +import { useRefFrom } from 'use-ref-from'; + +type DropZoneState = false | 'visible' | 'droppable'; + +const isFilesTransferEvent = (event: DragEvent) => + !!event.dataTransfer?.types?.some(type => type.toLowerCase() === 'files'); + +const isOrIsDescendantOf = (target: unknown, ancestor: Node | null): boolean => { + if (!ancestor) { + return false; + } + + if (target === ancestor) { + return true; + } + + if (!(target instanceof HTMLElement)) { + return false; + } + + let current: Node | null = target; + while ((current = current.parentNode)) { + if (current === ancestor) { + return true; + } + } + + return false; +}; + +/** + * Shared drag-and-drop state management hook for file drop zones. + * Manages global document event listeners and drop zone state. + * + * @param onFilesAdded - Callback invoked when files are dropped + * @returns Object containing dropZoneState, dropZoneRef, and event handlers + */ +function useFileDropZone(onFilesAdded: (files: readonly File[]) => void): Readonly<{ + dropZoneState: DropZoneState; + dropZoneRef: React.RefObject; + handleDragOver: DragEventHandler; + handleDrop: DragEventHandler; +}> { + const [dropZoneState, setDropZoneState] = useState(false); + const dropZoneRef = useRef(null); + const onFilesAddedRef = useRefFrom(onFilesAdded); + + // Prevent default dragover behavior to enable drop event triggering. + // Browsers require this to fire subsequent drop events - without it, + // they would handle the drop directly (e.g., open files in new tabs). + // This is needed regardless of whether we prevent default drop behavior, + // as it ensures our dropzone receives the drop event first. If we allow + // default drop handling (by not calling preventDefault there), the browser + // will still process the drop after our event handlers complete. + const handleDragOver = useCallback((event: ReactDragEvent | DragEvent) => { + event.preventDefault(); + }, []); + + useEffect(() => { + let entranceCounter = 0; + + const handleDragEnter = (event: DragEvent) => { + document.addEventListener('dragover', handleDragOver); + + entranceCounter++; + + if (isFilesTransferEvent(event)) { + setDropZoneState(isOrIsDescendantOf(event.target, dropZoneRef.current) ? 'droppable' : 'visible'); + } + }; + + const handleDragLeave = () => --entranceCounter <= 0 && setDropZoneState(false); + + const handleDragEnd = () => { + document.removeEventListener('dragover', handleDragOver); + + entranceCounter = 0; + + setDropZoneState(false); + }; + + const handleDocumentDrop = (event: DragEvent) => { + if (!dropZoneRef.current?.contains(event.target as Node)) { + handleDragEnd(); + } + }; + + document.addEventListener('dragend', handleDragEnd); + document.addEventListener('dragenter', handleDragEnter); + document.addEventListener('dragleave', handleDragLeave); + document.addEventListener('drop', handleDocumentDrop); + + return () => { + document.removeEventListener('dragend', handleDragEnd); + document.removeEventListener('dragenter', handleDragEnter); + document.removeEventListener('dragleave', handleDragLeave); + document.removeEventListener('dragover', handleDragOver); + document.removeEventListener('drop', handleDocumentDrop); + }; + }, [handleDragOver, setDropZoneState]); + + const handleDrop = useCallback>( + event => { + event.preventDefault(); + + setDropZoneState(false); + + if (!isFilesTransferEvent(event.nativeEvent)) { + return; + } + + onFilesAddedRef.current(Object.freeze(Array.from(event.dataTransfer.files))); + }, + [onFilesAddedRef] + ); + + return useMemo( + () => + Object.freeze({ + dropZoneRef, + dropZoneState, + handleDragOver, + handleDrop + }), + [dropZoneRef, dropZoneState, handleDragOver, handleDrop] + ); +} + +export default useFileDropZone; +export { type DropZoneState };