From e50c15b0d5a09d769b2764fa6aae78f7957bae4e Mon Sep 17 00:00:00 2001 From: Peter Pan Date: Thu, 24 Sep 2020 14:21:07 +0800 Subject: [PATCH] sample pages improvement (close #757, #758) (#829) * chore: set server address * chore: add bce-python-sdk to requirements * fix: limit max length of runs in chart tooltip table * feat: add keyboard shortcuts in sample page * feat: add image preview in image sample page --- .../packages/core/public/icons/chevron-up.svg | 3 + frontend/packages/core/public/icons/close.svg | 4 +- .../core/public/icons/minus-circle.svg | 3 + .../core/public/icons/plus-circle.svg | 3 + .../core/public/icons/restore-circle.svg | 3 + .../core/public/locales/en/sample.json | 1 + .../core/public/locales/zh/sample.json | 1 + frontend/packages/core/src/App.tsx | 4 +- .../packages/core/src/components/Audio.tsx | 355 +++++++++--------- .../GraphPage/ModelPropertiesDialog.tsx | 8 +- .../packages/core/src/components/Image.tsx | 34 +- .../src/components/SamplePage/AudioChart.tsx | 15 +- .../src/components/SamplePage/ImageChart.tsx | 18 +- .../components/SamplePage/ImagePreviewer.tsx | 347 +++++++++++++++++ .../src/components/SamplePage/SampleChart.tsx | 124 +++++- .../src/components/SamplePage/StepSlider.tsx | 60 ++- .../packages/core/src/components/Select.tsx | 5 +- frontend/packages/core/src/utils/style.ts | 17 +- frontend/packages/core/src/utils/theme.ts | 4 + 19 files changed, 751 insertions(+), 258 deletions(-) create mode 100644 frontend/packages/core/public/icons/chevron-up.svg mode change 100755 => 100644 frontend/packages/core/public/icons/close.svg create mode 100644 frontend/packages/core/public/icons/minus-circle.svg create mode 100644 frontend/packages/core/public/icons/plus-circle.svg create mode 100644 frontend/packages/core/public/icons/restore-circle.svg create mode 100644 frontend/packages/core/src/components/SamplePage/ImagePreviewer.tsx diff --git a/frontend/packages/core/public/icons/chevron-up.svg b/frontend/packages/core/public/icons/chevron-up.svg new file mode 100644 index 00000000..587c8ed8 --- /dev/null +++ b/frontend/packages/core/public/icons/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/core/public/icons/close.svg b/frontend/packages/core/public/icons/close.svg old mode 100755 new mode 100644 index 8bf5dafb..964145ee --- a/frontend/packages/core/public/icons/close.svg +++ b/frontend/packages/core/public/icons/close.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/packages/core/public/icons/minus-circle.svg b/frontend/packages/core/public/icons/minus-circle.svg new file mode 100644 index 00000000..190b4d9f --- /dev/null +++ b/frontend/packages/core/public/icons/minus-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/core/public/icons/plus-circle.svg b/frontend/packages/core/public/icons/plus-circle.svg new file mode 100644 index 00000000..2151a117 --- /dev/null +++ b/frontend/packages/core/public/icons/plus-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/core/public/icons/restore-circle.svg b/frontend/packages/core/public/icons/restore-circle.svg new file mode 100644 index 00000000..7b582d09 --- /dev/null +++ b/frontend/packages/core/public/icons/restore-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/core/public/locales/en/sample.json b/frontend/packages/core/public/locales/en/sample.json index 78f7bc89..81812a35 100644 --- a/frontend/packages/core/public/locales/en/sample.json +++ b/frontend/packages/core/public/locales/en/sample.json @@ -9,5 +9,6 @@ "sample-rate": "Sample Rate", "show-actual-size": "Show Actual Image Size", "step": "Step", + "step-tip": "You can change step by pressing up & donw on your keyboard", "text": "text" } diff --git a/frontend/packages/core/public/locales/zh/sample.json b/frontend/packages/core/public/locales/zh/sample.json index bab20f3b..c5b2c874 100644 --- a/frontend/packages/core/public/locales/zh/sample.json +++ b/frontend/packages/core/public/locales/zh/sample.json @@ -9,5 +9,6 @@ "sample-rate": "采样率", "show-actual-size": "按真实大小展示", "step": "Step", + "step-tip": "您还可以通过键盘 ↑ ↓ 键,快速调节step哦~", "text": "文本" } diff --git a/frontend/packages/core/src/App.tsx b/frontend/packages/core/src/App.tsx index 983065c0..9eb238ef 100644 --- a/frontend/packages/core/src/App.tsx +++ b/frontend/packages/core/src/App.tsx @@ -1,7 +1,7 @@ import React, {FunctionComponent, Suspense, useCallback, useEffect, useMemo, useState} from 'react'; import {Redirect, Route, BrowserRouter as Router, Switch, useLocation} from 'react-router-dom'; import {THEME, matchMedia} from '~/utils/theme'; -import {headerHeight, position, size} from '~/utils/style'; +import {headerHeight, position, size, zIndexes} from '~/utils/style'; import BodyLoading from '~/components/BodyLoading'; import ErrorBoundary from '~/components/ErrorBoundary'; @@ -27,7 +27,7 @@ const Main = styled.main` `; const Header = styled.header` - z-index: 10000; + z-index: ${zIndexes.header}; ${size(headerHeight, '100%')} ${position('fixed', 0, 0, null, 0)} diff --git a/frontend/packages/core/src/components/Audio.tsx b/frontend/packages/core/src/components/Audio.tsx index 899829a6..c5d44597 100644 --- a/frontend/packages/core/src/components/Audio.tsx +++ b/frontend/packages/core/src/components/Audio.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {FunctionComponent, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {WithStyled, primaryColor, rem, size, transitionProps} from '~/utils/style'; import {AudioPlayer} from '~/utils/audio'; @@ -9,12 +9,9 @@ import RangeSlider from '~/components/RangeSlider'; import Slider from 'react-rangeslider'; import SyncLoader from 'react-spinners/SyncLoader'; import Tippy from '@tippyjs/react'; -import {fetcher} from '~/utils/fetch'; import mime from 'mime-types'; import moment from 'moment'; -import {saveAs} from 'file-saver'; import styled from 'styled-components'; -import useRequest from '~/hooks/useRequest'; import {useTranslation} from 'react-i18next'; const Container = styled.div` @@ -130,202 +127,192 @@ function formatDuration(seconds: number) { ); } -export type AudioRef = { - save(filename: string): void; -}; - export type AudioProps = { audioContext?: AudioContext; - src?: string; - cache?: number; + data?: BlobResponse; + loading?: boolean; + error?: Error; onLoading?: () => unknown; onLoad?: (audio: {sampleRate: number; duration: number}) => unknown; }; -const Audio = React.forwardRef( - ({audioContext, src, cache, onLoading, onLoad, className}, ref) => { - const {t} = useTranslation('common'); - - const {data, error, loading} = useRequest(src ?? null, fetcher, { - dedupingInterval: cache ?? 2000 - }); - - useImperativeHandle(ref, () => ({ - save: (filename: string) => { - if (data) { - const ext = data.type ? mime.extension(data.type) : null; - saveAs(data.data, filename.replace(/[/\\?%*:|"<>]/g, '_') + (ext ? `.${ext}` : '')); - } - } - })); - - const timer = useRef(null); - const player = useRef(null); - const [sliderValue, setSliderValue] = useState(0); - const [offset, setOffset] = useState(0); - const [duration, setDuration] = useState('00:00'); - const [decoding, setDecoding] = useState(false); - const [playing, setPlaying] = useState(false); - const [volumn, setVolumn] = useState(100); - const [playAfterSeek, setPlayAfterSeek] = useState(false); - - const play = useCallback(() => player.current?.play(), []); - const pause = useCallback(() => player.current?.pause(), []); - const toggle = useCallback(() => player.current?.toggle(), []); - const change = useCallback((value: number) => { - if (!player.current) { - return; - } - setOffset((value / SLIDER_MAX) * player.current.duration); - setSliderValue(value); - }, []); - const startSeek = useCallback(() => { - setPlayAfterSeek(playing); - pause(); - }, [playing, pause]); - const stopSeek = useCallback(() => { - if (!player.current) { - return; - } - player.current.seek(offset); - if (playAfterSeek && offset < player.current.duration) { - play(); - } - }, [play, offset, playAfterSeek]); - const toggleMute = useCallback(() => { - if (player.current) { - player.current.toggleMute(); - setVolumn(player.current.volumn); - } - }, []); - - const tick = useCallback(() => { - if (player.current) { - const current = player.current.current; - setOffset(current); - setSliderValue(Math.floor((current / player.current.duration) * SLIDER_MAX)); - } - }, []); - const startTimer = useCallback(() => { - tick(); - timer.current = (window.setInterval(tick, 250) as unknown) as number; - }, [tick]); - const stopTimer = useCallback(() => { - if (player.current) { - if (player.current.current >= player.current.duration) { - tick(); - } - } - if (timer.current) { - window.clearInterval(timer.current); - timer.current = null; - } - }, [tick]); - - useEffect(() => { - if (player.current) { - player.current.volumn = volumn; - } - }, [volumn]); +const Audio: FunctionComponent = ({ + audioContext, + data, + loading, + error, + onLoading, + onLoad, + className +}) => { + const {t} = useTranslation('common'); + + const timer = useRef(null); + const player = useRef(null); + const [sliderValue, setSliderValue] = useState(0); + const [offset, setOffset] = useState(0); + const [duration, setDuration] = useState('00:00'); + const [decoding, setDecoding] = useState(false); + const [playing, setPlaying] = useState(false); + const [volumn, setVolumn] = useState(100); + const [playAfterSeek, setPlayAfterSeek] = useState(false); + + const play = useCallback(() => player.current?.play(), []); + const pause = useCallback(() => player.current?.pause(), []); + const toggle = useCallback(() => player.current?.toggle(), []); + const change = useCallback((value: number) => { + if (!player.current) { + return; + } + setOffset((value / SLIDER_MAX) * player.current.duration); + setSliderValue(value); + }, []); + const startSeek = useCallback(() => { + setPlayAfterSeek(playing); + pause(); + }, [playing, pause]); + const stopSeek = useCallback(() => { + if (!player.current) { + return; + } + player.current.seek(offset); + if (playAfterSeek && offset < player.current.duration) { + play(); + } + }, [play, offset, playAfterSeek]); + const toggleMute = useCallback(() => { + if (player.current) { + player.current.toggleMute(); + setVolumn(player.current.volumn); + } + }, []); - useEffect(() => { - let p: AudioPlayer | null = null; - if (data) { - (async () => { - setDecoding(true); - onLoading?.(); - setOffset(0); - setSliderValue(0); - setDuration('00:00'); - p = new AudioPlayer({ - context: audioContext, - onplay: () => { - setPlaying(true); - startTimer(); - }, - onstop: () => { - setPlaying(false); - stopTimer(); - } - }); - const buffer = await data.data.arrayBuffer(); - await p.load(buffer, data.type != null ? mime.extension(data.type) || undefined : undefined); - setDecoding(false); - setDuration(formatDuration(p.duration)); - onLoad?.({sampleRate: p.sampleRate, duration: p.duration}); - player.current = p; - })(); + const tick = useCallback(() => { + if (player.current) { + const current = player.current.current; + setOffset(current); + setSliderValue(Math.floor((current / player.current.duration) * SLIDER_MAX)); + } + }, []); + const startTimer = useCallback(() => { + tick(); + timer.current = (window.setInterval(tick, 250) as unknown) as number; + }, [tick]); + const stopTimer = useCallback(() => { + if (player.current) { + if (player.current.current >= player.current.duration) { + tick(); } - return () => { - if (p) { - setPlaying(false); - p.dispose(); - player.current = null; - } - }; - }, [data, startTimer, stopTimer, onLoading, onLoad, audioContext]); + } + if (timer.current) { + window.clearInterval(timer.current); + timer.current = null; + } + }, [tick]); - const volumnIcon = useMemo(() => { - if (volumn === 0) { - return 'mute'; - } - if (volumn <= 50) { - return 'volumn-low'; + useEffect(() => { + if (player.current) { + player.current.volumn = volumn; + } + }, [volumn]); + + useEffect(() => { + let p: AudioPlayer | null = null; + if (data) { + (async () => { + setDecoding(true); + onLoading?.(); + setOffset(0); + setSliderValue(0); + setDuration('00:00'); + p = new AudioPlayer({ + context: audioContext, + onplay: () => { + setPlaying(true); + startTimer(); + }, + onstop: () => { + setPlaying(false); + stopTimer(); + } + }); + const buffer = await data.data.arrayBuffer(); + await p.load(buffer, data.type != null ? mime.extension(data.type) || undefined : undefined); + setDecoding(false); + setDuration(formatDuration(p.duration)); + onLoad?.({sampleRate: p.sampleRate, duration: p.duration}); + player.current = p; + })(); + } + return () => { + if (p) { + setPlaying(false); + p.dispose(); + player.current = null; } - return 'volumn'; - }, [volumn]); + }; + }, [data, startTimer, stopTimer, onLoading, onLoad, audioContext]); - if (loading) { - return ; + const volumnIcon = useMemo(() => { + if (volumn === 0) { + return 'mute'; } - - if (error) { - return
{t('common:error')}
; + if (volumn <= 50) { + return 'volumn-low'; } + return 'volumn'; + }, [volumn]); - return ( - - - {decoding ? : } - -
- ; + } + + if (error) { + return
{t('common:error')}
; + } + + return ( + + + {decoding ? : } + +
+ +
+ + {formatDuration(offset)}/{duration} + + -
- - {formatDuration(offset)}/{duration} - - - } - > - - - - -
- ); - } -); + } + > + + + + + + ); +}; export default Audio; diff --git a/frontend/packages/core/src/components/GraphPage/ModelPropertiesDialog.tsx b/frontend/packages/core/src/components/GraphPage/ModelPropertiesDialog.tsx index f716561c..e8e9e05c 100644 --- a/frontend/packages/core/src/components/GraphPage/ModelPropertiesDialog.tsx +++ b/frontend/packages/core/src/components/GraphPage/ModelPropertiesDialog.tsx @@ -1,5 +1,5 @@ import React, {FunctionComponent} from 'react'; -import {em, size, transitionProps} from '~/utils/style'; +import {em, size, transitionProps, zIndexes} from '~/utils/style'; import Icon from '~/components/Icon'; import Properties from '~/components/GraphPage/Properties'; @@ -15,7 +15,7 @@ const Dialog = styled.div` height: 100vh; overscroll-behavior: none; background-color: var(--mask-color); - z-index: 999; + z-index: ${zIndexes.dialog}; ${transitionProps('background-color')} > .modal { @@ -42,8 +42,8 @@ const Dialog = styled.div` > .modal-close { flex: none; - ${size(em(14, 18), em(14, 18))} - font-size: ${em(14, 18)}; + ${size(em(20, 18), em(20, 18))} + font-size: ${em(20, 18)}; text-align: center; cursor: pointer; } diff --git a/frontend/packages/core/src/components/Image.tsx b/frontend/packages/core/src/components/Image.tsx index b78cb35a..9fce0211 100644 --- a/frontend/packages/core/src/components/Image.tsx +++ b/frontend/packages/core/src/components/Image.tsx @@ -1,39 +1,21 @@ -import React, {useImperativeHandle, useLayoutEffect, useState} from 'react'; +import React, {FunctionComponent, useLayoutEffect, useState} from 'react'; import {WithStyled, primaryColor} from '~/utils/style'; import type {BlobResponse} from '~/utils/fetch'; import GridLoader from 'react-spinners/GridLoader'; -import {fetcher} from '~/utils/fetch'; -import mime from 'mime-types'; -import {saveAs} from 'file-saver'; -import useRequest from '~/hooks/useRequest'; import {useTranslation} from 'react-i18next'; -export type ImageRef = { - save(filename: string): void; -}; - type ImageProps = { - src?: string; - cache?: number; + data?: BlobResponse; + loading?: boolean; + error?: Error; + onClick?: () => unknown; }; -const Image = React.forwardRef(({src, cache, className}, ref) => { +const Image: FunctionComponent = ({data, loading, error, onClick, className}) => { const {t} = useTranslation('common'); const [url, setUrl] = useState(''); - const {data, error, loading} = useRequest(src ?? null, fetcher, { - dedupingInterval: cache ?? 2000 - }); - - useImperativeHandle(ref, () => ({ - save: (filename: string) => { - if (data) { - const ext = data.type ? mime.extension(data.type) : null; - saveAs(data.data, filename.replace(/[/\\?%*:|"<>]/g, '_') + (ext ? `.${ext}` : '')); - } - } - })); // use useLayoutEffect hook to prevent image render after url revoked useLayoutEffect(() => { @@ -55,7 +37,7 @@ const Image = React.forwardRef(({src, cache, return
{t('common:error')}
; } - return ; -}); + return ; +}; export default Image; diff --git a/frontend/packages/core/src/components/SamplePage/AudioChart.tsx b/frontend/packages/core/src/components/SamplePage/AudioChart.tsx index 3ca9ba4b..078177af 100644 --- a/frontend/packages/core/src/components/SamplePage/AudioChart.tsx +++ b/frontend/packages/core/src/components/SamplePage/AudioChart.tsx @@ -1,6 +1,6 @@ -import Audio, {AudioProps, AudioRef} from '~/components/Audio'; +import Audio, {AudioProps} from '~/components/Audio'; import React, {FunctionComponent, useCallback, useState} from 'react'; -import SampleChart, {SampleChartBaseProps} from '~/components/SamplePage/SampleChart'; +import SampleChart, {SampleChartBaseProps, SampleEntityProps} from '~/components/SamplePage/SampleChart'; import {format} from 'd3-format'; import styled from 'styled-components'; @@ -32,15 +32,8 @@ const AudioChart: FunctionComponent = ({audioContext, ...props} ); const content = useCallback( - (ref: React.RefObject, src: string) => ( - + (props: SampleEntityProps) => ( + ), [onLoading, onLoad, audioContext] ); diff --git a/frontend/packages/core/src/components/SamplePage/ImageChart.tsx b/frontend/packages/core/src/components/SamplePage/ImageChart.tsx index 3d991629..707358e9 100644 --- a/frontend/packages/core/src/components/SamplePage/ImageChart.tsx +++ b/frontend/packages/core/src/components/SamplePage/ImageChart.tsx @@ -1,8 +1,13 @@ -import Image, {ImageRef} from '~/components/Image'; import React, {FunctionComponent, useCallback} from 'react'; -import SampleChart, {SampleChartBaseProps} from '~/components/SamplePage/SampleChart'; +import SampleChart, { + SampleChartBaseProps, + SampleEntityProps, + SamplePreviewerProps +} from '~/components/SamplePage/SampleChart'; import {size, transitionProps} from '~/utils/style'; +import Image from '~/components/Image'; +import ImagePreviewer from '~/components/SamplePage/ImagePreviewer'; import styled from 'styled-components'; const StyledImage = styled(Image)<{brightness?: number; contrast?: number; fit?: boolean}>` @@ -23,12 +28,13 @@ type ImageChartProps = { const ImageChart: FunctionComponent = ({brightness, contrast, fit, ...props}) => { const content = useCallback( - (ref: React.RefObject, src: string) => ( - - ), + (props: SampleEntityProps) => , [brightness, contrast, fit] ); - return ; + + const previewer = useCallback((props: SamplePreviewerProps) => , []); + + return ; }; export default ImageChart; diff --git a/frontend/packages/core/src/components/SamplePage/ImagePreviewer.tsx b/frontend/packages/core/src/components/SamplePage/ImagePreviewer.tsx new file mode 100644 index 00000000..cbb34429 --- /dev/null +++ b/frontend/packages/core/src/components/SamplePage/ImagePreviewer.tsx @@ -0,0 +1,347 @@ +import React, {FunctionComponent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {em, headerHeight, primaryColor, rem, zIndexes} from '~/utils/style'; + +import GridLoader from 'react-spinners/GridLoader'; +import Icon from '~/components/Icon'; +import RangeSlider from '~/components/RangeSlider'; +import type {SamplePreviewerProps} from '~/components/SamplePage/SampleChart'; +import styled from 'styled-components'; +import {useTranslation} from 'react-i18next'; + +const Wrapper = styled.div` + position: fixed; + top: 0; + left: 0; + z-index: ${zIndexes.dialog}; + height: 100vh; + width: 100vw; + background-color: var(--sample-preview-mask-color); +`; + +const Header = styled.div` + position: relative; + height: ${headerHeight}; + width: 100%; + background-color: var(--model-header-background-color); + display: flex; + justify-content: space-between; + align-items: center; + + .step-slider { + display: flex; + align-items: center; + flex-grow: 1; + margin: 0 ${rem(20)}; + + .slider { + width: 31.7213115%; + margin: 0 ${rem(12)}; + } + + .step-buttons { + margin-left: ${em(10)}; + display: flex; + flex-direction: column; + font-size: ${em(10)}; + + > a { + display: inline-blcok; + line-height: 1; + height: ${em(14)}; + + &:hover { + color: var(--text-lighter-color); + } + + > i { + display: inline-block; + height: 100%; + + > svg { + vertical-align: top; + } + } + } + } + } + + .buttons { + display: flex; + align-items: center; + font-size: ${rem(24)}; + + > * { + margin-right: ${rem(30)}; + } + + > a { + height: ${rem(24)}; + overflow: hidden; + } + + > span { + width: 1px; + height: ${rem(30)}; + background-color: var(--border-color); + } + } +`; + +const Container = styled.div<{grabbing?: boolean}>` + display: flex; + justify-content: center; + align-items: center; + position: absolute; + height: calc(100% - ${headerHeight}); + width: 100%; + top: ${headerHeight}; + left: 0; + overflow: hidden; + + > img { + cursor: ${props => (props.grabbing ? 'grabbing' : 'grab')}; + } +`; + +const MAX_IMAGE_SCALE = 3; +const MIN_IMAGE_SCALE = 0.1; + +type ImagePreviewerProps = SamplePreviewerProps; + +const ImagePreviewer: FunctionComponent = ({ + data, + loading, + error, + steps, + step: propStep, + onClose, + onChange, + onChangeComplete +}) => { + const {t} = useTranslation('sample'); + + const [step, setStep] = useState(propStep ?? 0); + useEffect(() => setStep(propStep ?? 0), [propStep]); + + const changeStep = useCallback( + (num: number) => { + setStep(num); + onChange?.(num); + }, + [onChange] + ); + + const prevStep = useCallback(() => { + if (step > 0) { + changeStep(step - 1); + } + }, [step, changeStep]); + + const nextStep = useCallback(() => { + if (step < steps.length - 1) { + changeStep(step + 1); + } + }, [step, steps, changeStep]); + + const [url, setUrl] = useState(''); + + // use useLayoutEffect hook to prevent image render after url revoked + useLayoutEffect(() => { + if (data) { + let objectUrl: string | null = null; + objectUrl = URL.createObjectURL(data.data); + setUrl(objectUrl); + return () => { + objectUrl && URL.revokeObjectURL(objectUrl); + }; + } + }, [data]); + + const container = useRef(null); + const image = useRef(null); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + useEffect(() => { + if (url) { + const img = new Image(); + img.src = url; + img.onload = () => { + const rect = container.current?.getBoundingClientRect(); + if (rect) { + const r = rect.width / rect.height; + const ir = img.naturalWidth / img.naturalHeight; + if (r >= ir && img.naturalHeight > rect.height * 0.9) { + setHeight(rect.height * 0.9); + setWidth(ir * rect.height * 0.9); + } else if (ir >= r && img.naturalWidth > rect.width * 0.9) { + setWidth(rect.width * 0.9); + setHeight(ir * rect.width * 0.9); + } else { + setWidth(img.naturalWidth); + setHeight(img.naturalHeight); + } + } + }; + } + return () => { + setWidth(0); + setHeight(0); + }; + }, [url]); + + const [scale, setScale] = useState(1); + const scaleImage = useCallback( + (step: number) => + setScale(s => { + if (s + step > MAX_IMAGE_SCALE) { + return MAX_IMAGE_SCALE; + } + if (s + step < MIN_IMAGE_SCALE) { + return MIN_IMAGE_SCALE; + } + return s + step; + }), + [] + ); + useEffect(() => { + const img = container.current; + const wheel = (e: WheelEvent) => { + e.preventDefault(); + setScale(s => { + const t = s - e.deltaY * 0.007; + if (t > MAX_IMAGE_SCALE) { + return MAX_IMAGE_SCALE; + } + if (t < MIN_IMAGE_SCALE) { + return MIN_IMAGE_SCALE; + } + return t; + }); + }; + img?.addEventListener('wheel', wheel); + return () => { + img?.removeEventListener('wheel', wheel); + }; + }, []); + + const [grabbing, setGrabbing] = useState(false); + const [x, setX] = useState(0); + const [y, setY] = useState(0); + useEffect(() => { + const img = image.current; + let trigger = false; + let ox = 0; + let oy = 0; + const mousedown = (e: MouseEvent) => { + e.preventDefault(); + setGrabbing(true); + trigger = true; + ox = e.clientX; + oy = e.clientY; + }; + const mousemove = (e: MouseEvent) => { + e.preventDefault(); + if (trigger) { + setX(sx => { + return sx + (e.clientX - ox); + }); + setY(sy => { + return sy + (e.clientY - oy); + }); + ox = e.clientX; + oy = e.clientY; + } + }; + const mouseup = () => { + setGrabbing(false); + trigger = false; + }; + img?.addEventListener('mousedown', mousedown); + img?.addEventListener('mousemove', mousemove); + img?.addEventListener('mouseup', mouseup); + img?.addEventListener('mouseout', mouseup); + return () => { + img?.removeEventListener('mousedown', mousedown); + img?.removeEventListener('mousemove', mousemove); + img?.removeEventListener('mouseup', mouseup); + img?.removeEventListener('mouseout', mouseup); + }; + }, [url]); + + const reset = useCallback(() => { + setScale(1); + setX(0); + setY(0); + }, []); + + useEffect(() => reset, [url, reset]); + + const content = useMemo(() => { + if (loading) { + return ; + } + if (error) { + return
{t('common:error')}
; + } + return ( + e.stopPropagation()} + style={{ + width, + height, + transform: `translate(${x}px, ${y}px) scale(${scale})` + }} + /> + ); + }, [url, loading, error, width, height, x, y, scale, t]); + + return ( + +
+
+ {t('sample:step')} + + {steps[step]} + +
+ +
+ + {content} + +
+ ); +}; + +export default ImagePreviewer; diff --git a/frontend/packages/core/src/components/SamplePage/SampleChart.tsx b/frontend/packages/core/src/components/SamplePage/SampleChart.tsx index d8a6b7cb..dd480fcb 100644 --- a/frontend/packages/core/src/components/SamplePage/SampleChart.tsx +++ b/frontend/packages/core/src/components/SamplePage/SampleChart.tsx @@ -1,14 +1,19 @@ import React, {FunctionComponent, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ellipsis, em, primaryColor, rem, size, transitionProps} from '~/utils/style'; +import type {BlobResponse} from '~/utils/fetch'; import ChartToolbox from '~/components/ChartToolbox'; import GridLoader from 'react-spinners/GridLoader'; import type {Run} from '~/types'; import StepSlider from '~/components/SamplePage/StepSlider'; +import {fetcher} from '~/utils/fetch'; import {formatTime} from '~/utils'; import isEmpty from 'lodash/isEmpty'; +import mime from 'mime-types'; import queryString from 'query-string'; +import {saveAs} from 'file-saver'; import styled from 'styled-components'; +import useRequest from '~/hooks/useRequest'; import {useRunningRequest} from '~/hooks/useRequest'; import {useTranslation} from 'react-i18next'; @@ -64,7 +69,7 @@ const Title = styled.div<{color: string}>` } `; -const Container = styled.div<{brightness?: number; contrast?: number; fit?: boolean}>` +const Container = styled.div<{preview?: boolean}>` flex-grow: 1; flex-shrink: 1; margin: ${em(20)} 0; @@ -72,6 +77,7 @@ const Container = styled.div<{brightness?: number; contrast?: number; fit?: bool justify-content: center; align-items: center; overflow: hidden; + cursor: ${props => (props.preview ? 'zoom-in' : 'default')}; `; const Footer = styled.div` @@ -103,25 +109,44 @@ export type SampleChartBaseProps = { running?: boolean; }; -type SampleChartRef = { - save: (filename: string) => void; +export type SampleEntityProps = { + data?: BlobResponse; + loading?: boolean; + error?: Error; +}; + +export type SamplePreviewerProps = SampleEntityProps & { + steps: number[]; + step?: number; + onClose?: () => unknown; + onChange?: (value: number) => unknown; + onChangeComplete?: () => unknown; }; type SampleChartProps = { type: 'image' | 'audio'; cache: number; + step?: number; footer?: JSX.Element; - content: (ref: React.RefObject, src: string) => JSX.Element; + content: (props: SampleEntityProps) => React.ReactNode; + previewer?: (props: SamplePreviewerProps) => React.ReactNode; } & SampleChartBaseProps; const getUrl = (type: string, index: number, run: string, tag: string, wallTime: number): string => `/${type}/${type}?${queryString.stringify({index, ts: wallTime, run, tag})}`; -const SampleChart: FunctionComponent = ({run, tag, running, type, cache, footer, content}) => { +const SampleChart: FunctionComponent = ({ + run, + tag, + running, + type, + cache, + footer, + content, + previewer +}) => { const {t, i18n} = useTranslation(['sample', 'common']); - const sampleRef = useRef(null); - const {data, error, loading} = useRunningRequest( `/${type}/list?${queryString.stringify({run: run.label, tag})}`, !!running @@ -129,12 +154,32 @@ const SampleChart: FunctionComponent = ({run, tag, running, ty const steps = useMemo(() => data?.map(item => item.step) ?? [], [data]); + const [preview, setPreview] = useState(false); const [step, setStep] = useState(0); const [src, setSrc] = useState(); const cached = useRef>({}); const timer = useRef(null); + const wrapperRef = useRef(null); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + const hoveredNodes = document.querySelectorAll(':hover'); + if (preview || Array.from(hoveredNodes).some(node => node.isSameNode(wrapperRef.current))) { + if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + setStep(s => (s > 0 ? s - 1 : s)); + event.preventDefault(); + } else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + setStep(s => (s < steps.length - 1 ? s + 1 : s)); + event.preventDefault(); + } + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [steps, preview]); + // clear cache if tag or run changed useEffect(() => { Object.values(cached.current).forEach(({timer}) => clearTimeout(timer)); @@ -158,10 +203,6 @@ const SampleChart: FunctionComponent = ({run, tag, running, ty timer.current = null; }, [type, step, run.label, tag, wallTime, data, cache]); - const download = useCallback(() => { - sampleRef.current?.save(`${run.label}-${tag}-${steps[step]}-${wallTime.toString().replace(/\./, '_')}`); - }, [run.label, tag, steps, step, wallTime]); - useEffect(() => { if (cached.current[step]) { // cached, return immediately @@ -200,6 +241,33 @@ const SampleChart: FunctionComponent = ({run, tag, running, ty } }, []); + const {data: entityData, error: entityError, loading: entityLoading} = useRequest( + src ?? null, + fetcher, + { + dedupingInterval: 5 * 60 * 1000 + } + ); + + const download = useCallback(() => { + if (entityData) { + const ext = entityData.type ? mime.extension(entityData.type) : null; + saveAs( + entityData.data, + `${run.label}-${tag}-${steps[step]}-${wallTime.toString().replace(/\./, '_')}`.replace( + /[/\\?%*:|"<>]/g, + '_' + ) + (ext ? `.${ext}` : '') + ); + } + }, [entityData, run.label, tag, steps, step, wallTime]); + + const entityProps = useMemo(() => { + if (src) { + return {data: entityData, error: entityError, loading: entityLoading}; + } + }, [src, entityData, entityError, entityLoading]); + const Content = useMemo(() => { // show loading when deferring if (loading || !cached.current[step] || !viewed) { @@ -211,14 +279,35 @@ const SampleChart: FunctionComponent = ({run, tag, running, ty if (isEmpty(data)) { return {t('common:empty')}; } - if (src) { - return content(sampleRef, src); + if (entityProps) { + return content(entityProps); } return null; - }, [viewed, loading, error, data, step, src, t, content]); + }, [viewed, loading, error, data, step, entityProps, t, content]); + + const Previewer = useMemo(() => { + if (!previewer) { + return null; + } + if (!preview) { + return null; + } + if (!entityProps) { + return null; + } + return previewer({ + ...entityProps, + loading: !cached.current[step] || entityProps.loading, + steps, + step, + onClose: () => setPreview(false), + onChange: setStep, + onChangeComplete: cacheSrc + }); + }, [previewer, entityProps, preview, steps, step, cacheSrc]); return ( - + <h4>{tag}</h4> <span>{run.label}</span> @@ -226,7 +315,10 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({run, tag, running, ty <StepSlider value={step} steps={steps} onChange={setStep} onChangeComplete={cacheSrc}> {formatTime(wallTime, i18n.language)} </StepSlider> - <Container ref={container}>{Content}</Container> + <Container ref={container} preview={!!previewer && !!src} onClick={() => setPreview(true)}> + {Content} + </Container> + {Previewer} <Footer> <ChartToolbox items={[ diff --git a/frontend/packages/core/src/components/SamplePage/StepSlider.tsx b/frontend/packages/core/src/components/SamplePage/StepSlider.tsx index 48e9ad05..641f1ab0 100644 --- a/frontend/packages/core/src/components/SamplePage/StepSlider.tsx +++ b/frontend/packages/core/src/components/SamplePage/StepSlider.tsx @@ -1,13 +1,16 @@ import React, {FunctionComponent, useCallback, useEffect, useState} from 'react'; import {em, transitionProps} from '~/utils/style'; +import Icon from '~/components/Icon'; import RangeSlider from '~/components/RangeSlider'; +import Tippy from '@tippyjs/react'; import styled from 'styled-components'; import {useTranslation} from 'react-i18next'; const Label = styled.div` display: flex; justify-content: space-between; + align-items: center; color: var(--text-light-color); font-size: ${em(12)}; margin-bottom: ${em(5)}; @@ -16,6 +19,37 @@ const Label = styled.div` > :not(:first-child) { flex-grow: 0; } + + .step-indicator { + display: flex; + align-items: center; + + .step-buttons { + margin-left: ${em(10)}; + display: flex; + flex-direction: column; + font-size: ${em(10)}; + + > a { + display: inline-blcok; + line-height: 1; + height: ${em(14)}; + + &:hover { + color: var(--text-lighter-color); + } + + > i { + display: inline-block; + height: 100%; + + > svg { + vertical-align: top; + } + } + } + } + } `; const FullWidthRangeSlider = styled(RangeSlider)` @@ -43,10 +77,34 @@ const StepSlider: FunctionComponent<StepSliderProps> = ({onChange, onChangeCompl [onChange] ); + const prevStep = useCallback(() => { + if (value > 0) { + changeStep(value - 1); + } + }, [value, changeStep]); + + const nextStep = useCallback(() => { + if (value < steps.length - 1) { + changeStep(value + 1); + } + }, [value, steps, changeStep]); + return ( <> <Label> - <span>{`${t('sample:step')}: ${steps[step] ?? '...'}`}</span> + <div className="step-indicator"> + <div>{`${t('sample:step')}: ${steps[step] ?? '...'}`}</div> + <Tippy placement="right" theme="tooltip" content={t('sample:step-tip')}> + <div className="step-buttons"> + <a href="javascript:void(0)" onClick={prevStep}> + <Icon type="chevron-up" /> + </a> + <a href="javascript:void(0)" onClick={nextStep}> + <Icon type="chevron-down" /> + </a> + </div> + </Tippy> + </div> {children && <span>{children}</span>} </Label> <FullWidthRangeSlider diff --git a/frontend/packages/core/src/components/Select.tsx b/frontend/packages/core/src/components/Select.tsx index 03baccfb..04f0b272 100644 --- a/frontend/packages/core/src/components/Select.tsx +++ b/frontend/packages/core/src/components/Select.tsx @@ -9,7 +9,8 @@ import { math, sameBorder, size, - transitionProps + transitionProps, + zIndexes } from '~/utils/style'; import Checkbox from '~/components/Checkbox'; @@ -77,7 +78,7 @@ const List = styled.div<{opened?: boolean; empty?: boolean}>` border-top-color: var(--border-color); ${borderRadiusShortHand('bottom', borderRadius)} display: ${props => (props.opened ? 'block' : 'none')}; - z-index: 9999; + z-index: ${zIndexes.component}; line-height: 1; background-color: inherit; box-shadow: 0 5px 6px 0 rgba(0, 0, 0, 0.05); diff --git a/frontend/packages/core/src/utils/style.ts b/frontend/packages/core/src/utils/style.ts index 67158689..ee321907 100644 --- a/frontend/packages/core/src/utils/style.ts +++ b/frontend/packages/core/src/utils/style.ts @@ -32,6 +32,15 @@ export const asideWidth = rem(260); export const borderRadius = '4px'; export const progressSpinnerSize = '20px'; +export const zIndexes = { + progress: 99999, + toast: 90000, + tooltip: 80000, + component: 30000, + dialog: 20000, + header: 10000 +}; + // shims // TODO: remove and use colors in theme instead export const primaryColor = colors.primary.default; @@ -143,7 +152,7 @@ export const GlobalStyle = createGlobalStyle` #nprogress .bar { background-color: var(--progress-bar-color); - z-index: 99999; + z-index: ${zIndexes.progress}; ${position('fixed', 0, null, null, 0)} ${size('2px', '100%')} ${transitionProps('background-color')} @@ -161,7 +170,7 @@ export const GlobalStyle = createGlobalStyle` #nprogress .spinner { display: block; - z-index: 99999; + z-index: ${zIndexes.progress}; ${position('fixed', progressSpinnerSize, progressSpinnerSize, null, null)} } @@ -190,7 +199,7 @@ export const GlobalStyle = createGlobalStyle` } .Toastify__toast-container { - z-index: 10001; + z-index: ${zIndexes.toast}; .Toastify__toast { border-radius: ${borderRadius}; @@ -207,7 +216,7 @@ export const GlobalStyle = createGlobalStyle` } [data-tippy-root] .tippy-box { - z-index: 10002; + z-index: ${zIndexes.tooltip}; color: var(--text-color); background-color: var(--background-color); box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); diff --git a/frontend/packages/core/src/utils/theme.ts b/frontend/packages/core/src/utils/theme.ts index dce30fc2..537d51aa 100644 --- a/frontend/packages/core/src/utils/theme.ts +++ b/frontend/packages/core/src/utils/theme.ts @@ -62,6 +62,8 @@ export const themes = { progressBarColor: '#fff', maskColor: 'rgba(255, 255, 255, 0.8)', + samplePreviewMaskColor: 'rgba(0, 0, 0, 0.5)', + graphUploaderBackgroundColor: '#f9f9f9', graphUploaderActiveBackgroundColor: '#f2f6ff', graphCopyrightColor: '#ddd', @@ -102,6 +104,8 @@ export const themes = { progressBarColor: '#fff', maskColor: 'rgba(0, 0, 0, 0.8)', + samplePreviewMaskColor: 'rgba(0, 0, 0, 0.8)', + graphUploaderBackgroundColor: '#262629', graphUploaderActiveBackgroundColor: '#303033', graphCopyrightColor: '#565657', -- GitLab