+
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;
+};