From a2a391b33231c2f35d3d1d62ae309b64730687c3 Mon Sep 17 00:00:00 2001 From: "zheng.shen" Date: Tue, 13 Aug 2024 16:51:08 +0800 Subject: [PATCH 1/4] seafile_ocr --- seahub/ai/apis.py | 47 +++++++++++++++++++++++++++++++++++++++++++++- seahub/ai/utils.py | 15 ++++++++++++++- seahub/settings.py | 7 +++++++ seahub/urls.py | 7 ++++++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/seahub/ai/apis.py b/seahub/ai/apis.py index 8b948ee497a..d5350a00872 100644 --- a/seahub/ai/apis.py +++ b/seahub/ai/apis.py @@ -17,7 +17,7 @@ from seahub.utils.repo import is_valid_repo_id_format, is_repo_admin from seahub.ai.utils import search, get_file_download_token, get_search_repos, \ RELATED_REPOS_PREFIX, RELATED_REPOS_CACHE_TIMEOUT, SEARCH_REPOS_LIMIT, \ - format_repos + format_repos, ocr from seahub.utils import is_org_context, normalize_cache_key, HAS_FILE_SEASEARCH from seahub.views import check_folder_permission @@ -121,3 +121,48 @@ def post(self, request): f['fullpath'] = f['fullpath'].split(origin_path)[-1] return Response(resp_json, resp.status_code) + + +class OCR(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, ) + throttle_classes = (UserRateThrottle, ) + + def post(self, request): + repo_id = request.data.get('repo_id') + path = request.data.get('path') + + if not repo_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'repo_id invalid') + if not path: + return api_error(status.HTTP_400_BAD_REQUEST, 'path invalid') + + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + file_obj = seafile_api.get_dirent_by_path(repo_id, path) + if not file_obj: + error_msg = 'File %s not found.' % path + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + if not check_folder_permission(request, repo_id, path): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + params = { + 'repo_id': repo_id, + 'path': path, + 'obj_id': file_obj.obj_id, + } + + try: + ocr_result = ocr(params) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'ocr_result': ocr_result}, status.HTTP_200_OK) diff --git a/seahub/ai/utils.py b/seahub/ai/utils.py index 75ddade0462..99ba2907573 100644 --- a/seahub/ai/utils.py +++ b/seahub/ai/utils.py @@ -4,7 +4,7 @@ import time from urllib.parse import urljoin -from seahub.settings import SECRET_KEY, SEAFEVENTS_SERVER_URL +from seahub.settings import SECRET_KEY, SEAFEVENTS_SERVER_URL, SEAFILE_AI_SECRET_KEY, SEAFILE_AI_SERVER_URL from seahub.utils import get_user_repos from seaserv import seafile_api @@ -18,6 +18,12 @@ RELATED_REPOS_CACHE_TIMEOUT = 2 * 60 * 60 +def gen_headers(): + payload = {'exp': int(time.time()) + 300, } + token = jwt.encode(payload, SEAFILE_AI_SECRET_KEY, algorithm='HS256') + return {"Authorization": "Token %s" % token} + + def search(params): payload = {'exp': int(time.time()) + 300, } token = jwt.encode(payload, SECRET_KEY, algorithm='HS256') @@ -65,3 +71,10 @@ def format_repos(repos): continue repos_map[real_repo_id] = (real_repo_id, origin_path, repo_name) return searched_repos, repos_map + + +def ocr(params): + headers = gen_headers() + url = urljoin(SEAFILE_AI_SERVER_URL, '/api/v1/ocr/') + resp = requests.post(url, json=params, headers=headers, timeout=30) + return resp diff --git a/seahub/settings.py b/seahub/settings.py index 11e600898d2..8596d0f3259 100644 --- a/seahub/settings.py +++ b/seahub/settings.py @@ -777,6 +777,13 @@ def genpassword(): ENABLE_GLOBAL_ADDRESSBOOK = True ENABLE_ADDRESSBOOK_OPT_IN = False +##################### +# Seafile AI # +##################### +SEAFILE_AI_SERVER_URL = '' +SEAFILE_AI_SECRET_KEY = '' +ENABLE_SEAFILE_AI = False + #################### # Guest Invite # #################### diff --git a/seahub/urls.py b/seahub/urls.py index 33040c43dfa..2bbaeb38bac 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -203,7 +203,7 @@ from seahub.seadoc.views import sdoc_revision, sdoc_revisions, sdoc_to_docx from seahub.ocm.settings import OCM_ENDPOINT -from seahub.ai.apis import Search +from seahub.ai.apis import Search, OCR from seahub.wiki2.views import wiki_view from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView, Wiki2PagesView, Wiki2PageView, \ Wiki2DuplicatePageView, WikiPageTrashView @@ -1044,3 +1044,8 @@ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/move-views/$', MetadataViewsMoveView.as_view(), name='api-v2.1-metadata-views-move'), ] + +if settings.ENABLE_SEAFILE_AI: + urlpatterns += [ + re_path(r'^api/v2.1/ai/ocr/$', OCR.as_view(), name='api-v2.1-ai-ocr'), + ] From 6700dc00bfbd8010d82fe36395c5a9a1c9de414e Mon Sep 17 00:00:00 2001 From: "zheng.shen" Date: Thu, 22 Aug 2024 16:34:48 +0800 Subject: [PATCH 2/4] update --- seahub/ai/apis.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/seahub/ai/apis.py b/seahub/ai/apis.py index d5350a00872..e157dbc20c7 100644 --- a/seahub/ai/apis.py +++ b/seahub/ai/apis.py @@ -124,9 +124,9 @@ def post(self, request): class OCR(APIView): - authentication_classes = (TokenAuthentication, SessionAuthentication) - permission_classes = (IsAuthenticated, ) - throttle_classes = (UserRateThrottle, ) + # authentication_classes = (TokenAuthentication, SessionAuthentication) + # permission_classes = (IsAuthenticated, ) + # throttle_classes = (UserRateThrottle, ) def post(self, request): repo_id = request.data.get('repo_id') @@ -148,9 +148,9 @@ def post(self, request): return api_error(status.HTTP_404_NOT_FOUND, error_msg) # permission check - if not check_folder_permission(request, repo_id, path): - error_msg = 'Permission denied.' - return api_error(status.HTTP_403_FORBIDDEN, error_msg) + # if not check_folder_permission(request, repo_id, path): + # error_msg = 'Permission denied.' + # return api_error(status.HTTP_403_FORBIDDEN, error_msg) params = { 'repo_id': repo_id, @@ -159,10 +159,11 @@ def post(self, request): } try: - ocr_result = ocr(params) + resp = ocr(params) + resp_json = resp.json() except Exception as e: logger.error(e) error_msg = 'Internal Server Error' return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) - return Response({'ocr_result': ocr_result}, status.HTTP_200_OK) + return Response(resp_json, resp.status_code) From 797d0cd7852cb0d7a95fbb9fd79f97fbde19b534 Mon Sep 17 00:00:00 2001 From: "zheng.shen" Date: Thu, 22 Aug 2024 16:35:07 +0800 Subject: [PATCH 3/4] update --- seahub/ai/apis.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/seahub/ai/apis.py b/seahub/ai/apis.py index e157dbc20c7..07162440b32 100644 --- a/seahub/ai/apis.py +++ b/seahub/ai/apis.py @@ -124,9 +124,9 @@ def post(self, request): class OCR(APIView): - # authentication_classes = (TokenAuthentication, SessionAuthentication) - # permission_classes = (IsAuthenticated, ) - # throttle_classes = (UserRateThrottle, ) + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, ) + throttle_classes = (UserRateThrottle, ) def post(self, request): repo_id = request.data.get('repo_id') @@ -148,9 +148,9 @@ def post(self, request): return api_error(status.HTTP_404_NOT_FOUND, error_msg) # permission check - # if not check_folder_permission(request, repo_id, path): - # error_msg = 'Permission denied.' - # return api_error(status.HTTP_403_FORBIDDEN, error_msg) + if not check_folder_permission(request, repo_id, path): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) params = { 'repo_id': repo_id, From 77ca774d5519f7b9eb39a97b8409b599946855c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E7=92=87?= Date: Mon, 2 Sep 2024 14:42:07 +0800 Subject: [PATCH 4/4] feat: optimize code --- .../src/components/dialog/image-dialog.js | 21 +- frontend/src/react-image-lightbox/constant.js | 41 + frontend/src/react-image-lightbox/index.js | 3 + .../src/react-image-lightbox/ocr-canvas.js | 75 + .../react-image-lightbox.js | 2033 +++++++++++++++++ frontend/src/react-image-lightbox/style.css | 466 ++++ frontend/src/react-image-lightbox/util.js | 49 + frontend/src/utils/ai-api.js | 62 + 8 files changed, 2749 insertions(+), 1 deletion(-) create mode 100644 frontend/src/react-image-lightbox/constant.js create mode 100644 frontend/src/react-image-lightbox/index.js create mode 100644 frontend/src/react-image-lightbox/ocr-canvas.js create mode 100644 frontend/src/react-image-lightbox/react-image-lightbox.js create mode 100644 frontend/src/react-image-lightbox/style.css create mode 100644 frontend/src/react-image-lightbox/util.js create mode 100644 frontend/src/utils/ai-api.js diff --git a/frontend/src/components/dialog/image-dialog.js b/frontend/src/components/dialog/image-dialog.js index d5254e62f2d..dd75c7c0fc7 100644 --- a/frontend/src/components/dialog/image-dialog.js +++ b/frontend/src/components/dialog/image-dialog.js @@ -1,8 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import { gettext } from '../../utils/constants'; -import Lightbox from '@seafile/react-image-lightbox'; +import Lightbox from '../../react-image-lightbox'; +import AIAPI from '../../utils/ai-api'; + import '@seafile/react-image-lightbox/style.css'; +import { Utils } from '../../utils/utils'; +import toaster from '../toast'; + const propTypes = { imageItems: PropTypes.array.isRequired, @@ -14,6 +19,19 @@ const propTypes = { class ImageDialog extends React.Component { + ocrRecognition = (src, successCallBack) => { + const repoIndex = src.indexOf('/repo/') + 6; + const rawIndex = src.indexOf('/raw/'); + const repoId = src.slice(repoIndex, rawIndex); + const path = decodeURIComponent(src.slice(rawIndex + 4)); + AIAPI.ocrRecognition(repoId, path).then(res => { + successCallBack(res.data.ocr_result); + }).catch(error => { + const errorMessage = Utils.getErrorMsg(error); + toaster.danger(errorMessage); + }); + }; + render() { const imageItems = this.props.imageItems; const imageIndex = this.props.imageIndex; @@ -37,6 +55,7 @@ class ImageDialog extends React.Component { closeLabel={gettext('Close (Esc)')} zoomInLabel={gettext('Zoom in')} zoomOutLabel={gettext('Zoom out')} + ocrRecognition={this.ocrRecognition} /> ); } diff --git a/frontend/src/react-image-lightbox/constant.js b/frontend/src/react-image-lightbox/constant.js new file mode 100644 index 00000000000..811d885ed37 --- /dev/null +++ b/frontend/src/react-image-lightbox/constant.js @@ -0,0 +1,41 @@ +// Min image zoom level +export const MIN_ZOOM_LEVEL = 0; + +// Max image zoom level +export const MAX_ZOOM_LEVEL = 300; + +// Size ratio between previous and next zoom levels +export const ZOOM_RATIO = 1.007; + +// How much to increase/decrease the zoom level when the zoom buttons are clicked +export const ZOOM_BUTTON_INCREMENT_SIZE = 100; + +// Used to judge the amount of horizontal scroll needed to initiate a image move +export const WHEEL_MOVE_X_THRESHOLD = 200; + +// Used to judge the amount of vertical scroll needed to initiate a zoom action +export const WHEEL_MOVE_Y_THRESHOLD = 1; + +export const KEYS = { + ESC: 27, + LEFT_ARROW: 37, + UP_ARROW: 38, + RIGHT_ARROW: 39, + DOWN_ARROW: 40, +}; + +// Actions +export const ACTION_NONE = 0; +export const ACTION_MOVE = 1; +export const ACTION_SWIPE = 2; +export const ACTION_PINCH = 3; +export const ACTION_ROTATE = 4; + +// Events source +export const SOURCE_ANY = 0; +export const SOURCE_MOUSE = 1; +export const SOURCE_TOUCH = 2; +export const SOURCE_POINTER = 3; + +// Minimal swipe distance +export const MIN_SWIPE_DISTANCE = 200; diff --git a/frontend/src/react-image-lightbox/index.js b/frontend/src/react-image-lightbox/index.js new file mode 100644 index 00000000000..dd7e9ac618b --- /dev/null +++ b/frontend/src/react-image-lightbox/index.js @@ -0,0 +1,3 @@ +import Lightbox from './react-image-lightbox'; + +export default Lightbox; diff --git a/frontend/src/react-image-lightbox/ocr-canvas.js b/frontend/src/react-image-lightbox/ocr-canvas.js new file mode 100644 index 00000000000..dc73b4d12a2 --- /dev/null +++ b/frontend/src/react-image-lightbox/ocr-canvas.js @@ -0,0 +1,75 @@ +import React, { useRef, useEffect, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +const OCRResultCanvas = ({ + className, + data, + style, +}) => { + const ref = useRef(null); + + useEffect(() => { + + }, []); + + useEffect(() => { + // 获取当前dom的位置信息 + const dom = document.getElementsByClassName('ril-image-current')[0]; + const { clientHeight, clientWidth } = dom; + + const canvas = ref.current; + canvas.height = clientHeight; + canvas.width = clientWidth; + + const ctx = canvas.getContext('2d'); + + ctx.clearRect(0, 0, canvas.width, canvas.height); + data.forEach(item => { + console.log(item); + // ctx.font = `${item.location.height / 1.5}px Arial`; + ctx.fillStyle = '#fff'; + ctx.opacity = 1; + // 根据位置信息绘制文字 + ctx.fillText(item.words, item.location.left, item.location.top); + }); + }, [data]); + + const handleClick = useCallback((event) => { + const canvas = ref.current; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const ctx = canvas.getContext('2d'); + + const selectedText = data.find(item => { + const fontMetrics = ctx.measureText(item.words); + const textWidth = fontMetrics.width; + const textHeight = item.location.height; + const textX = item.location.left; + const textY = item.location.top; + + // 判断点击位置是否在文字区域内 + return ( + x >= textX && + x <= textX + textWidth && + y >= textY && + y <= textY + textHeight + ); + }); + if (selectedText) { + // 给选中的文字添加背景色 + ctx.fillStyle = 'yellow'; // 这里设置背景色为黄色,你可以根据需要修改 + ctx.fillRect(selectedText.location.left, selectedText.location.top, ctx.measureText(selectedText.words).width, selectedText.location.height); + } + }, [data]); + + + return ( + //
+ //
+ + //
+ ); +}; + +export default OCRResultCanvas; diff --git a/frontend/src/react-image-lightbox/react-image-lightbox.js b/frontend/src/react-image-lightbox/react-image-lightbox.js new file mode 100644 index 00000000000..f4bd21bbfc0 --- /dev/null +++ b/frontend/src/react-image-lightbox/react-image-lightbox.js @@ -0,0 +1,2033 @@ +/* eslint-disable spaced-comment */ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Modal from 'react-modal'; +import { + isMobile, + translate, + getWindowWidth, + getWindowHeight, + getHighestSafeWindowContext, +} from './util'; +import { + KEYS, + MIN_ZOOM_LEVEL, + MAX_ZOOM_LEVEL, + ZOOM_RATIO, + WHEEL_MOVE_Y_THRESHOLD, + ZOOM_BUTTON_INCREMENT_SIZE, + ACTION_NONE, + ACTION_MOVE, + ACTION_SWIPE, + ACTION_PINCH, + SOURCE_ANY, + SOURCE_MOUSE, + SOURCE_TOUCH, + SOURCE_POINTER, + MIN_SWIPE_DISTANCE, +} from './constant'; +import OCRCanvas from './ocr-canvas'; + +import './style.css'; + +class ReactImageLightbox extends Component { + static isTargetMatchImage(target) { + return target && /ril-image-current/.test(target.className); + } + + static parseMouseEvent(mouseEvent) { + return { + id: 'mouse', + source: SOURCE_MOUSE, + x: parseInt(mouseEvent.clientX, 10), + y: parseInt(mouseEvent.clientY, 10), + }; + } + + static parseTouchPointer(touchPointer) { + return { + id: touchPointer.identifier, + source: SOURCE_TOUCH, + x: parseInt(touchPointer.clientX, 10), + y: parseInt(touchPointer.clientY, 10), + }; + } + + static parsePointerEvent(pointerEvent) { + return { + id: pointerEvent.pointerId, + source: SOURCE_POINTER, + x: parseInt(pointerEvent.clientX, 10), + y: parseInt(pointerEvent.clientY, 10), + }; + } + + // Request to transition to the previous image + static getTransform({ x = 0, y = 0, zoom = 1, width, targetWidth }) { + let nextX = x; + const windowWidth = getWindowWidth(); + if (width > windowWidth) { + nextX += (windowWidth - width) / 2; + } + const scaleFactor = zoom * (targetWidth / width); + + return { + transform: `translate3d(${nextX}px,${y}px,0) scale3d(${scaleFactor},${scaleFactor},1)`, + }; + } + + constructor(props) { + super(props); + + this.state = { + //----------------------------- + // Animation + //----------------------------- + + // Lightbox is closing + // When Lightbox is mounted, if animation is enabled it will open with the reverse of the closing animation + isClosing: !props.animationDisabled, + + // Component parts should animate (e.g., when images are moving, or image is being zoomed) + shouldAnimate: false, + + //----------------------------- + // Zoom settings + //----------------------------- + // Zoom level of image + zoomLevel: MIN_ZOOM_LEVEL, + + //----------------------------- + // Image position settings + //----------------------------- + // Horizontal offset from center + offsetX: 0, + + // Vertical offset from center + offsetY: 0, + + // image load error for srcType + loadErrorStatus: {}, + + // rotate image degree + rotateDeg: 0, + + // ocr + ocrResultCache: {}, + }; + + // Refs + this.outerEl = React.createRef(); + this.zoomInBtn = React.createRef(); + this.zoomOutBtn = React.createRef(); + this.caption = React.createRef(); + + this.closeIfClickInner = this.closeIfClickInner.bind(this); + this.handleImageDoubleClick = this.handleImageDoubleClick.bind(this); + this.handleImageClick = this.handleImageClick.bind(this); + this.handleImageMouseWheel = this.handleImageMouseWheel.bind(this); + this.handleKeyInput = this.handleKeyInput.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + this.handleMouseDown = this.handleMouseDown.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleOuterMousewheel = this.handleOuterMousewheel.bind(this); + this.handleTouchStart = this.handleTouchStart.bind(this); + this.handleTouchMove = this.handleTouchMove.bind(this); + this.handleTouchEnd = this.handleTouchEnd.bind(this); + this.handlePointerEvent = this.handlePointerEvent.bind(this); + this.handleCaptionMousewheel = this.handleCaptionMousewheel.bind(this); + this.handleWindowResize = this.handleWindowResize.bind(this); + this.handleZoomInButtonClick = this.handleZoomInButtonClick.bind(this); + this.handleZoomOutButtonClick = this.handleZoomOutButtonClick.bind(this); + this.requestClose = this.requestClose.bind(this); + this.requestMoveNext = this.requestMoveNext.bind(this); + this.requestMovePrev = this.requestMovePrev.bind(this); + this.requestMoveUp = this.requestMoveUp.bind(this); + this.requestMoveDown = this.requestMoveDown.bind(this); + this.rotateImage = this.rotateImage.bind(this); + this.ocrRecognition = this.ocrRecognition.bind(this); + this.isMobile = isMobile; + } + + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + // Timeouts - always clear it before umount + this.timeouts = []; + + // Current action + this.currentAction = ACTION_NONE; + + // Events source + this.eventsSource = SOURCE_ANY; + + // Empty pointers list + this.pointerList = []; + + // Prevent inner close + this.preventInnerClose = false; + this.preventInnerCloseTimeout = null; + + // Used to disable animation when changing props.mainSrc|nextSrc|prevSrc + this.keyPressed = false; + + // Used to store load state / dimensions of images + this.imageCache = {}; + + // Time the last keydown event was called (used in keyboard action rate limiting) + this.lastKeyDownTime = 0; + + // Used for debouncing window resize event + this.resizeTimeout = null; + + // Used to determine when actions are triggered by the scroll wheel + this.wheelActionTimeout = null; + this.resetScrollTimeout = null; + this.scrollX = 0; + this.scrollY = 0; + + // Used in panning zoomed images + this.moveStartX = 0; + this.moveStartY = 0; + this.moveStartOffsetX = 0; + this.moveStartOffsetY = 0; + + // Used to swipe + this.swipeStartX = 0; + this.swipeStartY = 0; + this.swipeEndX = 0; + this.swipeEndY = 0; + + // Used to pinch + this.pinchTouchList = null; + this.pinchDistance = 0; + + // Used to differentiate between images with identical src + this.keyCounter = 0; + + // Used to detect a move when all src's remain unchanged (four or more of the same image in a row) + this.moveRequested = false; + + if (!this.props.animationDisabled) { + // Make opening animation play + this.setState({ isClosing: false }); + } + } + + componentDidMount() { + // Prevents cross-origin errors when using a cross-origin iframe + this.windowContext = getHighestSafeWindowContext(); + + this.listeners = { + resize: this.handleWindowResize, + mouseup: this.handleMouseUp, + touchend: this.handleTouchEnd, + touchcancel: this.handleTouchEnd, + pointerdown: this.handlePointerEvent, + pointermove: this.handlePointerEvent, + pointerup: this.handlePointerEvent, + pointercancel: this.handlePointerEvent, + }; + Object.keys(this.listeners).forEach(type => { + this.windowContext.addEventListener(type, this.listeners[type]); + }); + + document.addEventListener('wheel', this.handleWheel, { passive: false }); + this.loadAllImages(); + } + + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps(nextProps) { + // Iterate through the source types for prevProps and nextProps to + // determine if any of the sources changed + let sourcesChanged = false; + const prevSrcDict = {}; + const nextSrcDict = {}; + this.getSrcTypes().forEach(srcType => { + if (this.props[srcType.name] !== nextProps[srcType.name]) { + sourcesChanged = true; + + prevSrcDict[this.props[srcType.name]] = true; + nextSrcDict[nextProps[srcType.name]] = true; + } + }); + + if (sourcesChanged || this.moveRequested) { + // Reset the loaded state for images not rendered next + Object.keys(prevSrcDict).forEach(prevSrc => { + if (!(prevSrc in nextSrcDict) && prevSrc in this.imageCache) { + this.imageCache[prevSrc].loaded = false; + } + }); + + this.moveRequested = false; + + // Load any new images + this.loadAllImages(nextProps); + } + } + + shouldComponentUpdate() { + // Wait for move... + return !this.moveRequested; + } + + componentWillUnmount() { + this.didUnmount = true; + Object.keys(this.listeners).forEach(type => { + this.windowContext.removeEventListener(type, this.listeners[type]); + }); + document.removeEventListener('wheel', this.handleWheel, { passive: false }); + this.timeouts.forEach(tid => clearTimeout(tid)); + } + + setTimeout(func, time) { + const id = setTimeout(() => { + this.timeouts = this.timeouts.filter(tid => tid !== id); + func(); + }, time); + this.timeouts.push(id); + return id; + } + + setPreventInnerClose() { + if (this.preventInnerCloseTimeout) { + this.clearTimeout(this.preventInnerCloseTimeout); + } + this.preventInnerClose = true; + this.preventInnerCloseTimeout = this.setTimeout(() => { + this.preventInnerClose = false; + this.preventInnerCloseTimeout = null; + }, 100); + } + + // Get info for the best suited image to display with the given srcType + getBestImageForType(srcType) { + let imageSrc = this.props[srcType]; + let fitSizes = {}; + + if (this.isImageLoaded(imageSrc)) { + // Use full-size image if available + fitSizes = this.getFitSizes( + this.imageCache[imageSrc].width, + this.imageCache[imageSrc].height + ); + } else if (this.isImageLoaded(this.props[`${srcType}Thumbnail`])) { + // Fall back to using thumbnail if the image has not been loaded + imageSrc = this.props[`${srcType}Thumbnail`]; + fitSizes = this.getFitSizes( + this.imageCache[imageSrc].width, + this.imageCache[imageSrc].height, + true + ); + } else { + return null; + } + + return { + src: imageSrc, + height: this.imageCache[imageSrc].height, + width: this.imageCache[imageSrc].width, + targetHeight: fitSizes.height, + targetWidth: fitSizes.width, + }; + } + + // Get sizing for when an image is larger than the window + getFitSizes(width, height, stretch) { + const boxSize = this.getLightboxRect(); + // Padding (px) between the edge of the window and the lightbox + const imagePadding = this.isMobile ? 0 : 70; + let maxHeight = boxSize.height - imagePadding * 2; + let maxWidth = boxSize.width - imagePadding * 2; + + if (!stretch) { + maxHeight = Math.min(maxHeight, height); + maxWidth = Math.min(maxWidth, width); + } + + const maxRatio = maxWidth / maxHeight; + const srcRatio = width / height; + + if (maxRatio > srcRatio) { + // height is the constraining dimension of the photo + return { + width: (width * maxHeight) / height, + height: maxHeight, + }; + } + + return { + width: maxWidth, + height: (height * maxWidth) / width, + }; + } + + getMaxOffsets(zoomLevel = this.state.zoomLevel) { + const currentImageInfo = this.getBestImageForType('mainSrc'); + if (currentImageInfo === null) { + return { maxX: 0, minX: 0, maxY: 0, minY: 0 }; + } + + const boxSize = this.getLightboxRect(); + const zoomMultiplier = this.getZoomMultiplier(zoomLevel); + + let maxX = 0; + if (zoomMultiplier * currentImageInfo.width - boxSize.width < 0) { + // if there is still blank space in the X dimension, don't limit except to the opposite edge + maxX = (boxSize.width - zoomMultiplier * currentImageInfo.width) / 2; + } else { + maxX = (zoomMultiplier * currentImageInfo.width - boxSize.width) / 2; + } + + let maxY = 0; + if (zoomMultiplier * currentImageInfo.height - boxSize.height < 0) { + // if there is still blank space in the Y dimension, don't limit except to the opposite edge + maxY = (boxSize.height - zoomMultiplier * currentImageInfo.height) / 2; + } else { + maxY = (zoomMultiplier * currentImageInfo.height - boxSize.height) / 2; + } + + return { + maxX, + maxY, + minX: -1 * maxX, + minY: -1 * maxY, + }; + } + + // Get image src types + getSrcTypes() { + return [ + { + name: 'mainSrc', + keyEnding: `i${this.keyCounter}`, + }, + { + name: 'mainSrcThumbnail', + keyEnding: `t${this.keyCounter}`, + }, + { + name: 'nextSrc', + keyEnding: `i${this.keyCounter + 1}`, + }, + { + name: 'nextSrcThumbnail', + keyEnding: `t${this.keyCounter + 1}`, + }, + { + name: 'prevSrc', + keyEnding: `i${this.keyCounter - 1}`, + }, + { + name: 'prevSrcThumbnail', + keyEnding: `t${this.keyCounter - 1}`, + }, + ]; + } + + /** + * Get sizing when the image is scaled + */ + getZoomMultiplier(zoomLevel = this.state.zoomLevel) { + return ZOOM_RATIO ** zoomLevel; + } + + /** + * Get the size of the lightbox in pixels + */ + getLightboxRect() { + if (this.outerEl.current) { + return this.outerEl.current.getBoundingClientRect(); + } + + return { + width: getWindowWidth(), + height: getWindowHeight(), + top: 0, + right: 0, + bottom: 0, + left: 0, + }; + } + + clearTimeout(id) { + this.timeouts = this.timeouts.filter(tid => tid !== id); + clearTimeout(id); + } + + // Change zoom level + changeZoom(zoomLevel, clientX, clientY) { + // Ignore if zoom disabled + if (!this.props.enableZoom) { + return; + } + + // Constrain zoom level to the set bounds + const nextZoomLevel = Math.max( + MIN_ZOOM_LEVEL, + Math.min(MAX_ZOOM_LEVEL, zoomLevel) + ); + + // Ignore requests that don't change the zoom level + if (nextZoomLevel === this.state.zoomLevel) { + return; + } + + if (nextZoomLevel === MIN_ZOOM_LEVEL) { + // Snap back to center if zoomed all the way out + this.setState({ + zoomLevel: nextZoomLevel, + offsetX: 0, + offsetY: 0, + }); + + return; + } + + const imageBaseSize = this.getBestImageForType('mainSrc'); + if (imageBaseSize === null) { + return; + } + + const currentZoomMultiplier = this.getZoomMultiplier(); + const nextZoomMultiplier = this.getZoomMultiplier(nextZoomLevel); + + // Default to the center of the image to zoom when no mouse position specified + const boxRect = this.getLightboxRect(); + const pointerX = + typeof clientX !== 'undefined' + ? clientX - boxRect.left + : boxRect.width / 2; + const pointerY = + typeof clientY !== 'undefined' + ? clientY - boxRect.top + : boxRect.height / 2; + + const currentImageOffsetX = + (boxRect.width - imageBaseSize.width * currentZoomMultiplier) / 2; + const currentImageOffsetY = + (boxRect.height - imageBaseSize.height * currentZoomMultiplier) / 2; + + const currentImageRealOffsetX = currentImageOffsetX - this.state.offsetX; + const currentImageRealOffsetY = currentImageOffsetY - this.state.offsetY; + + const currentPointerXRelativeToImage = + (pointerX - currentImageRealOffsetX) / currentZoomMultiplier; + const currentPointerYRelativeToImage = + (pointerY - currentImageRealOffsetY) / currentZoomMultiplier; + + const nextImageRealOffsetX = + pointerX - currentPointerXRelativeToImage * nextZoomMultiplier; + const nextImageRealOffsetY = + pointerY - currentPointerYRelativeToImage * nextZoomMultiplier; + + const nextImageOffsetX = + (boxRect.width - imageBaseSize.width * nextZoomMultiplier) / 2; + const nextImageOffsetY = + (boxRect.height - imageBaseSize.height * nextZoomMultiplier) / 2; + + let nextOffsetX = nextImageOffsetX - nextImageRealOffsetX; + let nextOffsetY = nextImageOffsetY - nextImageRealOffsetY; + + // When zooming out, limit the offset so things don't get left askew + if (this.currentAction !== ACTION_PINCH) { + const maxOffsets = this.getMaxOffsets(); + if (this.state.zoomLevel > nextZoomLevel) { + nextOffsetX = Math.max( + maxOffsets.minX, + Math.min(maxOffsets.maxX, nextOffsetX) + ); + nextOffsetY = Math.max( + maxOffsets.minY, + Math.min(maxOffsets.maxY, nextOffsetY) + ); + } + } + + this.setState({ + zoomLevel: nextZoomLevel, + offsetX: nextOffsetX, + offsetY: nextOffsetY, + }); + } + + closeIfClickInner(event) { + if ( + !this.preventInnerClose && + event.target.className.search(/\bril-inner\b/) > -1 + ) { + this.requestClose(event); + } + } + + /** + * Handle user keyboard actions + */ + handleKeyInput(event) { + event.stopPropagation(); + + // Ignore key input during animations + if (this.isAnimating()) { + return; + } + + // Allow slightly faster navigation through the images when user presses keys repeatedly + if (event.type === 'keyup') { + this.lastKeyDownTime -= this.props.keyRepeatKeyupBonus; + return; + } + + const keyCode = event.which || event.keyCode; + + // Ignore key presses that happen too close to each other (when rapid fire key pressing or holding down the key) + // But allow it if it's a lightbox closing action + const currentTime = new Date(); + if ( + currentTime.getTime() - this.lastKeyDownTime < + this.props.keyRepeatLimit && + keyCode !== KEYS.ESC + ) { + return; + } + this.lastKeyDownTime = currentTime.getTime(); + + switch (keyCode) { + // ESC key closes the lightbox + case KEYS.ESC: + event.preventDefault(); + this.requestClose(event); + break; + + // Left arrow key moves to previous image + case KEYS.LEFT_ARROW: + if (!this.props.prevSrc) { + return; + } + + event.preventDefault(); + this.keyPressed = true; + this.requestMovePrev(event); + break; + + // Right arrow key moves to next image + case KEYS.RIGHT_ARROW: + if (!this.props.nextSrc) { + return; + } + + event.preventDefault(); + this.keyPressed = true; + this.requestMoveNext(event); + break; + + // Up arrow key moves to upper row + case KEYS.UP_ARROW: { + if (this.props.onClickMoveUp) { + event.preventDefault(); + this.keyPressed = true; + this.requestMoveUp(event); + } + break; + } + // Down arrow key moves to down row + case KEYS.DOWN_ARROW: { + if (this.props.onClickMoveUp) { + event.preventDefault(); + this.keyPressed = true; + this.requestMoveDown(event); + } + break; + } + default: + } + } + + /** + * Handle a mouse wheel event over the lightbox container + */ + handleOuterMousewheel(event) { + // Prevent scrolling of the background + event.stopPropagation(); + + this.clearTimeout(this.resetScrollTimeout); + this.resetScrollTimeout = this.setTimeout(() => { + this.scrollX = 0; + this.scrollY = 0; + }, 300); + } + + handleImageMouseWheel(event) { + // when gesture move up/down/left/right, event.deltaY is integer, move image + if (parseInt(event.deltaY) === parseFloat(event.deltaY)) { + if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) { + let newOffsetY = this.state.offsetY + event.deltaY; + newOffsetY = newOffsetY < 0 ? 0 : newOffsetY; + this.setState({ offsetY: newOffsetY }); + } else { + let newOffsetX = this.state.offsetX + event.deltaX; + newOffsetX = newOffsetX < 0 ? 0 : newOffsetX; + this.setState({ offsetX: newOffsetX }); + } + return; + } + // when gesture zoom in or zoom out, event.deltaY is decimal, zoom image + const yThreshold = WHEEL_MOVE_Y_THRESHOLD; + + if (Math.abs(event.deltaY) >= Math.abs(event.deltaX)) { + event.stopPropagation(); + // If the vertical scroll amount was large enough, perform a zoom + if (Math.abs(event.deltaY) < yThreshold) { + return; + } + + this.scrollX = 0; + this.scrollY += event.deltaY; + + this.changeZoom( + this.state.zoomLevel - event.deltaY, + event.clientX, + event.clientY + ); + } + } + + /** + * Handle a double click on the current image + */ + handleImageDoubleClick(event) { + if (this.state.zoomLevel > MIN_ZOOM_LEVEL) { + // A double click when zoomed in zooms all the way out + this.changeZoom(MIN_ZOOM_LEVEL, event.clientX, event.clientY); + } else { + // A double click when zoomed all the way out zooms in + this.changeZoom( + this.state.zoomLevel + ZOOM_BUTTON_INCREMENT_SIZE, + event.clientX, + event.clientY + ); + } + } + + handleImageClick(event) { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + } + + shouldHandleEvent(source) { + if (this.eventsSource === source) { + return true; + } + if (this.eventsSource === SOURCE_ANY) { + this.eventsSource = source; + return true; + } + switch (source) { + case SOURCE_MOUSE: + return false; + case SOURCE_TOUCH: + this.eventsSource = SOURCE_TOUCH; + this.filterPointersBySource(); + return true; + case SOURCE_POINTER: + if (this.eventsSource === SOURCE_MOUSE) { + this.eventsSource = SOURCE_POINTER; + this.filterPointersBySource(); + return true; + } + return false; + default: + return false; + } + } + + addPointer(pointer) { + this.pointerList.push(pointer); + } + + removePointer(pointer) { + this.pointerList = this.pointerList.filter(({ id }) => id !== pointer.id); + } + + filterPointersBySource() { + this.pointerList = this.pointerList.filter( + ({ source }) => source === this.eventsSource + ); + } + + handleMouseDown(event) { + if ( + this.shouldHandleEvent(SOURCE_MOUSE) && + ReactImageLightbox.isTargetMatchImage(event.target) + ) { + this.addPointer(ReactImageLightbox.parseMouseEvent(event)); + this.multiPointerStart(event); + } + } + + handleMouseMove(event) { + if (this.shouldHandleEvent(SOURCE_MOUSE)) { + this.multiPointerMove(event, [ReactImageLightbox.parseMouseEvent(event)]); + } + } + + handleMouseUp(event) { + if (this.shouldHandleEvent(SOURCE_MOUSE)) { + this.removePointer(ReactImageLightbox.parseMouseEvent(event)); + this.multiPointerEnd(event); + } + } + + handlePointerEvent(event) { + if (this.shouldHandleEvent(SOURCE_POINTER)) { + switch (event.type) { + case 'pointerdown': + if (ReactImageLightbox.isTargetMatchImage(event.target)) { + this.addPointer(ReactImageLightbox.parsePointerEvent(event)); + this.multiPointerStart(event); + } + break; + case 'pointermove': + this.multiPointerMove(event, [ + ReactImageLightbox.parsePointerEvent(event), + ]); + break; + case 'pointerup': + case 'pointercancel': + this.removePointer(ReactImageLightbox.parsePointerEvent(event)); + this.multiPointerEnd(event); + break; + default: + break; + } + } + } + + handleTouchStart(event) { + if ( + this.shouldHandleEvent(SOURCE_TOUCH) && + ReactImageLightbox.isTargetMatchImage(event.target) + ) { + [].forEach.call(event.changedTouches, eventTouch => + this.addPointer(ReactImageLightbox.parseTouchPointer(eventTouch)) + ); + this.multiPointerStart(event); + } + } + + handleTouchMove(event) { + if (this.shouldHandleEvent(SOURCE_TOUCH)) { + this.multiPointerMove( + event, + [].map.call(event.changedTouches, eventTouch => + ReactImageLightbox.parseTouchPointer(eventTouch) + ) + ); + } + } + + handleTouchEnd(event) { + if (this.shouldHandleEvent(SOURCE_TOUCH)) { + [].map.call(event.changedTouches, touch => + this.removePointer(ReactImageLightbox.parseTouchPointer(touch)) + ); + this.multiPointerEnd(event); + } + } + + decideMoveOrSwipe(pointer) { + if (this.state.zoomLevel <= MIN_ZOOM_LEVEL) { + this.handleSwipeStart(pointer); + } else { + this.handleMoveStart(pointer); + } + } + + multiPointerStart(event) { + this.handleEnd(null); + switch (this.pointerList.length) { + case 1: { + event.preventDefault(); + this.decideMoveOrSwipe(this.pointerList[0]); + break; + } + case 2: { + event.preventDefault(); + this.handlePinchStart(this.pointerList); + break; + } + default: + break; + } + } + + multiPointerMove(event, pointerList) { + switch (this.currentAction) { + case ACTION_MOVE: { + event.preventDefault(); + this.handleMove(pointerList[0]); + break; + } + case ACTION_SWIPE: { + event.preventDefault(); + this.handleSwipe(pointerList[0]); + break; + } + case ACTION_PINCH: { + event.preventDefault(); + this.handlePinch(pointerList); + break; + } + default: + break; + } + } + + multiPointerEnd(event) { + if (this.currentAction !== ACTION_NONE) { + this.setPreventInnerClose(); + this.handleEnd(event); + } + switch (this.pointerList.length) { + case 0: { + this.eventsSource = SOURCE_ANY; + break; + } + case 1: { + event.preventDefault(); + this.decideMoveOrSwipe(this.pointerList[0]); + break; + } + case 2: { + event.preventDefault(); + this.handlePinchStart(this.pointerList); + break; + } + default: + break; + } + } + + handleEnd(event) { + switch (this.currentAction) { + case ACTION_MOVE: + this.handleMoveEnd(event); + break; + case ACTION_SWIPE: + this.handleSwipeEnd(event); + break; + case ACTION_PINCH: + this.handlePinchEnd(event); + break; + default: + break; + } + } + + // Handle move start over the lightbox container + // This happens: + // - On a mouseDown event + // - On a touchstart event + handleMoveStart({ x: clientX, y: clientY }) { + if (!this.props.enableZoom) { + return; + } + this.currentAction = ACTION_MOVE; + this.moveStartX = clientX; + this.moveStartY = clientY; + this.moveStartOffsetX = this.state.offsetX; + this.moveStartOffsetY = this.state.offsetY; + } + + // Handle dragging over the lightbox container + // This happens: + // - After a mouseDown and before a mouseUp event + // - After a touchstart and before a touchend event + handleMove({ x: clientX, y: clientY }) { + const newOffsetX = this.moveStartX - clientX + this.moveStartOffsetX; + const newOffsetY = this.moveStartY - clientY + this.moveStartOffsetY; + if ( + this.state.offsetX !== newOffsetX || + this.state.offsetY !== newOffsetY + ) { + this.setState({ + offsetX: newOffsetX, + offsetY: newOffsetY, + }); + } + } + + handleMoveEnd() { + this.currentAction = ACTION_NONE; + this.moveStartX = 0; + this.moveStartY = 0; + this.moveStartOffsetX = 0; + this.moveStartOffsetY = 0; + // Snap image back into frame if outside max offset range + const maxOffsets = this.getMaxOffsets(); + const nextOffsetX = Math.max( + maxOffsets.minX, + Math.min(maxOffsets.maxX, this.state.offsetX) + ); + const nextOffsetY = Math.max( + maxOffsets.minY, + Math.min(maxOffsets.maxY, this.state.offsetY) + ); + if ( + nextOffsetX !== this.state.offsetX || + nextOffsetY !== this.state.offsetY + ) { + this.setState({ + offsetX: nextOffsetX, + offsetY: nextOffsetY, + shouldAnimate: true, + }); + this.setTimeout(() => { + this.setState({ shouldAnimate: false }); + }, this.props.animationDuration); + } + } + + handleSwipeStart({ x: clientX, y: clientY }) { + this.currentAction = ACTION_SWIPE; + this.swipeStartX = clientX; + this.swipeStartY = clientY; + this.swipeEndX = clientX; + this.swipeEndY = clientY; + } + + handleSwipe({ x: clientX, y: clientY }) { + this.swipeEndX = clientX; + this.swipeEndY = clientY; + } + + handleSwipeEnd(event) { + const xDiff = this.swipeEndX - this.swipeStartX; + const xDiffAbs = Math.abs(xDiff); + const yDiffAbs = Math.abs(this.swipeEndY - this.swipeStartY); + + this.currentAction = ACTION_NONE; + this.swipeStartX = 0; + this.swipeStartY = 0; + this.swipeEndX = 0; + this.swipeEndY = 0; + + if (!event || this.isAnimating() || xDiffAbs < yDiffAbs * 1.5) { + return; + } + + if (xDiffAbs < MIN_SWIPE_DISTANCE) { + const boxRect = this.getLightboxRect(); + if (xDiffAbs < boxRect.width / 4) { + return; + } + } + + if (xDiff > 0 && this.props.prevSrc) { + event.preventDefault(); + this.requestMovePrev(); + } else if (xDiff < 0 && this.props.nextSrc) { + event.preventDefault(); + this.requestMoveNext(); + } + } + + calculatePinchDistance([a, b] = this.pinchTouchList) { + return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); + } + + calculatePinchCenter([a, b] = this.pinchTouchList) { + return { + x: a.x - (a.x - b.x) / 2, + y: a.y - (a.y - b.y) / 2, + }; + } + + handleWheel = event => { + event.preventDefault(); + }; + + handlePinchStart(pointerList) { + if (!this.props.enableZoom) { + return; + } + this.currentAction = ACTION_PINCH; + this.pinchTouchList = pointerList.map(({ id, x, y }) => ({ id, x, y })); + this.pinchDistance = this.calculatePinchDistance(); + } + + handlePinch(pointerList) { + this.pinchTouchList = this.pinchTouchList.map(oldPointer => { + for (let i = 0; i < pointerList.length; i += 1) { + if (pointerList[i].id === oldPointer.id) { + return pointerList[i]; + } + } + + return oldPointer; + }); + + const newDistance = this.calculatePinchDistance(); + + const zoomLevel = this.state.zoomLevel + newDistance - this.pinchDistance; + + this.pinchDistance = newDistance; + const { x: clientX, y: clientY } = this.calculatePinchCenter( + this.pinchTouchList + ); + this.changeZoom(zoomLevel, clientX, clientY); + } + + handlePinchEnd() { + this.currentAction = ACTION_NONE; + this.pinchTouchList = null; + this.pinchDistance = 0; + } + + // Handle the window resize event + handleWindowResize() { + this.clearTimeout(this.resizeTimeout); + this.resizeTimeout = this.setTimeout(this.forceUpdate.bind(this), 100); + } + + handleZoomInButtonClick() { + const nextZoomLevel = this.state.zoomLevel + ZOOM_BUTTON_INCREMENT_SIZE; + this.changeZoom(nextZoomLevel); + if (nextZoomLevel === MAX_ZOOM_LEVEL) { + this.zoomOutBtn.current.focus(); + } + } + + handleZoomOutButtonClick() { + const nextZoomLevel = this.state.zoomLevel - ZOOM_BUTTON_INCREMENT_SIZE; + this.changeZoom(nextZoomLevel); + if (nextZoomLevel === MIN_ZOOM_LEVEL) { + this.zoomInBtn.current.focus(); + } + } + + handleCaptionMousewheel(event) { + event.stopPropagation(); + + if (!this.caption.current) { + return; + } + + const { height } = this.caption.current.getBoundingClientRect(); + const { scrollHeight, scrollTop } = this.caption.current; + if ( + (event.deltaY > 0 && height + scrollTop >= scrollHeight) || + (event.deltaY < 0 && scrollTop <= 0) + ) { + event.preventDefault(); + } + } + + // Detach key and mouse input events + isAnimating() { + return this.state.shouldAnimate || this.state.isClosing; + } + + // Check if image is loaded + isImageLoaded(imageSrc) { + return ( + imageSrc && + imageSrc in this.imageCache && + this.imageCache[imageSrc].loaded + ); + } + + // Load image from src and call callback with image width and height on load + loadImage(srcType, imageSrc, done) { + // Return the image info if it is already cached + if (this.isImageLoaded(imageSrc)) { + this.setTimeout(() => { + done(); + }, 1); + return; + } + + const inMemoryImage = new global.Image(); + + if (this.props.imageCrossOrigin) { + inMemoryImage.crossOrigin = this.props.imageCrossOrigin; + } + + inMemoryImage.onerror = errorEvent => { + this.props.onImageLoadError(imageSrc, srcType, errorEvent); + + // failed to load so set the state loadErrorStatus + this.setState(prevState => ({ + loadErrorStatus: { ...prevState.loadErrorStatus, [srcType]: true }, + })); + + done(errorEvent); + }; + + inMemoryImage.onload = () => { + this.props.onImageLoad(imageSrc, srcType, inMemoryImage); + + this.imageCache[imageSrc] = { + loaded: true, + width: inMemoryImage.width, + height: inMemoryImage.height, + }; + + done(); + }; + + inMemoryImage.src = imageSrc; + } + + // Load all images and their thumbnails + loadAllImages(props = this.props) { + const generateLoadDoneCallback = (srcType, imageSrc) => err => { + // Give up showing image on error + if (err) { + return; + } + + // Don't rerender if the src is not the same as when the load started + // or if the component has unmounted + if (this.props[srcType] !== imageSrc || this.didUnmount) { + return; + } + + // Force rerender with the new image + this.forceUpdate(); + }; + + // Load the images + this.getSrcTypes().forEach(srcType => { + const type = srcType.name; + + // there is no error when we try to load it initially + if (props[type] && this.state.loadErrorStatus[type]) { + this.setState(prevState => ({ + loadErrorStatus: { ...prevState.loadErrorStatus, [type]: false }, + })); + } + + // Load unloaded images + if (props[type] && !this.isImageLoaded(props[type])) { + this.loadImage( + type, + props[type], + generateLoadDoneCallback(type, props[type]) + ); + } + }); + } + + // Request that the lightbox be closed + requestClose(event) { + // Call the parent close request + const closeLightbox = () => { + this.saveRotateImage(); + this.props.onCloseRequest(event); + }; + + if ( + this.props.animationDisabled || + (event.type === 'keydown' && !this.props.animationOnKeyInput) + ) { + // No animation + closeLightbox(); + return; + } + + // With animation + // Start closing animation + this.setState({ isClosing: true }); + + // Perform the actual closing at the end of the animation + this.setTimeout(closeLightbox, this.props.animationDuration); + } + + requestMove(direction, event) { + // Reset the zoom level on image move + const nextState = { + zoomLevel: MIN_ZOOM_LEVEL, + offsetX: 0, + offsetY: 0, + }; + + // Enable animated states + if ( + !this.props.animationDisabled && + (!this.keyPressed || this.props.animationOnKeyInput) + ) { + nextState.shouldAnimate = true; + this.setTimeout( + () => this.setState({ shouldAnimate: false }), + this.props.animationDuration + ); + } + this.keyPressed = false; + this.moveRequested = true; + this.saveRotateImage(); + + if (direction === 'prev') { + this.keyCounter -= 1; + this.setState(nextState); + this.props.onMovePrevRequest(event); + } + else if (direction === 'next') { + this.keyCounter += 1; + this.setState(nextState); + this.props.onMoveNextRequest(event); + } + else if (direction === 'up') { + this.keyCounter = 0; + this.setState(nextState); + this.props.onClickMoveUp(event); + } + else if (direction === 'down') { + this.keyCounter = 0; + this.setState(nextState); + this.props.onClickMoveDown(event); + } + } + + // Request to transition to the next image + requestMoveNext(event) { + this.requestMove('next', event); + } + + // Request to transition to the previous image + requestMovePrev(event) { + this.requestMove('prev', event); + } + + // Request to transition to the up row image + requestMoveUp(event) { + this.requestMove('up', event); + } + + // Request to transition to the down row image + requestMoveDown(event) { + this.requestMove('down', event); + } + + saveRotateImage() { + if ( + this.props.onRotateImage && + this.state.rotateDeg !== 0 && + this.state.rotateDeg !== 360 + ) { + this.props.onRotateImage(this.state.rotateDeg); + this.setState({ rotateDeg: 0 }); + } + } + + rotateImage() { + let { rotateDeg } = this.state; + rotateDeg = rotateDeg >= 360 ? this.state.rotateDeg - 270 : rotateDeg + 90; + this.setState({ rotateDeg }); + } + + ocrRecognition() { + const { mainSrc } = this.props; + const that = this; + const { ocrResultCache } = that.state; + const key = mainSrc + 'i'; + if (ocrResultCache[key]) return; + this.props.ocrRecognition(mainSrc, (result) => { + const { ocrResultCache } = that.state; + const newOcrResultCache = { ...ocrResultCache, [key]: result }; + console.log(newOcrResultCache); + console.log(result); + that.setState({ ocrResultCache: newOcrResultCache }); + }); + } + + renderOcrResult(zoomMultiplier) { + const { mainSrc, animationDisabled, animationDuration } = this.props; + const { ocrResultCache, rotateDeg, zoomLevel, offsetX, offsetY } = this.state; + const key = mainSrc + 'i'; + const ocrResult = ocrResultCache[key]; + if (!ocrResult) return null; + const bestImageInfo = this.getBestImageForType('mainSrc'); + let transitionStyle = {}; + const isAnimating = this.isAnimating(); + + // Transition settings for sliding animations + if (!animationDisabled && isAnimating) { + transitionStyle = { + ...transitionStyle, + transition: `transform ${animationDuration}ms`, + }; + } + const transforms = { + x: -1 * offsetX, + y: -1 * offsetY, + zoom: zoomMultiplier, + }; + let style = { + ...transitionStyle, + ...ReactImageLightbox.getTransform({ + ...transforms, + ...bestImageInfo, + }), + }; + + if (zoomLevel > MIN_ZOOM_LEVEL) { + style.cursor = 'move'; + } + + style.transform = `${style.transform} rotate(${rotateDeg}deg)`; + return ( + + ); + } + + render() { + const { + animationDisabled, + animationDuration, + clickOutsideToClose, + discourageDownloads, + enableZoom, + imageTitle, + nextSrc, + prevSrc, + toolbarButtons, + onAfterOpen, + imageCrossOrigin, + reactModalProps, + onRotateImage, + } = this.props; + const { + zoomLevel, + offsetX, + offsetY, + isClosing, + loadErrorStatus, + rotateDeg, + } = this.state; + + const boxSize = this.getLightboxRect(); + let transitionStyle = {}; + const isAnimating = this.isAnimating(); + + // Transition settings for sliding animations + if (!animationDisabled && isAnimating) { + transitionStyle = { + ...transitionStyle, + transition: `transform ${animationDuration}ms`, + }; + } + + // Key endings to differentiate between images with the same src + const keyEndings = {}; + this.getSrcTypes().forEach(({ name, keyEnding }) => { + keyEndings[name] = keyEnding; + }); + + // Images to be displayed + const images = []; + const addImage = (srcType, imageClass, transforms) => { + // Ignore types that have no source defined for their full size image + if (!this.props[srcType]) { + return; + } + const bestImageInfo = this.getBestImageForType(srcType); + + const imageStyle = { + ...transitionStyle, + ...ReactImageLightbox.getTransform({ + ...transforms, + ...bestImageInfo, + }), + }; + + if (zoomLevel > MIN_ZOOM_LEVEL) { + imageStyle.cursor = 'move'; + } + + imageStyle.transform = `${imageStyle.transform} rotate(${rotateDeg}deg)`; + // support IE 9 and 11 + const hasTrueValue = object => + Object.keys(object).some(key => object[key]); + + // when error on one of the loads then push custom error stuff + if (bestImageInfo === null && hasTrueValue(loadErrorStatus)) { + images.push( +
+
+ {this.props.imageLoadErrorMessage} +
+
+ ); + + return; + } + if (bestImageInfo === null) { + const loadingIcon = ( +
+ {[...new Array(12)].map((_, index) => ( +
+ ))} +
+ ); + + // Fall back to loading icon if the thumbnail has not been loaded + images.push( +
+
{loadingIcon}
+
+ ); + + return; + } + + const imageSrc = bestImageInfo.src; + if (discourageDownloads) { + imageStyle.backgroundImage = `url('${imageSrc}')`; + images.push( +
+
+
+ ); + } else { + images.push( + e.preventDefault()} + style={imageStyle} + src={imageSrc} + key={imageSrc + keyEndings[srcType]} + alt={ + typeof imageTitle === 'string' ? imageTitle : translate('Image') + } + draggable={false} + /> + ); + } + }; + + const zoomMultiplier = this.getZoomMultiplier(); + // Next Image (displayed on the right) + addImage('nextSrc', 'ril-image-next ril__imageNext', { + x: boxSize.width, + }); + // Main Image + addImage('mainSrc', 'ril-image-current', { + x: -1 * offsetX, + y: -1 * offsetY, + zoom: zoomMultiplier, + }); + // Previous Image (displayed on the left) + addImage('prevSrc', 'ril-image-prev ril__imagePrev', { + x: -1 * boxSize.width, + }); + + const reactModalStyle = Object.assign({}, { + overlay: { + zIndex: 1051, + backgroundColor: this.isMobile ? '#000' : 'transparent', + } + }, this.props.reactModalStyle); + + const modalStyle = { + overlay: { + zIndex: 1000, + backgroundColor: 'transparent', + ...reactModalStyle.overlay, // Allow style overrides via props + }, + content: { + backgroundColor: 'transparent', + overflow: 'hidden', // Needed, otherwise keyboard shortcuts scroll the page + border: 'none', + borderRadius: 0, + padding: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + ...reactModalStyle.content, // Allow style overrides via props + }, + }; + + return ( + { + // Focus on the div with key handlers + if (this.outerEl.current) { + this.outerEl.current.focus(); + } + + onAfterOpen(); + }} + style={modalStyle} + contentLabel={translate('Lightbox')} + appElement={ + typeof global.window !== 'undefined' + ? global.window.document.body + : undefined + } + {...reactModalProps} + > +
+
+ {images} + {this.renderOcrResult(zoomMultiplier)} +
+ + {prevSrc && !this.isMobile && ( +
+ {/*
+ {this.props.imageCaption} +
*/} + + {/* Image footer buttons */} +
event.stopPropagation()} + className="ril-caption ril__caption" + // ref={this.caption} + > + {enableZoom && ( +
  • +
  • + )} + + {enableZoom && ( +
  • +
  • + )} + + {onRotateImage && ( +
  • +
  • + )} +
    +
    + {this.isMobile && +
    +
    +
    + {onRotateImage && ( +
  • +
  • + )} + {this.props.onClickDownload && ( +
  • +
  • + )} +
    + {this.props.onClickDelete && +
  • +
  • + } +
    +
    + } + + ); + } +} + +ReactImageLightbox.propTypes = { + //----------------------------- + // Image sources + //----------------------------- + + // Main display image url + mainSrc: PropTypes.string.isRequired, // eslint-disable-line react/no-unused-prop-types + + // Previous display image url (displayed to the left) + // If left undefined, movePrev actions will not be performed, and the button not displayed + prevSrc: PropTypes.string, + + // Next display image url (displayed to the right) + // If left undefined, moveNext actions will not be performed, and the button not displayed + nextSrc: PropTypes.string, + + //----------------------------- + // Image thumbnail sources + //----------------------------- + + // Thumbnail image url corresponding to props.mainSrc + mainSrcThumbnail: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + + // Thumbnail image url corresponding to props.prevSrc + prevSrcThumbnail: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + + // Thumbnail image url corresponding to props.nextSrc + nextSrcThumbnail: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + + //----------------------------- + // Event Handlers + //----------------------------- + + // Close window event + // Should change the parent state such that the lightbox is not rendered + onCloseRequest: PropTypes.func.isRequired, + + // Move to previous image event + // Should change the parent state such that props.prevSrc becomes props.mainSrc, + // props.mainSrc becomes props.nextSrc, etc. + onMovePrevRequest: PropTypes.func, + + // Move to next image event + // Should change the parent state such that props.nextSrc becomes props.mainSrc, + // props.mainSrc becomes props.prevSrc, etc. + onMoveNextRequest: PropTypes.func, + + // Called when an image fails to load + // (imageSrc: string, srcType: string, errorEvent: object): void + onImageLoadError: PropTypes.func, + + // Called when image successfully loads + onImageLoad: PropTypes.func, + + // Open window event + onAfterOpen: PropTypes.func, + + onRotateImage: PropTypes.func, + onClickMoveUp: PropTypes.func, + onClickMoveDown: PropTypes.func, + onClickDelete: PropTypes.func, + onClickDownload: PropTypes.func, + + //----------------------------- + // Download discouragement settings + //----------------------------- + + // Enable download discouragement (prevents [right-click -> Save Image As...]) + discourageDownloads: PropTypes.bool, + + //----------------------------- + // Animation settings + //----------------------------- + + // Disable all animation + animationDisabled: PropTypes.bool, + + // Disable animation on actions performed with keyboard shortcuts + animationOnKeyInput: PropTypes.bool, + + // Animation duration (ms) + animationDuration: PropTypes.number, + + //----------------------------- + // Keyboard shortcut settings + //----------------------------- + + // Required interval of time (ms) between key actions + // (prevents excessively fast navigation of images) + keyRepeatLimit: PropTypes.number, + + // Amount of time (ms) restored after each keyup + // (makes rapid key presses slightly faster than holding down the key to navigate images) + keyRepeatKeyupBonus: PropTypes.number, + + //----------------------------- + // Image info + //----------------------------- + + // Image title + imageTitle: PropTypes.node, + + // Image caption + imageCaption: PropTypes.node, + + // Optional crossOrigin attribute + imageCrossOrigin: PropTypes.string, + + //----------------------------- + // Lightbox style + //----------------------------- + + // Set z-index style, etc., for the parent react-modal (format: https://github.com/reactjs/react-modal#styles ) + reactModalStyle: PropTypes.object, + + wrapperClassName: PropTypes.string, + + //----------------------------- + // Other + //----------------------------- + + // Array of custom toolbar buttons + toolbarButtons: PropTypes.arrayOf(PropTypes.node), + + // When true, clicks outside of the image close the lightbox + clickOutsideToClose: PropTypes.bool, + + // Set to false to disable zoom functionality and hide zoom buttons + enableZoom: PropTypes.bool, + + // Override props set on react-modal (https://github.com/reactjs/react-modal) + reactModalProps: PropTypes.shape({}), + + // Aria-labels + nextLabel: PropTypes.string, + prevLabel: PropTypes.string, + zoomInLabel: PropTypes.string, + zoomOutLabel: PropTypes.string, + closeLabel: PropTypes.string, + + imageLoadErrorMessage: PropTypes.node, +}; + +ReactImageLightbox.defaultProps = { + imageTitle: null, + imageCaption: null, + toolbarButtons: null, + reactModalProps: {}, + animationDisabled: false, + animationDuration: 300, + animationOnKeyInput: false, + clickOutsideToClose: true, + closeLabel: 'Close lightbox', + discourageDownloads: false, + enableZoom: true, + imageCrossOrigin: null, + keyRepeatKeyupBonus: 40, + keyRepeatLimit: 180, + mainSrcThumbnail: null, + nextLabel: 'Next image', + nextSrc: null, + nextSrcThumbnail: null, + onAfterOpen: () => {}, + onImageLoadError: () => {}, + onImageLoad: () => {}, + onMoveNextRequest: () => {}, + onMovePrevRequest: () => {}, + onClickMoveUp: null, + onClickMoveDown: null, + onClickDelete: null, + onClickDownload: null, + prevLabel: 'Previous image', + prevSrc: null, + prevSrcThumbnail: null, + reactModalStyle: {}, + wrapperClassName: '', + zoomInLabel: 'Zoom in', + zoomOutLabel: 'Zoom out', + imageLoadErrorMessage: 'This image failed to load', + onRotateImage: null, +}; + +export default ReactImageLightbox; diff --git a/frontend/src/react-image-lightbox/style.css b/frontend/src/react-image-lightbox/style.css new file mode 100644 index 00000000000..5c72defcbdc --- /dev/null +++ b/frontend/src/react-image-lightbox/style.css @@ -0,0 +1,466 @@ +@keyframes closeWindow { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.ril__outer { + background-color: rgba(0, 0, 0, 0.85); + outline: none; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + width: 100%; + height: 100%; + -ms-content-zooming: none; + -ms-user-select: none; + -ms-touch-select: none; + touch-action: none; +} + +.ril__outerClosing { + opacity: 0; +} + +.ril__inner { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.ril__image, +.ril__imagePrev, +.ril__imageNext { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + max-width: none; + -ms-content-zooming: none; + -ms-user-select: none; + -ms-touch-select: none; + touch-action: none; +} + +.ril__imageDiscourager { + background-repeat: no-repeat; + background-position: center; + background-size: contain; +} + +.ril__navButtons { + border: none; + position: absolute; + top: 0; + bottom: 0; + width: 20px; + height: 34px; + padding: 40px 30px; + margin: auto; + cursor: pointer; + opacity: 0.7; +} +.ril__navButtons:hover { + opacity: 1; +} +.ril__navButtons:active { + opacity: 0.7; +} + +.ril__outer .ril__navButtonPrev { + left: 0; + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNjcyMzkyNjY0MTMzIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjEyNDkwIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIj48cGF0aCBkPSJNNDEyIDUxNS4ybDM2MS42LTM2MS42YzIyLjQtMTkuMiAyMi40LTU0LjQgMC03Ni44cy01Ny42LTIyLjQtNzYuOCAwbC00MDYuNCA0MDBjLTIyLjQgMjIuNC0yMi40IDU3LjYgMCA3Ni44bDQwNi40IDQwMy4yYzIyLjQgMjIuNCA1Ny42IDIyLjQgNzYuOCAwczIyLjQtNTcuNiAwLTc2LjhsLTM2MS42LTM2NC44eiIgZmlsbD0iI2ZmZmZmZiIgcC1pZD0iMTI0OTEiPjwvcGF0aD48L3N2Zz4=) + no-repeat center; +} + +.ril__outer .ril__navButtonNext { + right: 0; + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNjcyMzkxOTM4MzIwIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjExOTg2IiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIj48cGF0aCBkPSJNNjQwIDUxNS4yTDI3OC40IDE1My42Yy0yMi40LTE5LjItMjIuNC01NC40IDAtNzYuOHM1Ny42LTIyLjQgNzYuOCAwbDQwNi40IDQwMGMyMi40IDIyLjQgMjIuNCA1Ny42IDAgNzYuOEwzNTUuMiA5NTYuOGMtMjIuNCAyMi40LTU3LjYgMjIuNC03Ni44IDBzLTIyLjQtNTcuNiAwLTc2LjhMNjQwIDUxNS4yeiIgZmlsbD0iI2ZmZmZmZiIgcC1pZD0iMTE5ODciPjwvcGF0aD48L3N2Zz4=) + no-repeat center; +} + +.ril__downloadBlocker { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: url('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); + background-size: cover; +} + +.ril__caption, +.ril__toolbar { + background-color: rgba(0, 0, 0, 0.5); + position: absolute; + left: 0; + right: 0; + display: flex; + justify-content: space-between; +} + +.ril__caption { + bottom: 0; + max-height: 150px; + overflow: auto; + justify-content: center; +} + +.ril__captionContent { + padding: 10px 20px; + color: #fff; + margin: 0 auto; +} + +.ril__toolbar { + top: 0; + height: 50px; +} + +.ril__toolbarSide { + height: 50px; + margin: 0; +} + +.ril__toolbarLeftSide { + padding-left: 20px; + padding-right: 0; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; +} + +.ril__toolbarRightSide { + padding-left: 0; + padding-right: 20px; + flex: 0 0 auto; +} + +.ril__toolbarItem { + display: inline-block; + line-height: 50px; + padding: 0; + color: #fff; + font-size: 120%; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ril__toolbarItemChild { + vertical-align: middle; +} + +.ril__builtinButton { + width: 40px; + height: 35px; + cursor: pointer; + border: none; + opacity: 0.7; +} + +.mobile-image-footer-choice .ril__builtinButton { + opacity: 1; +} + +.ril__builtinButton:hover { + opacity: 1; +} + +.ril__builtinButton:active { + outline: none; +} + +.ril-toolbar .ril__builtinButton { + width: 30px; +} + +.ril__builtinButtonDisabled { + cursor: default; + opacity: 0.5; +} + +.ril__builtinButtonDisabled:hover { + opacity: 0.5; +} + +/* ril-toolbar icons: size: 16px * 16px, color: #FFF */ +.ril__deleteButton { + background: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTZweCIgaGVpZ2h0PSJweCIgdmlld0JveD0iMCAwIDE2IDE2IiB2ZXJzaW9uPSIxLjEiPgo8ZyBpZD0ic3VyZmFjZTEiPgo8cGF0aCBzdHlsZT0iIHN0cm9rZTpub25lO2ZpbGwtcnVsZTpub256ZXJvO2ZpbGw6cmdiKDEwMCUsMTAwJSwxMDAlKTtmaWxsLW9wYWNpdHk6MTsiIGQ9Ik0gMTAuMTk5MjE5IDAuNSBDIDEwLjYwMTU2MiAwLjUgMTEgMC44NTE1NjIgMTEuMDUwNzgxIDEuMjUgTCAxMS4yNSAyLjYwMTU2MiBMIDE0Ljg1MTU2MiAyLjYwMTU2MiBDIDE1LjE5OTIxOSAyLjYwMTU2MiAxNS41IDIuODk4NDM4IDE1LjUgMy4xOTkyMTkgQyAxNS41IDMuNSAxNS4xOTkyMTkgMy44MDA3ODEgMTQuODUxNTYyIDMuODAwNzgxIEwgMTMuNjAxNTYyIDMuODAwNzgxIEwgMTMuNjAxNTYyIDEzLjgwMDc4MSBDIDEzLjYwMTU2MiAxNC4xOTkyMTkgMTMuMzk4NDM4IDE0LjY0ODQzOCAxMy4xMDE1NjIgMTUgQyAxMi43NSAxNS4zNTE1NjIgMTIuMzUxNTYyIDE1LjQ0OTIxOSAxMS44OTg0MzggMTUuNDQ5MjE5IEwgNC4xNDg0MzggMTUuNDQ5MjE5IEMgMy42OTkyMTkgMTUuNDQ5MjE5IDMuMjUgMTUuMjUgMi45NDkyMTkgMTUgQyAyLjYwMTU2MiAxNC42NDg0MzggMi40NDkyMTkgMTQuMjUgMi40NDkyMTkgMTMuODAwNzgxIEwgMi40NDkyMTkgMy44MDA3ODEgTCAxLjE0ODQzOCAzLjgwMDc4MSBDIDAuODAwNzgxIDMuODUxNTYyIDAuNSAzLjU1MDc4MSAwLjUgMy4yNSBDIDAuNSAyLjk0OTIxOSAwLjgwMDc4MSAyLjY0ODQzOCAxLjE0ODQzOCAyLjY0ODQzOCBMIDQuNjk5MjE5IDIuNjQ4NDM4IEwgNC42OTkyMTkgMi41NTA3ODEgTCA0Ljg5ODQzOCAxLjE5OTIxOSBDIDQuOTQ5MjE5IDAuODUxNTYyIDUuMzAwNzgxIDAuNSA1Ljc1IDAuNSBaIE0gOS42NDg0MzggNi4xMDE1NjIgQyA5LjI1IDYuMTAxNTYyIDguOTQ5MjE5IDYuMzk4NDM4IDguOTQ5MjE5IDYuODAwNzgxIEwgOC45NDkyMTkgMTIgQyA4Ljk0OTIxOSAxMi4zOTg0MzggOS4yNSAxMi42OTkyMTkgOS42NDg0MzggMTIuNjk5MjE5IEMgMTAuMDUwNzgxIDEyLjY5OTIxOSAxMC4zNTE1NjIgMTIuMzk4NDM4IDEwLjM1MTU2MiAxMiBMIDEwLjM1MTU2MiA2Ljg1MTU2MiBDIDEwLjM1MTU2MiA2LjQ0OTIxOSAxMC4wNTA3ODEgNi4xMDE1NjIgOS42NDg0MzggNi4xMDE1NjIgWiBNIDYuMzUxNTYyIDYuMTAxNTYyIEMgNS45NDkyMTkgNi4xMDE1NjIgNS42NDg0MzggNi4zOTg0MzggNS42NDg0MzggNi44MDA3ODEgTCA1LjY0ODQzOCAxMiBDIDUuNjQ4NDM4IDEyLjM5ODQzOCA1Ljk0OTIxOSAxMi42OTkyMTkgNi4zNTE1NjIgMTIuNjk5MjE5IEMgNi43NSAxMi42OTkyMTkgNy4wNTA3ODEgMTIuMzk4NDM4IDcuMDUwNzgxIDEyIEwgNy4wNTA3ODEgNi44NTE1NjIgQyA3LjA1MDc4MSA2LjQ0OTIxOSA2Ljc1IDYuMTAxNTYyIDYuMzUxNTYyIDYuMTAxNTYyIFogTSA2LjM1MTU2MiA2LjEwMTU2MiAiLz4KPC9nPgo8L3N2Zz4K") + no-repeat center; +} + +.ril__downloadButton { + background: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTZweCIgaGVpZ2h0PSJweCIgdmlld0JveD0iMCAwIDE2IDE2IiB2ZXJzaW9uPSIxLjEiPgo8ZyBpZD0ic3VyZmFjZTEiPgo8cGF0aCBzdHlsZT0iIHN0cm9rZTpub25lO2ZpbGwtcnVsZTpub256ZXJvO2ZpbGw6cmdiKDEwMCUsMTAwJSwxMDAlKTtmaWxsLW9wYWNpdHk6MTsiIGQ9Ik0gOS4xMDE1NjIgOC44OTg0MzggTCA5LjEwMTU2MiAxLjYwMTU2MiBDIDkuMTAxNTYyIDAuOTQ5MjE5IDguNjAxNTYyIDAuNSA4IDAuNSBDIDcuMzk4NDM4IDAuNSA2Ljg5ODQzOCAwLjk0OTIxOSA2Ljg5ODQzOCAxLjYwMTU2MiBMIDYuODk4NDM4IDguODk4NDM4IEwgNC4xOTkyMTkgNi4xOTkyMTkgQyAzLjgwMDc4MSA1LjgwMDc4MSAzLjEwMTU2MiA1LjgwMDc4MSAyLjY5OTIxOSA2LjI1IEMgMi4zMDA3ODEgNi42NDg0MzggMi4zMDA3ODEgNy4zNTE1NjIgMi42OTkyMTkgNy43NSBMIDcuMjUgMTIuMzAwNzgxIEMgNy42NDg0MzggMTIuNjk5MjE5IDguMzUxNTYyIDEyLjY5OTIxOSA4Ljc1IDEyLjMwMDc4MSBMIDEzLjMwMDc4MSA3Ljc1IEMgMTMuNjk5MjE5IDcuMzUxNTYyIDEzLjY5OTIxOSA2LjY0ODQzOCAxMy4zNTE1NjIgNi4yNSBDIDEyLjk0OTIxOSA1Ljg1MTU2MiAxMi4yNSA1Ljg1MTU2MiAxMS44NTE1NjIgNi4xOTkyMTkgQyAxMS44NTE1NjIgNi4xOTkyMTkgMTEuODUxNTYyIDYuMTk5MjE5IDExLjgwMDc4MSA2LjI1IFogTSAxLjYwMTU2MiAxMy4zNTE1NjIgTCAxNC40NDkyMTkgMTMuMzUxNTYyIEMgMTUuMDUwNzgxIDEzLjM1MTU2MiAxNS41NTA3ODEgMTMuODAwNzgxIDE1LjU1MDc4MSAxNC40NDkyMTkgQyAxNS41NTA3ODEgMTUuMTAxNTYyIDE1LjA1MDc4MSAxNS41IDE0LjM5ODQzOCAxNS41IEwgMS42MDE1NjIgMTUuNSBDIDAuOTQ5MjE5IDE1LjUgMC41IDE1LjA1MDc4MSAwLjUgMTQuMzk4NDM4IEMgMC41IDEzLjg1MTU2MiAwLjk0OTIxOSAxMy4zNTE1NjIgMS42MDE1NjIgMTMuMzUxNTYyIFogTSAxLjYwMTU2MiAxMy4zNTE1NjIgIi8+CjwvZz4KPC9zdmc+Cg==") + no-repeat center; +} + +.ril__downMoveButton { + background: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTZweCIgaGVpZ2h0PSJweCIgdmlld0JveD0iMCAwIDE2IDE2IiB2ZXJzaW9uPSIxLjEiPgo8ZyBpZD0ic3VyZmFjZTEiPgo8cGF0aCBzdHlsZT0iIHN0cm9rZTpub25lO2ZpbGwtcnVsZTpub256ZXJvO2ZpbGw6cmdiKDEwMCUsMTAwJSwxMDAlKTtmaWxsLW9wYWNpdHk6MTsiIGQ9Ik0gOCAxNS41IEMgMy44NTE1NjIgMTUuNSAwLjUgMTIuMTQ4NDM4IDAuNSA4IEMgMC41IDMuODUxNTYyIDMuODUxNTYyIDAuNSA4IDAuNSBDIDEyLjE0ODQzOCAwLjUgMTUuNSAzLjg1MTU2MiAxNS41IDggQyAxNS41IDEyLjE0ODQzOCAxMi4xNDg0MzggMTUuNSA4IDE1LjUgWiBNIDggMi4xNDg0MzggQyA0LjgwMDc4MSAyLjE0ODQzOCAyLjE0ODQzOCA0LjgwMDc4MSAyLjE0ODQzOCA4IEMgMi4xNDg0MzggMTEuMTk5MjE5IDQuODAwNzgxIDEzLjg1MTU2MiA4IDEzLjg1MTU2MiBDIDExLjE5OTIxOSAxMy44NTE1NjIgMTMuODUxNTYyIDExLjE5OTIxOSAxMy44NTE1NjIgOCBDIDEzLjg1MTU2MiA0LjgwMDc4MSAxMS4xOTkyMTkgMi4xNDg0MzggOCAyLjE0ODQzOCBaIE0gOCAyLjE0ODQzOCAiLz4KPHBhdGggc3R5bGU9IiBzdHJva2U6bm9uZTtmaWxsLXJ1bGU6bm9uemVybztmaWxsOnJnYigxMDAlLDEwMCUsMTAwJSk7ZmlsbC1vcGFjaXR5OjE7IiBkPSJNIDguNTUwNzgxIDEwIEMgOC4xOTkyMTkgMTAuMzAwNzgxIDcuNjk5MjE5IDEwLjMwMDc4MSA3LjM1MTU2MiAxMCBMIDQuNDQ5MjE5IDcuMTAxNTYyIEMgNC4xOTkyMTkgNi43NSA0LjE5OTIxOSA2LjI1IDQuNSA1Ljk0OTIxOSBDIDQuODAwNzgxIDUuNjQ4NDM4IDUuMzAwNzgxIDUuNjAxNTYyIDUuNjQ4NDM4IDUuODk4NDM4IEwgNy45NDkyMTkgOC4xOTkyMTkgTCAxMC4yNSA1Ljg5ODQzOCBDIDEwLjQ0OTIxOSA1LjY0ODQzOCAxMC44MDA3ODEgNS41NTA3ODEgMTEuMTAxNTYyIDUuNjAxNTYyIEMgMTEuMzk4NDM4IDUuNjQ4NDM4IDExLjY0ODQzOCA1Ljg5ODQzOCAxMS43NSA2LjI1IEMgMTEuODAwNzgxIDYuNTUwNzgxIDExLjY5OTIxOSA2Ljg5ODQzOCAxMS40NDkyMTkgNy4xMDE1NjIgWiBNIDguNTUwNzgxIDEwICIvPgo8L2c+Cjwvc3ZnPgo=") + no-repeat center; +} + +.ril__upMoveButton { + background: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTZweCIgaGVpZ2h0PSJweCIgdmlld0JveD0iMCAwIDE2IDE2IiB2ZXJzaW9uPSIxLjEiPgo8ZyBpZD0ic3VyZmFjZTEiPgo8cGF0aCBzdHlsZT0iIHN0cm9rZTpub25lO2ZpbGwtcnVsZTpub256ZXJvO2ZpbGw6cmdiKDEwMCUsMTAwJSwxMDAlKTtmaWxsLW9wYWNpdHk6MTsiIGQ9Ik0gOCAwLjUgQyAzLjg1MTU2MiAwLjUgMC41IDMuODUxNTYyIDAuNSA4IEMgMC41IDEyLjE0ODQzOCAzLjg1MTU2MiAxNS41IDggMTUuNSBDIDEyLjE0ODQzOCAxNS41IDE1LjUgMTIuMTQ4NDM4IDE1LjUgOCBDIDE1LjUgMy44NTE1NjIgMTIuMTQ4NDM4IDAuNSA4IDAuNSBaIE0gOCAxMy44NTE1NjIgQyA0LjgwMDc4MSAxMy44NTE1NjIgMi4xNDg0MzggMTEuMTk5MjE5IDIuMTQ4NDM4IDggQyAyLjE0ODQzOCA0LjgwMDc4MSA0LjgwMDc4MSAyLjE0ODQzOCA4IDIuMTQ4NDM4IEMgMTEuMTk5MjE5IDIuMTQ4NDM4IDEzLjg1MTU2MiA0LjgwMDc4MSAxMy44NTE1NjIgOCBDIDEzLjg1MTU2MiAxMS4xOTkyMTkgMTEuMTk5MjE5IDEzLjg1MTU2MiA4IDEzLjg1MTU2MiBaIE0gOCAxMy44NTE1NjIgIi8+CjxwYXRoIHN0eWxlPSIgc3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOm5vbnplcm87ZmlsbDpyZ2IoMTAwJSwxMDAlLDEwMCUpO2ZpbGwtb3BhY2l0eToxOyIgZD0iTSA4LjU1MDc4MSA2IEMgOC4xOTkyMTkgNS42OTkyMTkgNy42OTkyMTkgNS42OTkyMTkgNy4zNTE1NjIgNiBMIDQuNDQ5MjE5IDguODk4NDM4IEMgNC4xOTkyMTkgOS4yNSA0LjE5OTIxOSA5Ljc1IDQuNSAxMC4wNTA3ODEgQyA0LjgwMDc4MSAxMC4zNTE1NjIgNS4zMDA3ODEgMTAuMzk4NDM4IDUuNjQ4NDM4IDEwLjEwMTU2MiBMIDcuOTQ5MjE5IDcuODAwNzgxIEwgMTAuMjUgMTAuMTAxNTYyIEMgMTAuNDQ5MjE5IDEwLjM1MTU2MiAxMC44MDA3ODEgMTAuNDQ5MjE5IDExLjEwMTU2MiAxMC4zOTg0MzggQyAxMS4zOTg0MzggMTAuMzUxNTYyIDExLjY0ODQzOCAxMC4xMDE1NjIgMTEuNzUgOS43NSBDIDExLjgwMDc4MSA5LjQ0OTIxOSAxMS42OTkyMTkgOS4xMDE1NjIgMTEuNDQ5MjE5IDguODk4NDM4IFogTSA4LjU1MDc4MSA2ICIvPgo8L2c+Cjwvc3ZnPgo=") + no-repeat center; +} + +.ril__ocrButton { + background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB0PSIxNzI0MzA1Mjg1MDgxIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiBwLWlkPSI0NzE0IiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCI+PHBhdGggZD0iTTEwMS41NCAzNzMuNDNjLTIwLjg3IDAtMzcuNzgtMTYuOTItMzcuNzgtMzcuNzh2LTgzLjQyYzAtMTAzLjkxIDg0LjU0LTE4OC40NSAxODguNDUtMTg4LjQ1aDgzLjQyYzIwLjg3IDAgMzcuNzggMTYuOTIgMzcuNzggMzcuNzhzLTE2LjkyIDM3Ljc4LTM3Ljc4IDM3Ljc4aC04My40MmMtNjIuMjUgMC0xMTIuODkgNTAuNjQtMTEyLjg5IDExMi44OXY4My40MmMwIDIwLjg3LTE2LjkyIDM3Ljc4LTM3Ljc4IDM3Ljc4ek03NzEuOTEgOTYwLjIxaC04My44N2MtMjAuODcgMC0zNy43OC0xNi45MS0zNy43OC0zNy43OHMxNi45Mi0zNy43OCAzNy43OC0zNy43OGg4My44N2M2Mi4yNSAwIDExMi44OS01MC42NCAxMTIuODktMTEyLjg5di04My4zN2MwLTIwLjg3IDE2LjkxLTM3Ljc4IDM3Ljc4LTM3Ljc4czM3Ljc4IDE2LjkyIDM3Ljc4IDM3Ljc4djgzLjM3Yy0wLjAxIDEwMy45MS04NC41NCAxODguNDUtMTg4LjQ1IDE4OC40NXpNMzM1LjQ3IDk2MC4yMUgyNTIuMWMtMTAzLjkxIDAtMTg4LjQ1LTg0LjU0LTE4OC40NS0xODguNDV2LTgzLjM3YzAtMjAuODcgMTYuOTItMzcuNzggMzcuNzgtMzcuNzhzMzcuNzggMTYuOTIgMzcuNzggMzcuNzh2ODMuMzdjMCA2Mi4yNSA1MC42NCAxMTIuODkgMTEyLjg5IDExMi44OWg4My4zN2MyMC44NyAwIDM3Ljc4IDE2LjkxIDM3Ljc4IDM3Ljc4cy0xNi45MiAzNy43OC0zNy43OCAzNy43OHpNOTIyLjU3IDM3My42MWMtMjAuODcgMC0zNy43OC0xNi45Mi0zNy43OC0zNy43OHYtODMuMzdjMC02Mi4yNS01MC42NC0xMTIuODktMTEyLjg5LTExMi44OWgtODMuODdjLTIwLjg3IDAtMzcuNzgtMTYuOTItMzcuNzgtMzcuNzhzMTYuOTItMzcuNzggMzcuNzgtMzcuNzhoODMuODdjMTAzLjkxIDAgMTg4LjQ1IDg0LjU0IDE4OC40NSAxODguNDV2ODMuMzdjMCAyMC44Ni0xNi45MSAzNy43OC0zNy43OCAzNy43OHoiIGZpbGw9IiNmZmYiIHAtaWQ9IjQ3MTUiLz48L3N2Zz4=") + no-repeat center; +} + +.ril__closeButton { + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNjcyMzk1NTkwMTc0IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjEzNjUyIiB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj48cGF0aCBkPSJNNjExLjIgNTEybDMwMC44LTMwMC44YzI4LjgtMjguOCAyOC44LTczLjYgMC05OS4ycy03My42LTI4LjgtOTkuMiAwTDUxMiA0MTIuOCAyMTEuMiAxMTJjLTI4LjgtMjguOC03My42LTI4LjgtOTkuMiAwcy0yOC44IDczLjYgMCA5OS4ybDMwMC44IDMwMC44LTMwMC44IDMwMC44Yy0yOC44IDI4LjgtMjguOCA3My42IDAgOTkuMiAyOC44IDI1LjYgNzMuNiAyOC44IDk5LjIgMGwzMDAuOC0zMDAuOCAzMDAuOCAzMDAuOGMyOC44IDI4LjggNzMuNiAyOC44IDk5LjIgMCAyOC44LTI4LjggMjguOC03My42IDAtOTkuMkw2MTEuMiA1MTJ6IiBmaWxsPSIjZmZmZmZmIiBwLWlkPSIxMzY1MyI+PC9wYXRoPjwvc3ZnPg==) + no-repeat center; +} + +/* ril-caption icons: size: 20px * 20px, color: #FFF */ +.ril__zoomInButton { + background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEgMTlsNi02Ii8+PHBhdGggZD0iTTkgOGg2Ii8+PHBhdGggZD0iTTEyIDV2NiIvPjwvZz48Y2lyY2xlIGN4PSIxMiIgY3k9IjgiIHI9IjciIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+') + no-repeat center; +} + +.ril__zoomOutButton { + background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEgMTlsNi02Ii8+PHBhdGggZD0iTTkgOGg2Ii8+PC9nPjxjaXJjbGUgY3g9IjEyIiBjeT0iOCIgcj0iNyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4=') + no-repeat center; +} + +.ril__rotateButton { + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNjcyMzkyNzI4MTMyIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjEyODc1IiB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj48cGF0aCBkPSJNMTI0LjggMTYwbDU0LjQgNTEuMkMzNDUuNiAyOC44IDYzMC40IDE2IDgxMi44IDE4Mi40czE5NS4yIDQ1MS4yIDI4LjggNjMzLjYtNDUxLjIgMTk1LjItNjMzLjYgMjguOGMtMzguNC0zNS4yLTczLjYtNzYuOC05Ni0xMjQuOC02LjQtOS42LTkuNi0xNi05LjYtMjguOCAwLTMyIDI1LjYtNTQuNCA1NC40LTU0LjQgMTkuMiAwIDM4LjQgOS42IDU0LjQgMzIgODMuMiAxNjYuNCAyODggMjMwLjQgNDU0LjQgMTQ0czIzMC40LTI5MS4yIDE0NC00NTcuNi0yODgtMjMwLjQtNDU0LjQtMTQ0Yy0zNS4yIDE5LjItNjcuMiA0MS42LTk2IDczLjZsNTEuMiA0NC44YzEyLjggMTIuOCAxNiAzOC40IDMuMiA1MS4yLTYuNCA2LjQtMTYgOS42LTI1LjYgOS42bC0xODIuNCA5LjZjLTE5LjIgMC0zNS4yLTEyLjgtMzguNC0zMkw2NCAxODguOGMtMy4yLTE5LjIgMTIuOC0zNS4yIDMyLTM4LjQgMTIuOC0zLjIgMjIuNCAzLjIgMjguOCA5LjZ6IiBmaWxsPSIjZmZmZmZmIiBwLWlkPSIxMjg3NiI+PC9wYXRoPjwvc3ZnPg==) + no-repeat center; +} + +.ril__outerAnimating { + animation-name: closeWindow; +} + +/* .ril_rotateImageButton { + transform: rotateY(180deg); +} */ + +@keyframes pointFade { + 0%, + 19.999%, + 100% { + opacity: 0; + } + 20% { + opacity: 1; + } +} + +.ril__loadingCircle { + width: 60px; + height: 60px; + position: relative; +} + +.ril__loadingCirclePoint { + width: 100%; + height: 100%; + position: absolute; + left: 0; + top: 0; +} +.ril__loadingCirclePoint::before { + content: ''; + display: block; + margin: 0 auto; + width: 11%; + height: 30%; + background-color: #fff; + border-radius: 30%; + animation: pointFade 800ms infinite ease-in-out both; +} +.ril__loadingCirclePoint:nth-of-type(1) { + transform: rotate(0deg); +} +.ril__loadingCirclePoint:nth-of-type(7) { + transform: rotate(180deg); +} +.ril__loadingCirclePoint:nth-of-type(1)::before, +.ril__loadingCirclePoint:nth-of-type(7)::before { + animation-delay: -800ms; +} +.ril__loadingCirclePoint:nth-of-type(2) { + transform: rotate(30deg); +} +.ril__loadingCirclePoint:nth-of-type(8) { + transform: rotate(210deg); +} +.ril__loadingCirclePoint:nth-of-type(2)::before, +.ril__loadingCirclePoint:nth-of-type(8)::before { + animation-delay: -666ms; +} +.ril__loadingCirclePoint:nth-of-type(3) { + transform: rotate(60deg); +} +.ril__loadingCirclePoint:nth-of-type(9) { + transform: rotate(240deg); +} +.ril__loadingCirclePoint:nth-of-type(3)::before, +.ril__loadingCirclePoint:nth-of-type(9)::before { + animation-delay: -533ms; +} +.ril__loadingCirclePoint:nth-of-type(4) { + transform: rotate(90deg); +} +.ril__loadingCirclePoint:nth-of-type(10) { + transform: rotate(270deg); +} +.ril__loadingCirclePoint:nth-of-type(4)::before, +.ril__loadingCirclePoint:nth-of-type(10)::before { + animation-delay: -400ms; +} +.ril__loadingCirclePoint:nth-of-type(5) { + transform: rotate(120deg); +} +.ril__loadingCirclePoint:nth-of-type(11) { + transform: rotate(300deg); +} +.ril__loadingCirclePoint:nth-of-type(5)::before, +.ril__loadingCirclePoint:nth-of-type(11)::before { + animation-delay: -266ms; +} +.ril__loadingCirclePoint:nth-of-type(6) { + transform: rotate(150deg); +} +.ril__loadingCirclePoint:nth-of-type(12) { + transform: rotate(330deg); +} +.ril__loadingCirclePoint:nth-of-type(6)::before, +.ril__loadingCirclePoint:nth-of-type(12)::before { + animation-delay: -133ms; +} +.ril__loadingCirclePoint:nth-of-type(7) { + transform: rotate(180deg); +} +.ril__loadingCirclePoint:nth-of-type(13) { + transform: rotate(360deg); +} +.ril__loadingCirclePoint:nth-of-type(7)::before, +.ril__loadingCirclePoint:nth-of-type(13)::before { + animation-delay: 0ms; +} + +.ril__loadingContainer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} +.ril__imagePrev .ril__loadingContainer, +.ril__imageNext .ril__loadingContainer { + display: none; +} + +.ril__errorContainer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; +} +.ril__imagePrev .ril__errorContainer, +.ril__imageNext .ril__errorContainer { + display: none; +} + +.ril__loadingContainer__icon { + color: #fff; + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); +} + +/* mobile */ +.mobile-image-previewer .ril-toolbar .ril__toolbarRightSide { + padding-right: 14px; +} + +.image-footer-choice.mobile-image-footer-choice { + height: 100px; + background-color: #000; + z-index: 1052; + padding: 0; +} + +.mobile-image-footer-choice { + height: 30px; + width: 100%; + border-radius: 2px; + border-top-right-radius: 0; + border-top-left-radius: 0; + bottom: 0; + position: absolute; + color: #fff; + background: rgba(0, 0, 0, 0.6); + display: flex; + padding:0 3px; + justify-content: space-between; + align-items: center; +} + +.mobile-image-footer-choice .image-footer-icon { + display: flex; + justify-content: center; + align-items: center +} + +.mobile-image-footer-choice .image-footer-icon span { + cursor: pointer; + display: flex; + width: 24px; + height: 24px; + justify-content: center; + align-items: center; + color: #dbdbdb; +} + +.image-footer-choice.mobile-image-footer-choice .image-footer-icon { + width: 100%; + justify-content: space-between; + flex-direction: row-reverse; + margin: 0 20px; +} + +.mobile-image-footer-choice .image-footer-icon .image-footer-choice-item { + height: 40px; + width: 40px; + border-radius: 5px; + background-color: #333; +} + +.ril__outer .ril-toolbar .ril-close { + width: 30px; + height: 53px; +} + +.ril__outer .ril__toolbarItem { + height: 50px; +} + +.ril__outer .ril__toolbarItem button { + opacity: 0.7; +} + +.ril__outer .ril__toolbarItem button:hover { + opacity: 1; +} diff --git a/frontend/src/react-image-lightbox/util.js b/frontend/src/react-image-lightbox/util.js new file mode 100644 index 00000000000..02e4633ebb2 --- /dev/null +++ b/frontend/src/react-image-lightbox/util.js @@ -0,0 +1,49 @@ +/** + * Placeholder for future translate functionality + */ +export function translate(str, replaceStrings = null) { + if (!str) { + return ''; + } + + let translated = str; + if (replaceStrings) { + Object.keys(replaceStrings).forEach(placeholder => { + translated = translated.replace(placeholder, replaceStrings[placeholder]); + }); + } + + return translated; +} + +export function getWindowWidth() { + return typeof global.window !== 'undefined' ? global.window.innerWidth : 0; +} + +export function getWindowHeight() { + return typeof global.window !== 'undefined' ? global.window.innerHeight : 0; +} + +export const isMobile = (typeof (window) !== 'undefined') && (window.innerWidth < 768 || navigator.userAgent.toLowerCase().match(/(ipod|ipad|iphone|android|coolpad|mmp|smartphone|midp|wap|xoom|symbian|j2me|blackberry|wince)/i) != null); + +// Get the highest window context that isn't cross-origin +// (When in an iframe) +export function getHighestSafeWindowContext(self = global.window.self) { + const { referrer } = self.document; + // If we reached the top level, return self + if (self === global.window.top || !referrer) { + return self; + } + + const getOrigin = href => href.match(/(.*\/\/.*?)(\/|$)/)[1]; + + // If parent is the same origin, we can move up one context + // Reference: https://stackoverflow.com/a/21965342/1601953 + if (getOrigin(self.location.href) === getOrigin(referrer)) { + return getHighestSafeWindowContext(self.parent); + } + + // If a different origin, we consider the current level + // as the top reachable one + return self; +} diff --git a/frontend/src/utils/ai-api.js b/frontend/src/utils/ai-api.js new file mode 100644 index 00000000000..75ac850a926 --- /dev/null +++ b/frontend/src/utils/ai-api.js @@ -0,0 +1,62 @@ +import cookie from 'react-cookies'; +import axios from 'axios'; +import { siteRoot } from './constants'; + +class AIAPI { + + init({ server, username, password, token }) { + this.server = server; + this.username = username; + this.password = password; + this.token = token; + if (this.token && this.server) { + this.req = axios.create({ + baseURL: this.server, + headers: { 'Authorization': 'Token ' + this.token }, + }); + } + return this; + } + + initForSeahubUsage({ siteRoot, xcsrfHeaders }) { + if (siteRoot && siteRoot.charAt(siteRoot.length - 1) === '/') { + var server = siteRoot.substring(0, siteRoot.length - 1); + this.server = server; + } else { + this.server = siteRoot; + } + + this.req = axios.create({ + headers: { + 'X-CSRFToken': xcsrfHeaders, + } + }); + return this; + } + + _sendPostRequest(url, form) { + if (form.getHeaders) { + return this.req.post(url, form, { + headers: form.getHeaders() + }); + } else { + return this.req.post(url, form); + } + } + + ocrRecognition = (repo_id, path) => { + const url = this.server + '/api/v2.1/ai/ocr/'; + const data = { + repo_id, + path, + }; + return this._sendPostRequest(url, data); + }; + +} + +let _AIAPI = new AIAPI(); +let xcsrfHeaders = cookie.load('sfcsrftoken'); +_AIAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders }); + +export default _AIAPI;