diff --git a/frontend/src/components/body-portal/index.js b/frontend/src/components/body-portal/index.js new file mode 100644 index 0000000000..caaabb5a4b --- /dev/null +++ b/frontend/src/components/body-portal/index.js @@ -0,0 +1,36 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import { canUseDOM } from '../../utils/dom'; + +class BodyPortal extends React.Component { + componentWillUnmount() { + if (this.defaultNode) { + document.body.removeChild(this.defaultNode); + } + this.defaultNode = null; + } + + render() { + if (!canUseDOM) { + return null; + } + + if (!this.props.node && !this.defaultNode) { + this.defaultNode = document.createElement('div'); + document.body.appendChild(this.defaultNode); + } + + return ReactDOM.createPortal( + this.props.children, + this.props.node || this.defaultNode, + ); + } +} + +BodyPortal.propTypes = { + children: PropTypes.node.isRequired, + node: PropTypes.any, +}; + +export default BodyPortal; diff --git a/frontend/src/components/context-menu/globalEventListener.js b/frontend/src/components/context-menu/globalEventListener.js index 1aa8a95abf..b2f29c6e76 100644 --- a/frontend/src/components/context-menu/globalEventListener.js +++ b/frontend/src/components/context-menu/globalEventListener.js @@ -1,5 +1,6 @@ import { MENU_SHOW, MENU_HIDE } from './actions'; -import { uniqueId, hasOwnProp, canUseDOM } from './helpers'; +import { uniqueId, hasOwnProp } from './helpers'; +import { canUseDOM } from '../../utils/dom'; class GlobalEventListener { diff --git a/frontend/src/components/context-menu/helpers.js b/frontend/src/components/context-menu/helpers.js index 1db31a2f82..6f4064ad1b 100644 --- a/frontend/src/components/context-menu/helpers.js +++ b/frontend/src/components/context-menu/helpers.js @@ -11,7 +11,3 @@ export function uniqueId() { } export const store = {}; - -export const canUseDOM = Boolean( - typeof window !== 'undefined' && window.document && window.document.createElement -); diff --git a/frontend/src/components/dialog/image-dialog/index.js b/frontend/src/components/dialog/image-dialog/index.js index 422a76f40d..ccbe884409 100644 --- a/frontend/src/components/dialog/image-dialog/index.js +++ b/frontend/src/components/dialog/image-dialog/index.js @@ -52,7 +52,7 @@ const ImageDialog = ({ repoID, repoInfo, enableRotate: oldEnableRotate = true, i const isSystemFolder = SYSTEM_FOLDERS.find(folderPath => mainImg.parentDir.startsWith(folderPath)); let onOCR = null; if (enableOCR && enableMetadata && canModify && !isSystemFolder) { - onOCR = () => onOCRByImageDialog({ parentDir: mainImg.parentDir, fileName: mainImg.name }); + onOCR = () => onOCRByImageDialog({ parentDir: mainImg.parentDir, fileName: mainImg.name }, document.activeElement); } const renderSidePanel = () => { diff --git a/frontend/src/components/sf-table/masks/drag-mask.js b/frontend/src/components/sf-table/masks/drag-mask.js index 8001642b0d..44984933b7 100644 --- a/frontend/src/components/sf-table/masks/drag-mask.js +++ b/frontend/src/components/sf-table/masks/drag-mask.js @@ -14,7 +14,8 @@ function DragMask({ draggedRange, getSelectedRangeDimensions, getSelectedDimensi return ( ); } diff --git a/frontend/src/components/sf-table/masks/selection-mask.js b/frontend/src/components/sf-table/masks/selection-mask.js index fb2a38e32e..b77979c12b 100644 --- a/frontend/src/components/sf-table/masks/selection-mask.js +++ b/frontend/src/components/sf-table/masks/selection-mask.js @@ -7,6 +7,7 @@ function SelectionMask({ innerRef, selectedPosition, getSelectedDimensions, chil return ( {children} diff --git a/frontend/src/components/toolbar/table-files-toolbar.js b/frontend/src/components/toolbar/table-files-toolbar.js index b535d66d0c..6138c24d25 100644 --- a/frontend/src/components/toolbar/table-files-toolbar.js +++ b/frontend/src/components/toolbar/table-files-toolbar.js @@ -16,6 +16,7 @@ import { openInNewTab, openParentFolder } from '../../metadata/utils/file'; const TableFilesToolbar = ({ repoID }) => { const [selectedRecordIds, setSelectedRecordIds] = useState([]); const metadataRef = useRef([]); + const menuRef = useRef(null); const { enableOCR } = useMetadataStatus(); const canModify = window.sfMetadataContext && window.sfMetadataContext.canModify(); @@ -139,7 +140,7 @@ const TableFilesToolbar = ({ repoID }) => { break; } case TextTranslation.OCR.key: { - eventBus && eventBus.dispatch(EVENT_BUS_TYPE.OCR, records[0]); + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.OCR, records[0], menuRef.current.dropdownRef.current); break; } default: @@ -180,8 +181,9 @@ const TableFilesToolbar = ({ repoID }) => { } {length > 0 && ( diff --git a/frontend/src/hooks/metadata-ai-operation.js b/frontend/src/hooks/metadata-ai-operation.js index e4894189aa..dc09e13bca 100644 --- a/frontend/src/hooks/metadata-ai-operation.js +++ b/frontend/src/hooks/metadata-ai-operation.js @@ -4,7 +4,7 @@ import { Utils } from '../utils/utils'; import toaster from '../components/toast'; import { PRIVATE_COLUMN_KEY, EVENT_BUS_TYPE } from '../metadata/constants'; import { gettext, lang } from '../utils/constants'; -import OCRResultDialog from '../metadata/components/dialog/ocr-result-dialog'; +import { OCRResultPopover } from '../metadata/components/popover'; import FileTagsDialog from '../metadata/components/dialog/file-tags-dialog'; // This hook provides content related to metadata ai operation @@ -23,6 +23,7 @@ export const MetadataAIOperationsProvider = ({ const [isFileTagsDialogShow, setFileTagsDialogShow] = useState(false); const recordRef = useRef(null); + const targetRef = useRef(null); const opCallBack = useRef(null); const permission = useMemo(() => repoInfo.permission !== 'admin' && repoInfo.permission !== 'rw' ? 'r' : 'rw', [repoInfo]); @@ -36,21 +37,24 @@ export const MetadataAIOperationsProvider = ({ const closeOcrResultDialog = useCallback(() => { recordRef.current = null; + targetRef.current = null; opCallBack.current = null; setOcrResultDialogShow(false); }, []); - const onOCR = useCallback((record, { success_callback }) => { + const onOCR = useCallback((record, { success_callback }, target) => { + targetRef.current = target; recordRef.current = record; opCallBack.current = success_callback; setOcrResultDialogShow(true); }, []); - const onOCRByImageDialog = useCallback(({ parentDir, fileName } = {}) => { + const onOCRByImageDialog = useCallback(({ parentDir, fileName } = {}, target) => { recordRef.current = { [PRIVATE_COLUMN_KEY.PARENT_DIR]: parentDir, [PRIVATE_COLUMN_KEY.FILE_NAME]: fileName, }; + targetRef.current = target; opCallBack.current = (description) => { const update = { [PRIVATE_COLUMN_KEY.FILE_DESCRIPTION]: description }; @@ -149,7 +153,13 @@ export const MetadataAIOperationsProvider = ({ )} {isOcrResultDialogShow && ( - + )} ); diff --git a/frontend/src/metadata/components/context-menu/index.js b/frontend/src/metadata/components/context-menu/index.js index 58000ede9f..afda46b754 100644 --- a/frontend/src/metadata/components/context-menu/index.js +++ b/frontend/src/metadata/components/context-menu/index.js @@ -33,11 +33,8 @@ const ContextMenu = ({ const dividerHeight = 16; const optionHeight = 32; const menuDefaultHeight = options.reduce((total, option) => { - if (option === 'Divider') { - return total + dividerHeight; - } else { - return total + optionHeight; - } + if (option === 'Divider') return total + dividerHeight; + return total + optionHeight; }, menuMargin + indent); if (menuStyles.left + menuDefaultWidth + indent > window.innerWidth) { menuStyles.left = window.innerWidth - menuDefaultWidth - indent; diff --git a/frontend/src/metadata/components/dialog/file-tags-dialog/index.css b/frontend/src/metadata/components/dialog/file-tags-dialog/index.css index e8d4801e6a..abe5e5a2e1 100644 --- a/frontend/src/metadata/components/dialog/file-tags-dialog/index.css +++ b/frontend/src/metadata/components/dialog/file-tags-dialog/index.css @@ -35,7 +35,6 @@ align-items: center; } - .sf-file-exit-tag { display: inline-flex; align-items: center; diff --git a/frontend/src/metadata/components/dialog/ocr-result-dialog/index.css b/frontend/src/metadata/components/dialog/ocr-result-dialog/index.css deleted file mode 100644 index dd988a6d9f..0000000000 --- a/frontend/src/metadata/components/dialog/ocr-result-dialog/index.css +++ /dev/null @@ -1,8 +0,0 @@ -.sf-metadata-ocr-file-dialog .longtext-header-divider { - border-bottom: 1px solid #e9ecef; -} - -.sf-metadata-ocr-file-dialog .seafile-multicolor-icon-save { - height: 15px; - width: 15px; -} diff --git a/frontend/src/metadata/components/dialog/ocr-result-dialog/index.js b/frontend/src/metadata/components/dialog/ocr-result-dialog/index.js deleted file mode 100644 index 030bc963c6..0000000000 --- a/frontend/src/metadata/components/dialog/ocr-result-dialog/index.js +++ /dev/null @@ -1,148 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; -import { I18nextProvider } from 'react-i18next'; -import { SimpleEditor, getBrowserInfo, LongTextModal, BrowserTip, slateToMdString, MarkdownPreview } from '@seafile/seafile-editor'; -import { gettext, lang } from '../../../../utils/constants'; -import { getFileNameFromRecord, getParentDirFromRecord } from '../../../utils/cell'; -import { Utils } from '../../../../utils/utils'; -import i18n from '../../../../_i18n/i18n-seafile-editor'; -import Icon from '../../../../components/icon'; -import metadataAPI from '../../../api'; -import { useMetadataAIOperations } from '../../../../hooks/metadata-ai-operation'; - -import './index.css'; - -const OCRResultDialog = ({ repoID, record, onToggle, saveToDescription }) => { - const [isLoading, setLoading] = useState(true); - const [errorMessage, setErrorMessage] = useState(''); - const [isFullScreen, setIsFullScreen] = useState(false); - const [dialogStyle, setDialogStyle] = useState({}); - const { canModify } = useMetadataAIOperations(); - - const ocrResult = useRef(null); - - const parentDir = useMemo(() => getParentDirFromRecord(record), [record]); - const fileName = useMemo(() => getFileNameFromRecord(record), [record]); - - const editorRef = useRef(null); - - const { isValidBrowser, isWindowsWechat } = useMemo(() => { - return getBrowserInfo(true); - }, []); - - const onFullScreenToggle = useCallback(() => { - let containerStyle = {}; - if (!isFullScreen) { - containerStyle = { - width: '100%', - height: '100%', - top: 0, - border: 'none' - }; - } - setIsFullScreen(!isFullScreen); - setDialogStyle(containerStyle); - }, [isFullScreen]); - - const onContainerKeyDown = useCallback((event) => { - event.stopPropagation(); - if (event.keyCode === 27) { - event.preventDefault(); - onToggle(); - } - }, [onToggle]); - - const onSave = useCallback(() => { - const newContent = editorRef.current?.getSlateValue(); - const value = slateToMdString(newContent); - saveToDescription(value); - onToggle(); - }, [saveToDescription, onToggle]); - - useEffect(() => { - const path = Utils.joinPath(parentDir, fileName); - metadataAPI.ocr(repoID, path).then(res => { - const result = res.data?.ocr_result || ''; - ocrResult.current = result.replaceAll('\f', '\n').replaceAll('\n\n', '\n').replaceAll('\n', '\n\n'); - setLoading(false); - }).catch(error => { - const errorMessage = gettext('Failed to extract text'); - setErrorMessage(errorMessage); - setLoading(false); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - document.addEventListener('keydown', onContainerKeyDown); - return () => { - document.removeEventListener('keydown', onContainerKeyDown); - }; - }, [onContainerKeyDown]); - - return ( - - -
-
-
- {gettext('OCR result')} -
- {!isLoading && !errorMessage && canModify && ( - - - - )} - - - - -
-
- {!isValidBrowser && } -
-
- {errorMessage ? ( -
{errorMessage}
- ) : ( - <> - {isWindowsWechat ? ( - - ) : ( - - )} - - )} -
-
-
-
- ); -}; - -OCRResultDialog.propTypes = { - record: PropTypes.object, - onToggle: PropTypes.func, - saveToDescription: PropTypes.func, -}; - -export default OCRResultDialog; diff --git a/frontend/src/metadata/components/metadata-details/ai-icon.js b/frontend/src/metadata/components/metadata-details/ai-icon.js index 376964eb5c..6372342088 100644 --- a/frontend/src/metadata/components/metadata-details/ai-icon.js +++ b/frontend/src/metadata/components/metadata-details/ai-icon.js @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap'; import Icon from '../../../components/icon'; import { useMetadataDetails } from '../../hooks'; @@ -19,6 +19,7 @@ const OPERATION = { }; const AIIcon = () => { + const menuToggleRef = useRef(null); const [isMenuShow, setMenuShow] = useState(false); @@ -85,7 +86,7 @@ const AIIcon = () => { case OPERATION.OCR: { onOCR(record, { success_callback: updateDescription - }); + }, menuToggleRef.current); break; } case OPERATION.FILE_TAGS: { @@ -141,7 +142,7 @@ const AIIcon = () => { aria-label='AI' tabIndex={0} > -
+
diff --git a/frontend/src/metadata/components/popover/index.js b/frontend/src/metadata/components/popover/index.js index 98435a0180..4e7b9b269f 100644 --- a/frontend/src/metadata/components/popover/index.js +++ b/frontend/src/metadata/components/popover/index.js @@ -5,6 +5,7 @@ import FilterPopover from './filter-popover'; import SortPopover from './sort-popover'; import GroupbysPopover from './groupbys-popover'; import HideColumnPopover from './hidden-column-popover'; +import OCRResultPopover from './ocr-result-popover'; import './index.css'; @@ -16,4 +17,5 @@ export { SortPopover, GroupbysPopover, HideColumnPopover, + OCRResultPopover, }; diff --git a/frontend/src/metadata/components/popover/ocr-result-popover/index.css b/frontend/src/metadata/components/popover/ocr-result-popover/index.css new file mode 100644 index 0000000000..51f42c5ca4 --- /dev/null +++ b/frontend/src/metadata/components/popover/ocr-result-popover/index.css @@ -0,0 +1,81 @@ +.sf-metadata-ocr-result-popover .popover { + max-width: 500px; + width: 500px; + height: 100%; + overflow: hidden; +} + +.sf-metadata-ocr-result-popover .popover-inner { + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.sf-metadata-ocr-result-popover .sf-metadata-ocr-result-popover-header { + height: 48px; + border-bottom: 1px solid rgba(0, 40, 100, .12); + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; +} + +.sf-metadata-ocr-result-popover .sf-metadata-ocr-result-popover-header h5 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 0; +} + +.sf-metadata-ocr-result-popover .sf-metadata-ocr-result-popover-body { + flex: 1 1 auto; + padding: 0; + overflow: hidden; +} + +.sf-metadata-ocr-result-popover .sf-metadata-ocr-file-loading-container { + display: inline-flex; + align-items: center; + justify-content: flex-start; + padding: 16px; +} + +.sf-metadata-ocr-result-popover .sf-metadata-ocr-file-loading-container .loading-tip { + margin: 0; + height: 16px; + width: 16px; + display: block; + flex-shrink: 0; +} + +.sf-metadata-ocr-result-popover .sf-metadata-ocr-file-loading-container .sf-metadata-ocr-file-loading-tip-text { + color: #666; + margin-left: 8px; +} + +.sf-metadata-ocr-result-popover .sf-metadata-ocr-result-display-container { + width: 100%; + height: 100%; + max-height: inherit; + padding: 16px; + overflow: auto; + white-space: pre-line; +} + +.sf-metadata-ocr-result-popover .sf-metadata-ocr-result-display-container.empty-tip { + color: #666; + margin: 0; + display: block; +} + +.sf-metadata-ocr-result-popover .sf-metadata-ocr-result-popover-footer { + display: flex; + align-items: center; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + padding: 12px; + border-top: 1px solid rgba(0, 40, 100, .12); +} diff --git a/frontend/src/metadata/components/popover/ocr-result-popover/index.js b/frontend/src/metadata/components/popover/ocr-result-popover/index.js new file mode 100644 index 0000000000..f4a37e7635 --- /dev/null +++ b/frontend/src/metadata/components/popover/ocr-result-popover/index.js @@ -0,0 +1,185 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState, useLayoutEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'reactstrap'; +import { deserializeHtml, slateToMdString } from '@seafile/seafile-editor'; +import isHotkey from 'is-hotkey'; +import classnames from 'classnames'; +import { gettext } from '../../../../utils/constants'; +import { getFileNameFromRecord, getParentDirFromRecord } from '../../../utils/cell'; +import { Utils } from '../../../../utils/utils'; +import metadataAPI from '../../../api'; +import { useMetadataAIOperations } from '../../../../hooks/metadata-ai-operation'; +import Loading from '../../../../components/loading'; +import { getTarget } from '../../../../utils/dom'; +import BodyPortal from '../../../../components/body-portal'; +import ClickOutside from '../../../../components/click-outside'; + +import './index.css'; + +const OCRResultPopover = ({ repoID, target, record, onToggle, saveToDescription }) => { + const [isLoading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [value, setValue] = useState(''); + const [isMount, setMount] = useState(false); + const [style, setStyle] = useState({}); + + const popoverRef = useRef(null); + const bodyRef = useRef(null); + + const { canModify } = useMetadataAIOperations(); + + const parentDir = useMemo(() => getParentDirFromRecord(record), [record]); + const fileName = useMemo(() => getFileNameFromRecord(record), [record]); + const element = useMemo(() => { + let _element = document.createElement('div'); + _element.setAttribute('tabindex', '-1'); + return _element; + }, []); + + const onSave = useCallback(() => { + const newContent = slateToMdString(deserializeHtml(value)); + + // eslint-disable-next-line no-irregular-whitespace + // '​': Special line breaks + // https://symbl.cc/en/200B/ + if (newContent.replaceAll('\n', '').replaceAll('\r', '').replaceAll('​', '').trim()) { + saveToDescription(newContent); + } + onToggle(); + }, [value, saveToDescription, onToggle]); + + const onHotKey = useCallback((event) => { + if (isHotkey('esc', event)) { + event.stopPropagation(); + event.preventDefault(); + onToggle(); + return false; + } + }, [onToggle]); + + const updateStyle = useCallback(() => { + const { bottom, top, left } = getTarget(target).getBoundingClientRect(); + const innerHeight = window.innerHeight; + const innerWidth = window.innerWidth; + const gap = 5; // gap between target and popover + const marginTB = 28; // margin top/bottom between screen and popover + const totalGap = gap + marginTB; + const popoverWidth = 500; + // 119: popover header height(48) + popover footer height(71) + const minPopoverBodyHeight = 160; + const x = left + popoverWidth > innerWidth ? 16 : innerWidth - left - popoverWidth; + let y = bottom + gap; + let popoverHeight = 0; + if (isLoading || errorMessage || !value) { + popoverHeight = minPopoverBodyHeight + 119; + } else { + const childNode = bodyRef.current.firstChild; + popoverHeight = Math.max(childNode.scrollHeight, minPopoverBodyHeight) + 119; + } + + if (top > innerHeight - bottom) { + if (popoverHeight + totalGap > top) { + y = 28; + popoverHeight = top - totalGap; + } else { + y = top - popoverHeight - gap; + } + } else { + y = bottom + gap; + if (popoverHeight + totalGap > innerHeight - bottom) { + popoverHeight = innerHeight - bottom - totalGap; + } + } + + setStyle({ + transform: `translate3d(-${x}px, ${y}px, 0px)`, + height: popoverHeight, + inset: '0 0 auto auto' + }); + }, [isLoading, errorMessage, value, target]); + + useEffect(() => { + document.body.appendChild(element); + setMount(true); + return () => { + document.body.removeChild(element); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + document.addEventListener('keydown', onHotKey, true); + window.addEventListener('resize', updateStyle); + return () => { + document.removeEventListener('keydown', onHotKey); + window.removeEventListener('resize', updateStyle); + }; + }, [onHotKey, updateStyle]); + + useLayoutEffect(() => { + updateStyle(); + }, [updateStyle]); + + useEffect(() => { + if (!isMount) return; + updateStyle(); + const path = Utils.joinPath(parentDir, fileName); + metadataAPI.ocr(repoID, path).then(res => { + const result = (res.data?.ocr_result || '').replaceAll('\f', '\n'); + const value = result.replaceAll('\n', '').trim() ? result : ''; + setValue(value); + setLoading(false); + }).catch(error => { + const errorMessage = gettext('Failed to extract text'); + setErrorMessage(errorMessage); + setLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMount]); + + return ( + +
+ +
+
+
+
{gettext('OCR result')}
+
+
+ {isLoading && ( +
+ + {gettext('Extracting text, please wait...')} +
+ )} + {!isLoading && errorMessage && ( +
{errorMessage}
+ )} + {!isLoading && !errorMessage && ( +
+ {value || gettext('No text extracted')} +
+ )} +
+
+ + +
+
+
+
+
+
+ ); +}; + +OCRResultPopover.propTypes = { + repoID: PropTypes.string, + record: PropTypes.object, + target: PropTypes.oneOfType([PropTypes.string, PropTypes.node, PropTypes.object]), + onToggle: PropTypes.func, + saveToDescription: PropTypes.func, +}; + +export default OCRResultPopover; diff --git a/frontend/src/metadata/hooks/metadata-view.js b/frontend/src/metadata/hooks/metadata-view.js index d1ac21e0ff..121cb7e81f 100644 --- a/frontend/src/metadata/hooks/metadata-view.js +++ b/frontend/src/metadata/hooks/metadata-view.js @@ -381,7 +381,7 @@ export const MetadataViewProvider = ({ }); }, [modifyRecords, generateDescription]); - const onOCR = useCallback((record) => { + const onOCR = useCallback((record, target) => { const parentDir = getParentDirFromRecord(record); const fileName = getFileNameFromRecord(record); if (!fileName || !parentDir) return; @@ -399,7 +399,7 @@ export const MetadataViewProvider = ({ idOriginalRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description || null }; modifyRecords(recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData); } - }); + }, target); }, [modifyRecords, OCRAPI]); const generateFileTags = useCallback((record) => { diff --git a/frontend/src/metadata/views/table/context-menu.js b/frontend/src/metadata/views/table/context-menu.js index dbf94b3532..b0f5ef5a76 100644 --- a/frontend/src/metadata/views/table/context-menu.js +++ b/frontend/src/metadata/views/table/context-menu.js @@ -283,7 +283,7 @@ const ContextMenu = ({ case OPERATION.OCR: { const { record } = option; if (!record) break; - onOCR(record); + onOCR(record, 'sf-table-rdg-selected'); break; } case OPERATION.DELETE_RECORD: { diff --git a/frontend/src/utils/dom.js b/frontend/src/utils/dom.js index a109c044b8..5959330d58 100644 --- a/frontend/src/utils/dom.js +++ b/frontend/src/utils/dom.js @@ -17,3 +17,71 @@ export const removeClassName = (originClassName, targetClassName) => { originClassNames.splice(targetClassNameIndex, 1); return originClassNames.join(' '); }; + +const isReactRefObj = (target) => { + if (target && typeof target === 'object') return 'current' in target; + return false; +}; + +const isObject = (value) => { + const type = typeof value; + return value != null && (type === 'object' || type === 'function'); +}; + +const getTag = (value) => { + if (value == null) return value === undefined ? '[object Undefined]' : '[object Null]'; + return Object.prototype.toString.call(value); +}; + +const isFunction = (value) => { + if (!isObject(value)) return false; + + const tag = getTag(value); + return ( + tag === '[object Function]' || + tag === '[object AsyncFunction]' || + tag === '[object GeneratorFunction]' || + tag === '[object Proxy]' + ); +}; + +export const canUseDOM = !!( + typeof window !== 'undefined' && + window.document && + window.document.createElement +); + +export const findDOMElements = (target) => { + if (isReactRefObj(target)) return target.current; + if (isFunction(target)) return target(); + + if (typeof target === 'string' && canUseDOM) { + let selection = document.querySelectorAll(target); + if (!selection.length) { + selection = document.querySelectorAll(`#${target}`); + } + if (!selection.length) { + throw new Error( + `The target '${target}' could not be identified in the dom, tip: check spelling`, + ); + } + return selection; + } + return target; +}; + +const isArrayOrNodeList = (els) => { + if (els === null) return false; + return Array.isArray(els) || (canUseDOM && typeof els.length === 'number'); +}; + +export const getTarget = (target, allElements) => { + const els = findDOMElements(target); + if (allElements) { + if (isArrayOrNodeList(els)) return els; + if (els === null) return []; + return [els]; + } + if (isArrayOrNodeList(els)) return els[0]; + return els; +};