未验证 提交 e50c15b0 编写于 作者: P Peter Pan 提交者: GitHub

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
上级 b8590f97
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<path d="M256 179.2l-219.429 230.4-36.572-38.4 256-268.8 256 268.8-36.572 38.4z"></path>
</svg>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M32 0.99l-1-0.99-15 15.010-15.010-15.010-0.99 0.99 15.010 15.010-15.010 15 0.99 1 15.010-15 15 15 1-1-15-15 15-15.010z"></path>
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="m17.8336309 5.10570888 1.0606602 1.06066018-5.8345822 5.83333984 5.8345822 5.833922-1.0606602 1.0606602-5.833922-5.8345822-5.83333984 5.8345822-1.06066018-1.0606602 5.83300002-5.833922-5.83300002-5.83333984 1.06066018-1.06066018 5.83333984 5.83300002z" fill-rule="nonzero" />
</svg>
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m12 1.5c5.7989899 0 10.5 4.70101013 10.5 10.5 0 5.7989899-4.7010101 10.5-10.5 10.5-5.79898987 0-10.5-4.7010101-10.5-10.5 0-5.79898987 4.70101013-10.5 10.5-10.5zm0 1.5c-4.97056275 0-9 4.02943725-9 9 0 4.9705627 4.02943725 9 9 9 4.9705627 0 9-4.0294373 9-9 0-4.97056275-4.0294373-9-9-9zm4.5 8.375v1.5h-9v-1.5z" fill-rule="nonzero" />
</svg>
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m12 1.5c5.7989899 0 10.5 4.70101013 10.5 10.5 0 5.7989899-4.7010101 10.5-10.5 10.5-5.79898987 0-10.5-4.7010101-10.5-10.5 0-5.79898987 4.70101013-10.5 10.5-10.5zm0 1.5c-4.97056275 0-9 4.02943725-9 9 0 4.9705627 4.02943725 9 9 9 4.9705627 0 9-4.0294373 9-9 0-4.97056275-4.0294373-9-9-9zm.75 4.5v3.875h3.75v1.5h-3.751l.001 3.625h-1.5l-.001-3.625h-3.749v-1.5h3.75v-3.875z" fill-rule="nonzero" />
</svg>
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m12 1.5c5.7989899 0 10.5 4.70101013 10.5 10.5 0 5.7989899-4.7010101 10.5-10.5 10.5-5.79898987 0-10.5-4.7010101-10.5-10.5 0-5.79898987 4.70101013-10.5 10.5-10.5zm0 1.5c-4.97056275 0-9 4.02943725-9 9 0 4.9705627 4.02943725 9 9 9 4.9705627 0 9-4.0294373 9-9 0-4.97056275-4.0294373-9-9-9zm-3 4.875v8.25h-1.5v-8.25zm7.5 0v8.25h-1.5v-8.25zm-4.5 5.25c.517767 0 .9375.419733.9375.9375s-.419733.9375-.9375.9375-.9375-.419733-.9375-.9375.419733-.9375.9375-.9375zm0-4.125c.517767 0 .9375.41973305.9375.9375 0 .517767-.419733.9375-.9375.9375s-.9375-.419733-.9375-.9375c0-.51776695.419733-.9375.9375-.9375z" fill-rule="nonzero" />
</svg>
......@@ -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"
}
......@@ -9,5 +9,6 @@
"sample-rate": "采样率",
"show-actual-size": "按真实大小展示",
"step": "Step",
"step-tip": "您还可以通过键盘 ↑ ↓ 键,快速调节step哦~",
"text": "文本"
}
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)}
......
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<AudioRef, AudioProps & WithStyled>(
({audioContext, src, cache, onLoading, onLoad, className}, ref) => {
const {t} = useTranslation('common');
const {data, error, loading} = useRequest<BlobResponse>(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<number | null>(null);
const player = useRef<AudioPlayer | null>(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<AudioProps & WithStyled> = ({
audioContext,
data,
loading,
error,
onLoading,
onLoad,
className
}) => {
const {t} = useTranslation('common');
const timer = useRef<number | null>(null);
const player = useRef<AudioPlayer | null>(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 <SyncLoader color={primaryColor} size="15px" />;
const volumnIcon = useMemo(() => {
if (volumn === 0) {
return 'mute';
}
if (error) {
return <div>{t('common:error')}</div>;
if (volumn <= 50) {
return 'volumn-low';
}
return 'volumn';
}, [volumn]);
return (
<Container className={className}>
<a className={`control ${decoding ? 'disabled' : ''}`} onClick={toggle}>
{decoding ? <PuffLoader size="16px" /> : <Icon type={playing ? 'pause' : 'play'} />}
</a>
<div className="slider">
<RangeSlider
if (loading) {
return <SyncLoader color={primaryColor} size="15px" />;
}
if (error) {
return <div>{t('common:error')}</div>;
}
return (
<Container className={className}>
<a className={`control ${decoding ? 'disabled' : ''}`} onClick={toggle}>
{decoding ? <PuffLoader size="16px" /> : <Icon type={playing ? 'pause' : 'play'} />}
</a>
<div className="slider">
<RangeSlider
min={0}
max={SLIDER_MAX}
step={1}
value={sliderValue}
disabled={decoding}
onChange={change}
onChangeStart={startSeek}
onChangeComplete={stopSeek}
/>
</div>
<span className="time">
{formatDuration(offset)}/{duration}
</span>
<Tippy
placement="top"
animation="shift-away-subtle"
interactive
hideOnClick={false}
content={
<VolumnSlider
value={volumn}
min={0}
max={SLIDER_MAX}
max={100}
step={1}
value={sliderValue}
disabled={decoding}
onChange={change}
onChangeStart={startSeek}
onChangeComplete={stopSeek}
onChange={setVolumn}
orientation="vertical"
/>
</div>
<span className="time">
{formatDuration(offset)}/{duration}
</span>
<Tippy
placement="top"
animation="shift-away-subtle"
interactive
hideOnClick={false}
content={
<VolumnSlider
value={volumn}
min={0}
max={100}
step={1}
onChange={setVolumn}
orientation="vertical"
/>
}
>
<a className="control volumn" onClick={toggleMute}>
<Icon type={volumnIcon} />
</a>
</Tippy>
</Container>
);
}
);
}
>
<a className="control volumn" onClick={toggleMute}>
<Icon type={volumnIcon} />
</a>
</Tippy>
</Container>
);
};
export default Audio;
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;
}
......
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<ImageRef, ImageProps & WithStyled>(({src, cache, className}, ref) => {
const Image: FunctionComponent<ImageProps & WithStyled> = ({data, loading, error, onClick, className}) => {
const {t} = useTranslation('common');
const [url, setUrl] = useState('');
const {data, error, loading} = useRequest<BlobResponse>(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<ImageRef, ImageProps & WithStyled>(({src, cache,
return <div>{t('common:error')}</div>;
}
return <img className={className} src={url} />;
});
return <img className={className} src={url} onClick={onClick} />;
};
export default Image;
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<AudioChartProps> = ({audioContext, ...props}
);
const content = useCallback(
(ref: React.RefObject<AudioRef>, src: string) => (
<StyledAudio
audioContext={audioContext}
src={src}
cache={cache}
onLoading={onLoading}
onLoad={onLoad}
ref={ref}
/>
(props: SampleEntityProps) => (
<StyledAudio audioContext={audioContext} {...props} onLoading={onLoading} onLoad={onLoad} />
),
[onLoading, onLoad, audioContext]
);
......
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<ImageChartProps> = ({brightness, contrast, fit, ...props}) => {
const content = useCallback(
(ref: React.RefObject<ImageRef>, src: string) => (
<StyledImage src={src} cache={cache} ref={ref} brightness={brightness} contrast={contrast} fit={fit} />
),
(props: SampleEntityProps) => <StyledImage {...props} brightness={brightness} contrast={contrast} fit={fit} />,
[brightness, contrast, fit]
);
return <SampleChart type="image" cache={cache} content={content} {...props} />;
const previewer = useCallback((props: SamplePreviewerProps) => <ImagePreviewer {...props} />, []);
return <SampleChart type="image" cache={cache} content={content} previewer={previewer} {...props} />;
};
export default ImageChart;
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<ImagePreviewerProps> = ({
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<HTMLDivElement>(null);
const image = useRef<HTMLImageElement>(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 <GridLoader color={primaryColor} size="10px" />;
}
if (error) {
return <div>{t('common:error')}</div>;
}
return (
<img
src={url}
ref={image}
onClick={e => e.stopPropagation()}
style={{
width,
height,
transform: `translate(${x}px, ${y}px) scale(${scale})`
}}
/>
);
}, [url, loading, error, width, height, x, y, scale, t]);
return (
<Wrapper>
<Header>
<div className="step-slider">
<span>{t('sample:step')}</span>
<RangeSlider
className="slider"
min={0}
max={steps.length ? steps.length - 1 : 0}
step={1}
value={step}
onChange={changeStep}
onChangeComplete={onChangeComplete}
/>
<span>{steps[step]}</span>
<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>
</div>
<div className="buttons">
<a href="javascript:void(0)" onClick={() => scaleImage(0.1)}>
<Icon type="plus-circle" />
</a>
<a href="javascript:void(0)" onClick={() => scaleImage(-0.1)}>
<Icon type="minus-circle" />
</a>
<a href="javascript:void(0)" onClick={reset}>
<Icon type="restore-circle" />
</a>
<span></span>
<a href="javascript:void(0)" onClick={onClose}>
<Icon type="close" />
</a>
</div>
</Header>
<Container ref={container} onClick={onClose} grabbing={grabbing}>
{content}
</Container>
</Wrapper>
);
};
export default ImagePreviewer;
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<SampleChartRef>, 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<SampleChartProps> = ({run, tag, running, type, cache, footer, content}) => {
const SampleChart: FunctionComponent<SampleChartProps> = ({
run,
tag,
running,
type,
cache,
footer,
content,
previewer
}) => {
const {t, i18n} = useTranslation(['sample', 'common']);
const sampleRef = useRef<SampleChartRef>(null);
const {data, error, loading} = useRunningRequest<SampleData[]>(
`/${type}/list?${queryString.stringify({run: run.label, tag})}`,
!!running
......@@ -129,12 +154,32 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({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<string>();
const cached = useRef<Record<number, {src: string; timer: number}>>({});
const timer = useRef<number | null>(null);
const wrapperRef = useRef<HTMLDivElement>(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<SampleChartProps> = ({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<SampleChartProps> = ({run, tag, running, ty
}
}, []);
const {data: entityData, error: entityError, loading: entityLoading} = useRequest<BlobResponse>(
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<SampleChartProps> = ({run, tag, running, ty
if (isEmpty(data)) {
return <span>{t('common:empty')}</span>;
}
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 (
<Wrapper>
<Wrapper ref={wrapperRef}>
<Title color={run.colors[0]}>
<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={[
......
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
......
......@@ -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);
......
......@@ -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);
......
......@@ -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',
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册