未验证 提交 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"> <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="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> <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>
<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 @@ ...@@ -9,5 +9,6 @@
"sample-rate": "Sample Rate", "sample-rate": "Sample Rate",
"show-actual-size": "Show Actual Image Size", "show-actual-size": "Show Actual Image Size",
"step": "Step", "step": "Step",
"step-tip": "You can change step by pressing up & donw on your keyboard",
"text": "text" "text": "text"
} }
...@@ -9,5 +9,6 @@ ...@@ -9,5 +9,6 @@
"sample-rate": "采样率", "sample-rate": "采样率",
"show-actual-size": "按真实大小展示", "show-actual-size": "按真实大小展示",
"step": "Step", "step": "Step",
"step-tip": "您还可以通过键盘 ↑ ↓ 键,快速调节step哦~",
"text": "文本" "text": "文本"
} }
import React, {FunctionComponent, Suspense, useCallback, useEffect, useMemo, useState} from 'react'; import React, {FunctionComponent, Suspense, useCallback, useEffect, useMemo, useState} from 'react';
import {Redirect, Route, BrowserRouter as Router, Switch, useLocation} from 'react-router-dom'; import {Redirect, Route, BrowserRouter as Router, Switch, useLocation} from 'react-router-dom';
import {THEME, matchMedia} from '~/utils/theme'; 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 BodyLoading from '~/components/BodyLoading';
import ErrorBoundary from '~/components/ErrorBoundary'; import ErrorBoundary from '~/components/ErrorBoundary';
...@@ -27,7 +27,7 @@ const Main = styled.main` ...@@ -27,7 +27,7 @@ const Main = styled.main`
`; `;
const Header = styled.header` const Header = styled.header`
z-index: 10000; z-index: ${zIndexes.header};
${size(headerHeight, '100%')} ${size(headerHeight, '100%')}
${position('fixed', 0, 0, null, 0)} ${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 {WithStyled, primaryColor, rem, size, transitionProps} from '~/utils/style';
import {AudioPlayer} from '~/utils/audio'; import {AudioPlayer} from '~/utils/audio';
...@@ -9,12 +9,9 @@ import RangeSlider from '~/components/RangeSlider'; ...@@ -9,12 +9,9 @@ import RangeSlider from '~/components/RangeSlider';
import Slider from 'react-rangeslider'; import Slider from 'react-rangeslider';
import SyncLoader from 'react-spinners/SyncLoader'; import SyncLoader from 'react-spinners/SyncLoader';
import Tippy from '@tippyjs/react'; import Tippy from '@tippyjs/react';
import {fetcher} from '~/utils/fetch';
import mime from 'mime-types'; import mime from 'mime-types';
import moment from 'moment'; import moment from 'moment';
import {saveAs} from 'file-saver';
import styled from 'styled-components'; import styled from 'styled-components';
import useRequest from '~/hooks/useRequest';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
const Container = styled.div` const Container = styled.div`
...@@ -130,202 +127,192 @@ function formatDuration(seconds: number) { ...@@ -130,202 +127,192 @@ function formatDuration(seconds: number) {
); );
} }
export type AudioRef = {
save(filename: string): void;
};
export type AudioProps = { export type AudioProps = {
audioContext?: AudioContext; audioContext?: AudioContext;
src?: string; data?: BlobResponse;
cache?: number; loading?: boolean;
error?: Error;
onLoading?: () => unknown; onLoading?: () => unknown;
onLoad?: (audio: {sampleRate: number; duration: number}) => unknown; onLoad?: (audio: {sampleRate: number; duration: number}) => unknown;
}; };
const Audio = React.forwardRef<AudioRef, AudioProps & WithStyled>( const Audio: FunctionComponent<AudioProps & WithStyled> = ({
({audioContext, src, cache, onLoading, onLoad, className}, ref) => { audioContext,
const {t} = useTranslation('common'); data,
loading,
const {data, error, loading} = useRequest<BlobResponse>(src ?? null, fetcher, { error,
dedupingInterval: cache ?? 2000 onLoading,
}); onLoad,
className
useImperativeHandle(ref, () => ({ }) => {
save: (filename: string) => { const {t} = useTranslation('common');
if (data) {
const ext = data.type ? mime.extension(data.type) : null; const timer = useRef<number | null>(null);
saveAs(data.data, filename.replace(/[/\\?%*:|"<>]/g, '_') + (ext ? `.${ext}` : '')); 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 timer = useRef<number | null>(null); const [playing, setPlaying] = useState(false);
const player = useRef<AudioPlayer | null>(null); const [volumn, setVolumn] = useState(100);
const [sliderValue, setSliderValue] = useState(0); const [playAfterSeek, setPlayAfterSeek] = useState(false);
const [offset, setOffset] = useState(0);
const [duration, setDuration] = useState('00:00'); const play = useCallback(() => player.current?.play(), []);
const [decoding, setDecoding] = useState(false); const pause = useCallback(() => player.current?.pause(), []);
const [playing, setPlaying] = useState(false); const toggle = useCallback(() => player.current?.toggle(), []);
const [volumn, setVolumn] = useState(100); const change = useCallback((value: number) => {
const [playAfterSeek, setPlayAfterSeek] = useState(false); if (!player.current) {
return;
const play = useCallback(() => player.current?.play(), []); }
const pause = useCallback(() => player.current?.pause(), []); setOffset((value / SLIDER_MAX) * player.current.duration);
const toggle = useCallback(() => player.current?.toggle(), []); setSliderValue(value);
const change = useCallback((value: number) => { }, []);
if (!player.current) { const startSeek = useCallback(() => {
return; setPlayAfterSeek(playing);
} pause();
setOffset((value / SLIDER_MAX) * player.current.duration); }, [playing, pause]);
setSliderValue(value); const stopSeek = useCallback(() => {
}, []); if (!player.current) {
const startSeek = useCallback(() => { return;
setPlayAfterSeek(playing); }
pause(); player.current.seek(offset);
}, [playing, pause]); if (playAfterSeek && offset < player.current.duration) {
const stopSeek = useCallback(() => { play();
if (!player.current) { }
return; }, [play, offset, playAfterSeek]);
} const toggleMute = useCallback(() => {
player.current.seek(offset); if (player.current) {
if (playAfterSeek && offset < player.current.duration) { player.current.toggleMute();
play(); setVolumn(player.current.volumn);
} }
}, [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]);
useEffect(() => { const tick = useCallback(() => {
let p: AudioPlayer | null = null; if (player.current) {
if (data) { const current = player.current.current;
(async () => { setOffset(current);
setDecoding(true); setSliderValue(Math.floor((current / player.current.duration) * SLIDER_MAX));
onLoading?.(); }
setOffset(0); }, []);
setSliderValue(0); const startTimer = useCallback(() => {
setDuration('00:00'); tick();
p = new AudioPlayer({ timer.current = (window.setInterval(tick, 250) as unknown) as number;
context: audioContext, }, [tick]);
onplay: () => { const stopTimer = useCallback(() => {
setPlaying(true); if (player.current) {
startTimer(); if (player.current.current >= player.current.duration) {
}, tick();
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) { if (timer.current) {
setPlaying(false); window.clearInterval(timer.current);
p.dispose(); timer.current = null;
player.current = null; }
} }, [tick]);
};
}, [data, startTimer, stopTimer, onLoading, onLoad, audioContext]);
const volumnIcon = useMemo(() => { useEffect(() => {
if (volumn === 0) { if (player.current) {
return 'mute'; player.current.volumn = volumn;
} }
if (volumn <= 50) { }, [volumn]);
return 'volumn-low';
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) { const volumnIcon = useMemo(() => {
return <SyncLoader color={primaryColor} size="15px" />; if (volumn === 0) {
return 'mute';
} }
if (volumn <= 50) {
if (error) { return 'volumn-low';
return <div>{t('common:error')}</div>;
} }
return 'volumn';
}, [volumn]);
return ( if (loading) {
<Container className={className}> return <SyncLoader color={primaryColor} size="15px" />;
<a className={`control ${decoding ? 'disabled' : ''}`} onClick={toggle}> }
{decoding ? <PuffLoader size="16px" /> : <Icon type={playing ? 'pause' : 'play'} />}
</a> if (error) {
<div className="slider"> return <div>{t('common:error')}</div>;
<RangeSlider }
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} min={0}
max={SLIDER_MAX} max={100}
step={1} step={1}
value={sliderValue} onChange={setVolumn}
disabled={decoding} orientation="vertical"
onChange={change}
onChangeStart={startSeek}
onChangeComplete={stopSeek}
/> />
</div> }
<span className="time"> >
{formatDuration(offset)}/{duration} <a className="control volumn" onClick={toggleMute}>
</span> <Icon type={volumnIcon} />
<Tippy </a>
placement="top" </Tippy>
animation="shift-away-subtle" </Container>
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>
);
}
);
export default Audio; export default Audio;
import React, {FunctionComponent} from 'react'; 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 Icon from '~/components/Icon';
import Properties from '~/components/GraphPage/Properties'; import Properties from '~/components/GraphPage/Properties';
...@@ -15,7 +15,7 @@ const Dialog = styled.div` ...@@ -15,7 +15,7 @@ const Dialog = styled.div`
height: 100vh; height: 100vh;
overscroll-behavior: none; overscroll-behavior: none;
background-color: var(--mask-color); background-color: var(--mask-color);
z-index: 999; z-index: ${zIndexes.dialog};
${transitionProps('background-color')} ${transitionProps('background-color')}
> .modal { > .modal {
...@@ -42,8 +42,8 @@ const Dialog = styled.div` ...@@ -42,8 +42,8 @@ const Dialog = styled.div`
> .modal-close { > .modal-close {
flex: none; flex: none;
${size(em(14, 18), em(14, 18))} ${size(em(20, 18), em(20, 18))}
font-size: ${em(14, 18)}; font-size: ${em(20, 18)};
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
} }
......
import React, {useImperativeHandle, useLayoutEffect, useState} from 'react'; import React, {FunctionComponent, useLayoutEffect, useState} from 'react';
import {WithStyled, primaryColor} from '~/utils/style'; import {WithStyled, primaryColor} from '~/utils/style';
import type {BlobResponse} from '~/utils/fetch'; import type {BlobResponse} from '~/utils/fetch';
import GridLoader from 'react-spinners/GridLoader'; 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'; import {useTranslation} from 'react-i18next';
export type ImageRef = {
save(filename: string): void;
};
type ImageProps = { type ImageProps = {
src?: string; data?: BlobResponse;
cache?: number; 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 {t} = useTranslation('common');
const [url, setUrl] = useState(''); 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 // use useLayoutEffect hook to prevent image render after url revoked
useLayoutEffect(() => { useLayoutEffect(() => {
...@@ -55,7 +37,7 @@ const Image = React.forwardRef<ImageRef, ImageProps & WithStyled>(({src, cache, ...@@ -55,7 +37,7 @@ const Image = React.forwardRef<ImageRef, ImageProps & WithStyled>(({src, cache,
return <div>{t('common:error')}</div>; return <div>{t('common:error')}</div>;
} }
return <img className={className} src={url} />; return <img className={className} src={url} onClick={onClick} />;
}); };
export default Image; export default Image;
import Audio, {AudioProps, AudioRef} from '~/components/Audio'; import Audio, {AudioProps} from '~/components/Audio';
import React, {FunctionComponent, useCallback, useState} from 'react'; 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 {format} from 'd3-format';
import styled from 'styled-components'; import styled from 'styled-components';
...@@ -32,15 +32,8 @@ const AudioChart: FunctionComponent<AudioChartProps> = ({audioContext, ...props} ...@@ -32,15 +32,8 @@ const AudioChart: FunctionComponent<AudioChartProps> = ({audioContext, ...props}
); );
const content = useCallback( const content = useCallback(
(ref: React.RefObject<AudioRef>, src: string) => ( (props: SampleEntityProps) => (
<StyledAudio <StyledAudio audioContext={audioContext} {...props} onLoading={onLoading} onLoad={onLoad} />
audioContext={audioContext}
src={src}
cache={cache}
onLoading={onLoading}
onLoad={onLoad}
ref={ref}
/>
), ),
[onLoading, onLoad, audioContext] [onLoading, onLoad, audioContext]
); );
......
import Image, {ImageRef} from '~/components/Image';
import React, {FunctionComponent, useCallback} from 'react'; 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 {size, transitionProps} from '~/utils/style';
import Image from '~/components/Image';
import ImagePreviewer from '~/components/SamplePage/ImagePreviewer';
import styled from 'styled-components'; import styled from 'styled-components';
const StyledImage = styled(Image)<{brightness?: number; contrast?: number; fit?: boolean}>` const StyledImage = styled(Image)<{brightness?: number; contrast?: number; fit?: boolean}>`
...@@ -23,12 +28,13 @@ type ImageChartProps = { ...@@ -23,12 +28,13 @@ type ImageChartProps = {
const ImageChart: FunctionComponent<ImageChartProps> = ({brightness, contrast, fit, ...props}) => { const ImageChart: FunctionComponent<ImageChartProps> = ({brightness, contrast, fit, ...props}) => {
const content = useCallback( const content = useCallback(
(ref: React.RefObject<ImageRef>, src: string) => ( (props: SampleEntityProps) => <StyledImage {...props} brightness={brightness} contrast={contrast} fit={fit} />,
<StyledImage src={src} cache={cache} ref={ref} brightness={brightness} contrast={contrast} fit={fit} />
),
[brightness, contrast, 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; 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 React, {FunctionComponent, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {ellipsis, em, primaryColor, rem, size, transitionProps} from '~/utils/style'; import {ellipsis, em, primaryColor, rem, size, transitionProps} from '~/utils/style';
import type {BlobResponse} from '~/utils/fetch';
import ChartToolbox from '~/components/ChartToolbox'; import ChartToolbox from '~/components/ChartToolbox';
import GridLoader from 'react-spinners/GridLoader'; import GridLoader from 'react-spinners/GridLoader';
import type {Run} from '~/types'; import type {Run} from '~/types';
import StepSlider from '~/components/SamplePage/StepSlider'; import StepSlider from '~/components/SamplePage/StepSlider';
import {fetcher} from '~/utils/fetch';
import {formatTime} from '~/utils'; import {formatTime} from '~/utils';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
import mime from 'mime-types';
import queryString from 'query-string'; import queryString from 'query-string';
import {saveAs} from 'file-saver';
import styled from 'styled-components'; import styled from 'styled-components';
import useRequest from '~/hooks/useRequest';
import {useRunningRequest} from '~/hooks/useRequest'; import {useRunningRequest} from '~/hooks/useRequest';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
...@@ -64,7 +69,7 @@ const Title = styled.div<{color: string}>` ...@@ -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-grow: 1;
flex-shrink: 1; flex-shrink: 1;
margin: ${em(20)} 0; margin: ${em(20)} 0;
...@@ -72,6 +77,7 @@ const Container = styled.div<{brightness?: number; contrast?: number; fit?: bool ...@@ -72,6 +77,7 @@ const Container = styled.div<{brightness?: number; contrast?: number; fit?: bool
justify-content: center; justify-content: center;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
cursor: ${props => (props.preview ? 'zoom-in' : 'default')};
`; `;
const Footer = styled.div` const Footer = styled.div`
...@@ -103,25 +109,44 @@ export type SampleChartBaseProps = { ...@@ -103,25 +109,44 @@ export type SampleChartBaseProps = {
running?: boolean; running?: boolean;
}; };
type SampleChartRef = { export type SampleEntityProps = {
save: (filename: string) => void; 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 SampleChartProps = {
type: 'image' | 'audio'; type: 'image' | 'audio';
cache: number; cache: number;
step?: number;
footer?: JSX.Element; footer?: JSX.Element;
content: (ref: React.RefObject<SampleChartRef>, src: string) => JSX.Element; content: (props: SampleEntityProps) => React.ReactNode;
previewer?: (props: SamplePreviewerProps) => React.ReactNode;
} & SampleChartBaseProps; } & SampleChartBaseProps;
const getUrl = (type: string, index: number, run: string, tag: string, wallTime: number): string => const getUrl = (type: string, index: number, run: string, tag: string, wallTime: number): string =>
`/${type}/${type}?${queryString.stringify({index, ts: wallTime, run, tag})}`; `/${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 {t, i18n} = useTranslation(['sample', 'common']);
const sampleRef = useRef<SampleChartRef>(null);
const {data, error, loading} = useRunningRequest<SampleData[]>( const {data, error, loading} = useRunningRequest<SampleData[]>(
`/${type}/list?${queryString.stringify({run: run.label, tag})}`, `/${type}/list?${queryString.stringify({run: run.label, tag})}`,
!!running !!running
...@@ -129,12 +154,32 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({run, tag, running, ty ...@@ -129,12 +154,32 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({run, tag, running, ty
const steps = useMemo(() => data?.map(item => item.step) ?? [], [data]); const steps = useMemo(() => data?.map(item => item.step) ?? [], [data]);
const [preview, setPreview] = useState(false);
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [src, setSrc] = useState<string>(); const [src, setSrc] = useState<string>();
const cached = useRef<Record<number, {src: string; timer: number}>>({}); const cached = useRef<Record<number, {src: string; timer: number}>>({});
const timer = useRef<number | null>(null); 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 // clear cache if tag or run changed
useEffect(() => { useEffect(() => {
Object.values(cached.current).forEach(({timer}) => clearTimeout(timer)); Object.values(cached.current).forEach(({timer}) => clearTimeout(timer));
...@@ -158,10 +203,6 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({run, tag, running, ty ...@@ -158,10 +203,6 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({run, tag, running, ty
timer.current = null; timer.current = null;
}, [type, step, run.label, tag, wallTime, data, cache]); }, [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(() => { useEffect(() => {
if (cached.current[step]) { if (cached.current[step]) {
// cached, return immediately // cached, return immediately
...@@ -200,6 +241,33 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({run, tag, running, ty ...@@ -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(() => { const Content = useMemo(() => {
// show loading when deferring // show loading when deferring
if (loading || !cached.current[step] || !viewed) { if (loading || !cached.current[step] || !viewed) {
...@@ -211,14 +279,35 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({run, tag, running, ty ...@@ -211,14 +279,35 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({run, tag, running, ty
if (isEmpty(data)) { if (isEmpty(data)) {
return <span>{t('common:empty')}</span>; return <span>{t('common:empty')}</span>;
} }
if (src) { if (entityProps) {
return content(sampleRef, src); return content(entityProps);
} }
return null; 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 ( return (
<Wrapper> <Wrapper ref={wrapperRef}>
<Title color={run.colors[0]}> <Title color={run.colors[0]}>
<h4>{tag}</h4> <h4>{tag}</h4>
<span>{run.label}</span> <span>{run.label}</span>
...@@ -226,7 +315,10 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({run, tag, running, ty ...@@ -226,7 +315,10 @@ const SampleChart: FunctionComponent<SampleChartProps> = ({run, tag, running, ty
<StepSlider value={step} steps={steps} onChange={setStep} onChangeComplete={cacheSrc}> <StepSlider value={step} steps={steps} onChange={setStep} onChangeComplete={cacheSrc}>
{formatTime(wallTime, i18n.language)} {formatTime(wallTime, i18n.language)}
</StepSlider> </StepSlider>
<Container ref={container}>{Content}</Container> <Container ref={container} preview={!!previewer && !!src} onClick={() => setPreview(true)}>
{Content}
</Container>
{Previewer}
<Footer> <Footer>
<ChartToolbox <ChartToolbox
items={[ items={[
......
import React, {FunctionComponent, useCallback, useEffect, useState} from 'react'; import React, {FunctionComponent, useCallback, useEffect, useState} from 'react';
import {em, transitionProps} from '~/utils/style'; import {em, transitionProps} from '~/utils/style';
import Icon from '~/components/Icon';
import RangeSlider from '~/components/RangeSlider'; import RangeSlider from '~/components/RangeSlider';
import Tippy from '@tippyjs/react';
import styled from 'styled-components'; import styled from 'styled-components';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
const Label = styled.div` const Label = styled.div`
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
color: var(--text-light-color); color: var(--text-light-color);
font-size: ${em(12)}; font-size: ${em(12)};
margin-bottom: ${em(5)}; margin-bottom: ${em(5)};
...@@ -16,6 +19,37 @@ const Label = styled.div` ...@@ -16,6 +19,37 @@ const Label = styled.div`
> :not(:first-child) { > :not(:first-child) {
flex-grow: 0; 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)` const FullWidthRangeSlider = styled(RangeSlider)`
...@@ -43,10 +77,34 @@ const StepSlider: FunctionComponent<StepSliderProps> = ({onChange, onChangeCompl ...@@ -43,10 +77,34 @@ const StepSlider: FunctionComponent<StepSliderProps> = ({onChange, onChangeCompl
[onChange] [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 ( return (
<> <>
<Label> <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>} {children && <span>{children}</span>}
</Label> </Label>
<FullWidthRangeSlider <FullWidthRangeSlider
......
...@@ -9,7 +9,8 @@ import { ...@@ -9,7 +9,8 @@ import {
math, math,
sameBorder, sameBorder,
size, size,
transitionProps transitionProps,
zIndexes
} from '~/utils/style'; } from '~/utils/style';
import Checkbox from '~/components/Checkbox'; import Checkbox from '~/components/Checkbox';
...@@ -77,7 +78,7 @@ const List = styled.div<{opened?: boolean; empty?: boolean}>` ...@@ -77,7 +78,7 @@ const List = styled.div<{opened?: boolean; empty?: boolean}>`
border-top-color: var(--border-color); border-top-color: var(--border-color);
${borderRadiusShortHand('bottom', borderRadius)} ${borderRadiusShortHand('bottom', borderRadius)}
display: ${props => (props.opened ? 'block' : 'none')}; display: ${props => (props.opened ? 'block' : 'none')};
z-index: 9999; z-index: ${zIndexes.component};
line-height: 1; line-height: 1;
background-color: inherit; background-color: inherit;
box-shadow: 0 5px 6px 0 rgba(0, 0, 0, 0.05); box-shadow: 0 5px 6px 0 rgba(0, 0, 0, 0.05);
......
...@@ -32,6 +32,15 @@ export const asideWidth = rem(260); ...@@ -32,6 +32,15 @@ export const asideWidth = rem(260);
export const borderRadius = '4px'; export const borderRadius = '4px';
export const progressSpinnerSize = '20px'; export const progressSpinnerSize = '20px';
export const zIndexes = {
progress: 99999,
toast: 90000,
tooltip: 80000,
component: 30000,
dialog: 20000,
header: 10000
};
// shims // shims
// TODO: remove and use colors in theme instead // TODO: remove and use colors in theme instead
export const primaryColor = colors.primary.default; export const primaryColor = colors.primary.default;
...@@ -143,7 +152,7 @@ export const GlobalStyle = createGlobalStyle` ...@@ -143,7 +152,7 @@ export const GlobalStyle = createGlobalStyle`
#nprogress .bar { #nprogress .bar {
background-color: var(--progress-bar-color); background-color: var(--progress-bar-color);
z-index: 99999; z-index: ${zIndexes.progress};
${position('fixed', 0, null, null, 0)} ${position('fixed', 0, null, null, 0)}
${size('2px', '100%')} ${size('2px', '100%')}
${transitionProps('background-color')} ${transitionProps('background-color')}
...@@ -161,7 +170,7 @@ export const GlobalStyle = createGlobalStyle` ...@@ -161,7 +170,7 @@ export const GlobalStyle = createGlobalStyle`
#nprogress .spinner { #nprogress .spinner {
display: block; display: block;
z-index: 99999; z-index: ${zIndexes.progress};
${position('fixed', progressSpinnerSize, progressSpinnerSize, null, null)} ${position('fixed', progressSpinnerSize, progressSpinnerSize, null, null)}
} }
...@@ -190,7 +199,7 @@ export const GlobalStyle = createGlobalStyle` ...@@ -190,7 +199,7 @@ export const GlobalStyle = createGlobalStyle`
} }
.Toastify__toast-container { .Toastify__toast-container {
z-index: 10001; z-index: ${zIndexes.toast};
.Toastify__toast { .Toastify__toast {
border-radius: ${borderRadius}; border-radius: ${borderRadius};
...@@ -207,7 +216,7 @@ export const GlobalStyle = createGlobalStyle` ...@@ -207,7 +216,7 @@ export const GlobalStyle = createGlobalStyle`
} }
[data-tippy-root] .tippy-box { [data-tippy-root] .tippy-box {
z-index: 10002; z-index: ${zIndexes.tooltip};
color: var(--text-color); color: var(--text-color);
background-color: var(--background-color); background-color: var(--background-color);
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
......
...@@ -62,6 +62,8 @@ export const themes = { ...@@ -62,6 +62,8 @@ export const themes = {
progressBarColor: '#fff', progressBarColor: '#fff',
maskColor: 'rgba(255, 255, 255, 0.8)', maskColor: 'rgba(255, 255, 255, 0.8)',
samplePreviewMaskColor: 'rgba(0, 0, 0, 0.5)',
graphUploaderBackgroundColor: '#f9f9f9', graphUploaderBackgroundColor: '#f9f9f9',
graphUploaderActiveBackgroundColor: '#f2f6ff', graphUploaderActiveBackgroundColor: '#f2f6ff',
graphCopyrightColor: '#ddd', graphCopyrightColor: '#ddd',
...@@ -102,6 +104,8 @@ export const themes = { ...@@ -102,6 +104,8 @@ export const themes = {
progressBarColor: '#fff', progressBarColor: '#fff',
maskColor: 'rgba(0, 0, 0, 0.8)', maskColor: 'rgba(0, 0, 0, 0.8)',
samplePreviewMaskColor: 'rgba(0, 0, 0, 0.8)',
graphUploaderBackgroundColor: '#262629', graphUploaderBackgroundColor: '#262629',
graphUploaderActiveBackgroundColor: '#303033', graphUploaderActiveBackgroundColor: '#303033',
graphCopyrightColor: '#565657', graphCopyrightColor: '#565657',
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册