mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-27 07:44:50 +00:00
refactor: ocr result display (#7936)
Co-authored-by: 杨国璇 <ygx@MacBookPro.lan>
This commit is contained in:
36
frontend/src/components/body-portal/index.js
Normal file
36
frontend/src/components/body-portal/index.js
Normal file
@@ -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;
|
@@ -1,5 +1,6 @@
|
|||||||
import { MENU_SHOW, MENU_HIDE } from './actions';
|
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 {
|
class GlobalEventListener {
|
||||||
|
|
||||||
|
@@ -11,7 +11,3 @@ export function uniqueId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const store = {};
|
export const store = {};
|
||||||
|
|
||||||
export const canUseDOM = Boolean(
|
|
||||||
typeof window !== 'undefined' && window.document && window.document.createElement
|
|
||||||
);
|
|
||||||
|
@@ -52,7 +52,7 @@ const ImageDialog = ({ repoID, repoInfo, enableRotate: oldEnableRotate = true, i
|
|||||||
const isSystemFolder = SYSTEM_FOLDERS.find(folderPath => mainImg.parentDir.startsWith(folderPath));
|
const isSystemFolder = SYSTEM_FOLDERS.find(folderPath => mainImg.parentDir.startsWith(folderPath));
|
||||||
let onOCR = null;
|
let onOCR = null;
|
||||||
if (enableOCR && enableMetadata && canModify && !isSystemFolder) {
|
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 = () => {
|
const renderSidePanel = () => {
|
||||||
|
@@ -14,7 +14,8 @@ function DragMask({ draggedRange, getSelectedRangeDimensions, getSelectedDimensi
|
|||||||
return (
|
return (
|
||||||
<CellMask
|
<CellMask
|
||||||
{...dimensions}
|
{...dimensions}
|
||||||
className='react-grid-cell-dragged-over-down'
|
className="react-grid-cell-dragged-over-down"
|
||||||
|
id="sf-table-cell-dragged-over-down"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -7,6 +7,7 @@ function SelectionMask({ innerRef, selectedPosition, getSelectedDimensions, chil
|
|||||||
return (
|
return (
|
||||||
<CellMask
|
<CellMask
|
||||||
className="rdg-selected"
|
className="rdg-selected"
|
||||||
|
id="sf-table-rdg-selected"
|
||||||
tabIndex="0"
|
tabIndex="0"
|
||||||
innerRef={innerRef}
|
innerRef={innerRef}
|
||||||
{...dimensions}
|
{...dimensions}
|
||||||
|
@@ -8,6 +8,7 @@ function SelectionRangeMask({ selectedRange, innerRef, getSelectedRangeDimension
|
|||||||
<CellMask
|
<CellMask
|
||||||
{...dimensions}
|
{...dimensions}
|
||||||
className="rdg-selected-range"
|
className="rdg-selected-range"
|
||||||
|
id="sf-table-rdg-selected-range"
|
||||||
innerRef={innerRef}
|
innerRef={innerRef}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@@ -16,6 +16,7 @@ import { openInNewTab, openParentFolder } from '../../metadata/utils/file';
|
|||||||
const TableFilesToolbar = ({ repoID }) => {
|
const TableFilesToolbar = ({ repoID }) => {
|
||||||
const [selectedRecordIds, setSelectedRecordIds] = useState([]);
|
const [selectedRecordIds, setSelectedRecordIds] = useState([]);
|
||||||
const metadataRef = useRef([]);
|
const metadataRef = useRef([]);
|
||||||
|
const menuRef = useRef(null);
|
||||||
const { enableOCR } = useMetadataStatus();
|
const { enableOCR } = useMetadataStatus();
|
||||||
|
|
||||||
const canModify = window.sfMetadataContext && window.sfMetadataContext.canModify();
|
const canModify = window.sfMetadataContext && window.sfMetadataContext.canModify();
|
||||||
@@ -139,7 +140,7 @@ const TableFilesToolbar = ({ repoID }) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TextTranslation.OCR.key: {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -180,8 +181,9 @@ const TableFilesToolbar = ({ repoID }) => {
|
|||||||
}
|
}
|
||||||
{length > 0 && (
|
{length > 0 && (
|
||||||
<ItemDropdownMenu
|
<ItemDropdownMenu
|
||||||
|
ref={menuRef}
|
||||||
item={{}}
|
item={{}}
|
||||||
toggleClass={'cur-view-path-btn sf3-font-more sf3-font'}
|
toggleClass="cur-view-path-btn sf3-font-more sf3-font"
|
||||||
onMenuItemClick={onMenuItemClick}
|
onMenuItemClick={onMenuItemClick}
|
||||||
getMenuList={getMenuList}
|
getMenuList={getMenuList}
|
||||||
/>
|
/>
|
||||||
|
@@ -4,7 +4,7 @@ import { Utils } from '../utils/utils';
|
|||||||
import toaster from '../components/toast';
|
import toaster from '../components/toast';
|
||||||
import { PRIVATE_COLUMN_KEY, EVENT_BUS_TYPE } from '../metadata/constants';
|
import { PRIVATE_COLUMN_KEY, EVENT_BUS_TYPE } from '../metadata/constants';
|
||||||
import { gettext, lang } from '../utils/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';
|
import FileTagsDialog from '../metadata/components/dialog/file-tags-dialog';
|
||||||
|
|
||||||
// This hook provides content related to metadata ai operation
|
// This hook provides content related to metadata ai operation
|
||||||
@@ -23,6 +23,7 @@ export const MetadataAIOperationsProvider = ({
|
|||||||
const [isFileTagsDialogShow, setFileTagsDialogShow] = useState(false);
|
const [isFileTagsDialogShow, setFileTagsDialogShow] = useState(false);
|
||||||
|
|
||||||
const recordRef = useRef(null);
|
const recordRef = useRef(null);
|
||||||
|
const targetRef = useRef(null);
|
||||||
const opCallBack = useRef(null);
|
const opCallBack = useRef(null);
|
||||||
|
|
||||||
const permission = useMemo(() => repoInfo.permission !== 'admin' && repoInfo.permission !== 'rw' ? 'r' : 'rw', [repoInfo]);
|
const permission = useMemo(() => repoInfo.permission !== 'admin' && repoInfo.permission !== 'rw' ? 'r' : 'rw', [repoInfo]);
|
||||||
@@ -36,21 +37,24 @@ export const MetadataAIOperationsProvider = ({
|
|||||||
|
|
||||||
const closeOcrResultDialog = useCallback(() => {
|
const closeOcrResultDialog = useCallback(() => {
|
||||||
recordRef.current = null;
|
recordRef.current = null;
|
||||||
|
targetRef.current = null;
|
||||||
opCallBack.current = null;
|
opCallBack.current = null;
|
||||||
setOcrResultDialogShow(false);
|
setOcrResultDialogShow(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onOCR = useCallback((record, { success_callback }) => {
|
const onOCR = useCallback((record, { success_callback }, target) => {
|
||||||
|
targetRef.current = target;
|
||||||
recordRef.current = record;
|
recordRef.current = record;
|
||||||
opCallBack.current = success_callback;
|
opCallBack.current = success_callback;
|
||||||
setOcrResultDialogShow(true);
|
setOcrResultDialogShow(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onOCRByImageDialog = useCallback(({ parentDir, fileName } = {}) => {
|
const onOCRByImageDialog = useCallback(({ parentDir, fileName } = {}, target) => {
|
||||||
recordRef.current = {
|
recordRef.current = {
|
||||||
[PRIVATE_COLUMN_KEY.PARENT_DIR]: parentDir,
|
[PRIVATE_COLUMN_KEY.PARENT_DIR]: parentDir,
|
||||||
[PRIVATE_COLUMN_KEY.FILE_NAME]: fileName,
|
[PRIVATE_COLUMN_KEY.FILE_NAME]: fileName,
|
||||||
};
|
};
|
||||||
|
targetRef.current = target;
|
||||||
|
|
||||||
opCallBack.current = (description) => {
|
opCallBack.current = (description) => {
|
||||||
const update = { [PRIVATE_COLUMN_KEY.FILE_DESCRIPTION]: description };
|
const update = { [PRIVATE_COLUMN_KEY.FILE_DESCRIPTION]: description };
|
||||||
@@ -149,7 +153,13 @@ export const MetadataAIOperationsProvider = ({
|
|||||||
<FileTagsDialog record={recordRef.current} onToggle={closeFileTagsDialog} onSubmit={opCallBack.current} />
|
<FileTagsDialog record={recordRef.current} onToggle={closeFileTagsDialog} onSubmit={opCallBack.current} />
|
||||||
)}
|
)}
|
||||||
{isOcrResultDialogShow && (
|
{isOcrResultDialogShow && (
|
||||||
<OCRResultDialog repoID={repoID} record={recordRef.current} onToggle={closeOcrResultDialog} saveToDescription={opCallBack.current} />
|
<OCRResultPopover
|
||||||
|
repoID={repoID}
|
||||||
|
target={targetRef.current}
|
||||||
|
record={recordRef.current}
|
||||||
|
onToggle={closeOcrResultDialog}
|
||||||
|
saveToDescription={opCallBack.current}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</MetadataAIOperationsContext.Provider>
|
</MetadataAIOperationsContext.Provider>
|
||||||
);
|
);
|
||||||
|
@@ -33,11 +33,8 @@ const ContextMenu = ({
|
|||||||
const dividerHeight = 16;
|
const dividerHeight = 16;
|
||||||
const optionHeight = 32;
|
const optionHeight = 32;
|
||||||
const menuDefaultHeight = options.reduce((total, option) => {
|
const menuDefaultHeight = options.reduce((total, option) => {
|
||||||
if (option === 'Divider') {
|
if (option === 'Divider') return total + dividerHeight;
|
||||||
return total + dividerHeight;
|
|
||||||
} else {
|
|
||||||
return total + optionHeight;
|
return total + optionHeight;
|
||||||
}
|
|
||||||
}, menuMargin + indent);
|
}, menuMargin + indent);
|
||||||
if (menuStyles.left + menuDefaultWidth + indent > window.innerWidth) {
|
if (menuStyles.left + menuDefaultWidth + indent > window.innerWidth) {
|
||||||
menuStyles.left = window.innerWidth - menuDefaultWidth - indent;
|
menuStyles.left = window.innerWidth - menuDefaultWidth - indent;
|
||||||
|
@@ -35,7 +35,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.sf-file-exit-tag {
|
.sf-file-exit-tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@@ -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;
|
|
||||||
}
|
|
@@ -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 (
|
|
||||||
<I18nextProvider i18n={ i18n }>
|
|
||||||
<LongTextModal onModalClick={onToggle} containerClass="sf-metadata-ocr-file-dialog">
|
|
||||||
<div style={dialogStyle} className="longtext-dialog-container">
|
|
||||||
<div className={classnames('longtext-header-container', {
|
|
||||||
'longtext-header-container-border': isWindowsWechat,
|
|
||||||
'longtext-header-divider': isLoading || errorMessage
|
|
||||||
})}>
|
|
||||||
<div className="longtext-header">
|
|
||||||
<span className="longtext-header-name">{gettext('OCR result')}</span>
|
|
||||||
<div className="longtext-header-tool">
|
|
||||||
{!isLoading && !errorMessage && canModify && (
|
|
||||||
<span
|
|
||||||
className="longtext-header-tool-item d-flex align-items-center mr-1"
|
|
||||||
title={gettext('Save to description property')}
|
|
||||||
onClick={onSave}
|
|
||||||
>
|
|
||||||
<Icon symbol="save" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={classnames('longtext-header-tool-item mr-1 iconfont icon-full-screen', { 'long-text-full-screen': isFullScreen })}
|
|
||||||
onClick={onFullScreenToggle}
|
|
||||||
title={gettext('Full screen')}
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="longtext-header-tool-item iconfont icon-x"
|
|
||||||
onClick={onToggle}
|
|
||||||
title={gettext('Close')}
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!isValidBrowser && <BrowserTip lang={lang} isWindowsWechat={isWindowsWechat} />}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
onKeyDown={onContainerKeyDown}
|
|
||||||
className={classnames('longtext-content-container', { 'longtext-container-scroll': isWindowsWechat })}
|
|
||||||
>
|
|
||||||
{errorMessage ? (
|
|
||||||
<div className="w-100 h-100 d-flex align-items-center justify-content-center error">{errorMessage}</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{isWindowsWechat ? (
|
|
||||||
<MarkdownPreview isWindowsWechat={isWindowsWechat} value={ocrResult.current} isShowOutline={false} />
|
|
||||||
) : (
|
|
||||||
<SimpleEditor ref={editorRef} isFetching={isLoading} focusEnd={false} value={ocrResult.current} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LongTextModal>
|
|
||||||
</I18nextProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
OCRResultDialog.propTypes = {
|
|
||||||
record: PropTypes.object,
|
|
||||||
onToggle: PropTypes.func,
|
|
||||||
saveToDescription: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OCRResultDialog;
|
|
@@ -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 { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
|
||||||
import Icon from '../../../components/icon';
|
import Icon from '../../../components/icon';
|
||||||
import { useMetadataDetails } from '../../hooks';
|
import { useMetadataDetails } from '../../hooks';
|
||||||
@@ -19,6 +19,7 @@ const OPERATION = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AIIcon = () => {
|
const AIIcon = () => {
|
||||||
|
const menuToggleRef = useRef(null);
|
||||||
|
|
||||||
const [isMenuShow, setMenuShow] = useState(false);
|
const [isMenuShow, setMenuShow] = useState(false);
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ const AIIcon = () => {
|
|||||||
case OPERATION.OCR: {
|
case OPERATION.OCR: {
|
||||||
onOCR(record, {
|
onOCR(record, {
|
||||||
success_callback: updateDescription
|
success_callback: updateDescription
|
||||||
});
|
}, menuToggleRef.current);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case OPERATION.FILE_TAGS: {
|
case OPERATION.FILE_TAGS: {
|
||||||
@@ -141,7 +142,7 @@ const AIIcon = () => {
|
|||||||
aria-label='AI'
|
aria-label='AI'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<div className="detail-control mr-2">
|
<div className="detail-control mr-2" ref={menuToggleRef}>
|
||||||
<Icon symbol="ai" className="detail-control-icon" />
|
<Icon symbol="ai" className="detail-control-icon" />
|
||||||
</div>
|
</div>
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
|
@@ -5,6 +5,7 @@ import FilterPopover from './filter-popover';
|
|||||||
import SortPopover from './sort-popover';
|
import SortPopover from './sort-popover';
|
||||||
import GroupbysPopover from './groupbys-popover';
|
import GroupbysPopover from './groupbys-popover';
|
||||||
import HideColumnPopover from './hidden-column-popover';
|
import HideColumnPopover from './hidden-column-popover';
|
||||||
|
import OCRResultPopover from './ocr-result-popover';
|
||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
@@ -16,4 +17,5 @@ export {
|
|||||||
SortPopover,
|
SortPopover,
|
||||||
GroupbysPopover,
|
GroupbysPopover,
|
||||||
HideColumnPopover,
|
HideColumnPopover,
|
||||||
|
OCRResultPopover,
|
||||||
};
|
};
|
||||||
|
@@ -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);
|
||||||
|
}
|
@@ -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 (
|
||||||
|
<BodyPortal node={element}>
|
||||||
|
<div className="sf-metadata-ocr-result-popover show">
|
||||||
|
<ClickOutside onClickOutside={onToggle}>
|
||||||
|
<div className="popover show bs-popover-auto position-absolute" style={style}>
|
||||||
|
<div className="popover-inner" ref={popoverRef}>
|
||||||
|
<div className="sf-metadata-ocr-result-popover-header">
|
||||||
|
<h5>{gettext('OCR result')}</h5>
|
||||||
|
</div>
|
||||||
|
<div className="sf-metadata-ocr-result-popover-body" ref={bodyRef}>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="sf-metadata-ocr-file-loading-container">
|
||||||
|
<Loading />
|
||||||
|
<span className="sf-metadata-ocr-file-loading-tip-text">{gettext('Extracting text, please wait...')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && errorMessage && (
|
||||||
|
<div className="h-100 w-100 d-flex align-items-center justify-content-center p-4 error">{errorMessage}</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !errorMessage && (
|
||||||
|
<div className={classnames('sf-metadata-ocr-result-display-container', { 'empty-tip': !value })}>
|
||||||
|
{value || gettext('No text extracted')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sf-metadata-ocr-result-popover-footer">
|
||||||
|
<Button color="secondary" className="mr-2" onClick={onToggle}>{gettext('Cancel')}</Button>
|
||||||
|
<Button color="primary" disabled={!canModify || isLoading || Boolean(errorMessage) || !value} onClick={onSave}>{gettext('Save to description field')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ClickOutside>
|
||||||
|
</div>
|
||||||
|
</BodyPortal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
@@ -381,7 +381,7 @@ export const MetadataViewProvider = ({
|
|||||||
});
|
});
|
||||||
}, [modifyRecords, generateDescription]);
|
}, [modifyRecords, generateDescription]);
|
||||||
|
|
||||||
const onOCR = useCallback((record) => {
|
const onOCR = useCallback((record, target) => {
|
||||||
const parentDir = getParentDirFromRecord(record);
|
const parentDir = getParentDirFromRecord(record);
|
||||||
const fileName = getFileNameFromRecord(record);
|
const fileName = getFileNameFromRecord(record);
|
||||||
if (!fileName || !parentDir) return;
|
if (!fileName || !parentDir) return;
|
||||||
@@ -399,7 +399,7 @@ export const MetadataViewProvider = ({
|
|||||||
idOriginalRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description || null };
|
idOriginalRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description || null };
|
||||||
modifyRecords(recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData);
|
modifyRecords(recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData);
|
||||||
}
|
}
|
||||||
});
|
}, target);
|
||||||
}, [modifyRecords, OCRAPI]);
|
}, [modifyRecords, OCRAPI]);
|
||||||
|
|
||||||
const generateFileTags = useCallback((record) => {
|
const generateFileTags = useCallback((record) => {
|
||||||
|
@@ -283,7 +283,7 @@ const ContextMenu = ({
|
|||||||
case OPERATION.OCR: {
|
case OPERATION.OCR: {
|
||||||
const { record } = option;
|
const { record } = option;
|
||||||
if (!record) break;
|
if (!record) break;
|
||||||
onOCR(record);
|
onOCR(record, 'sf-table-rdg-selected');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case OPERATION.DELETE_RECORD: {
|
case OPERATION.DELETE_RECORD: {
|
||||||
|
@@ -17,3 +17,71 @@ export const removeClassName = (originClassName, targetClassName) => {
|
|||||||
originClassNames.splice(targetClassNameIndex, 1);
|
originClassNames.splice(targetClassNameIndex, 1);
|
||||||
return originClassNames.join(' ');
|
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;
|
||||||
|
};
|
||||||
|
Reference in New Issue
Block a user