Update frontend with Tailwind.

- Replace inline CSS with Tailwind classes
- Improve page layout and scaling
- Add icons to ControlBar
- Small refactor of play/pause logic
- Add basic (not by any means final) colours
This commit is contained in:
Rob Watson 2022-01-14 12:54:54 +01:00
parent ec3ac8996d
commit a33057651d
12 changed files with 138 additions and 176 deletions

View File

@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@heroicons/react": "^1.0.5",
"@improbable-eng/grpc-web": "^0.14.1", "@improbable-eng/grpc-web": "^0.14.1",
"@testing-library/jest-dom": "^5.11.4", "@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",

View File

@ -1,6 +1,6 @@
{ {
"short_name": "React App", "short_name": "Clipper",
"name": "Create React App Sample", "name": "Clipper",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",
@ -20,6 +20,7 @@
], ],
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"orientation": "landscape",
"theme_color": "#000000", "theme_color": "#000000",
"background_color": "#ffffff" "background_color": "#ffffff"
} }

View File

@ -1,7 +0,0 @@
body {
background-color: #333;
}
.App {
text-align: center;
}

View File

@ -13,15 +13,15 @@ import { Overview, CanvasLogicalWidth } from './Overview';
import { Waveform } from './Waveform'; import { Waveform } from './Waveform';
import { ControlBar } from './ControlBar'; import { ControlBar } from './ControlBar';
import { SeekBar } from './SeekBar'; import { SeekBar } from './SeekBar';
import './App.css';
import { firstValueFrom, from, Observable } from 'rxjs'; import { firstValueFrom, from, Observable } from 'rxjs';
import { first, map } from 'rxjs/operators'; import { first, map } from 'rxjs/operators';
import millisFromDuration from './helpers/millisFromDuration'; import millisFromDuration from './helpers/millisFromDuration';
import { zoomViewportIn, zoomViewportOut } from './helpers/zoom'; import { zoomViewportIn, zoomViewportOut } from './helpers/zoom';
import { ExternalLinkIcon } from '@heroicons/react/solid';
// ported from backend, where should they live? // ported from backend, where should they live?
const thumbnailWidth = 177; const thumbnailWidth = 177; // height 100
const thumbnailHeight = 100;
const initialViewportCanvasPixels = 100; const initialViewportCanvasPixels = 100;
@ -40,6 +40,11 @@ export interface VideoPosition {
percent: number; percent: number;
} }
export enum PlayState {
Paused,
Playing,
}
const video = document.createElement('video'); const video = document.createElement('video');
const audio = document.createElement('audio'); const audio = document.createElement('audio');
@ -50,6 +55,7 @@ function App(): JSX.Element {
const [overviewPeaks, setOverviewPeaks] = useState<Observable<number[]>>( const [overviewPeaks, setOverviewPeaks] = useState<Observable<number[]>>(
from([]) from([])
); );
const [playState, setPlayState] = useState(PlayState.Paused);
// position stores the current playback position. positionRef makes it // position stores the current playback position. positionRef makes it
// available inside a setInterval callback. // available inside a setInterval callback.
@ -98,7 +104,7 @@ function App(): JSX.Element {
currentTimeToFrame(position.currentTime) < selection.end && currentTimeToFrame(position.currentTime) < selection.end &&
currentTimeToFrame(currTime) >= selection.end currentTimeToFrame(currTime) >= selection.end
) { ) {
handlePause(); pause();
} }
// update the current position // update the current position
@ -114,7 +120,7 @@ function App(): JSX.Element {
useEffect(() => { useEffect(() => {
document.addEventListener('keypress', handleKeyPress); document.addEventListener('keypress', handleKeyPress);
return () => document.removeEventListener('keypress', handleKeyPress); return () => document.removeEventListener('keypress', handleKeyPress);
}, [selection]); }, [playState]);
// load audio when MediaSet is loaded: // load audio when MediaSet is loaded:
useEffect(() => { useEffect(() => {
@ -189,11 +195,7 @@ function App(): JSX.Element {
return; return;
} }
if (audio.paused) { handleTogglePlay();
handlePlay();
} else {
handlePause();
}
}; };
// handler called when the selection in the overview (zoom setting) is changed. // handler called when the selection in the overview (zoom setting) is changed.
@ -227,18 +229,30 @@ function App(): JSX.Element {
video.currentTime = currentTime; video.currentTime = currentTime;
}; };
const handlePlay = () => { const handleTogglePlay = () => {
audio.play(); if (playState == PlayState.Paused) {
video.play(); play();
} else {
pause();
}
}; };
const handlePause = () => { const play = () => {
audio.play();
video.play();
setPlayState(PlayState.Playing);
};
const pause = () => {
video.pause(); video.pause();
audio.pause(); audio.pause();
if (selection.start != selection.end) { if (selection.start != selection.end) {
setPositionFromFrame(selection.start); setPositionFromFrame(selection.start);
} }
setPlayState(PlayState.Paused);
}; };
const handleClip = () => { const handleClip = () => {
@ -370,17 +384,8 @@ function App(): JSX.Element {
// render component // render component
const containerStyles = {
border: '1px solid black',
width: '90%',
margin: '1em auto',
minHeight: '500px',
height: '700px',
display: 'flex',
flexDirection: 'column',
} as React.CSSProperties;
const offsetPixels = Math.floor(thumbnailWidth / 2); const offsetPixels = Math.floor(thumbnailWidth / 2);
const marginClass = 'mx-[88px]'; // offsetPixels
if (mediaSet == null) { if (mediaSet == null) {
// TODO: improve // TODO: improve
@ -389,33 +394,48 @@ function App(): JSX.Element {
return ( return (
<> <>
<div className="App"> <div className="App bg-gray-800 h-screen flex flex-col">
<div style={containerStyles}> <header className="bg-green-900 h-16 grow-0 flex items-center mb-12 px-[88px]">
<ControlBar <h1 className="text-3xl font-bold">Clipper</h1>
onPlay={handlePlay} </header>
onPause={handlePause} <div className="flex flex-col grow-1 bg-gray-800 w-full h-full mx-auto">
onClip={handleClip} <div className={`flex flex-col grow ${marginClass}`}>
onZoomIn={handleZoomIn} <div className="flex grow-0 h-8 pt-4 pb-2 items-center space-x-2 text-white">
onZoomOut={handleZoomOut} <span className="text-gray-300">{mediaSet.author}</span>
/> <span>/</span>
<span>{mediaSet.title}</span>
<a
href={`https://www.youtube.com/watch?v=${mediaSet.youtubeId}`}
target="_blank"
rel="noreferrer"
title="Open in YouTube"
>
<ExternalLinkIcon className="h-6 w-6 text-gray-500 hover:text-gray-200" />
</a>
</div>
<ControlBar
playState={playState}
onTogglePlay={handleTogglePlay}
onClip={handleClip}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
/>
<Overview <Overview
peaks={overviewPeaks} peaks={overviewPeaks}
mediaSet={mediaSet} mediaSet={mediaSet}
offsetPixels={offsetPixels} viewport={viewport}
height={80} position={position}
viewport={viewport} onSelectionChange={handleOverviewSelectionChange}
position={position} />
onSelectionChange={handleOverviewSelectionChange}
/>
<Waveform <Waveform
mediaSet={mediaSet} mediaSet={mediaSet}
position={position} position={position}
viewport={viewport} viewport={viewport}
offsetPixels={offsetPixels} onSelectionChange={handleWaveformSelectionChange}
onSelectionChange={handleWaveformSelectionChange} />
/> </div>
<SeekBar <SeekBar
position={video.currentTime} position={video.currentTime}
@ -432,10 +452,9 @@ function App(): JSX.Element {
video={video} video={video}
position={position} position={position}
duration={millisFromDuration(mediaSet.videoDuration)} duration={millisFromDuration(mediaSet.videoDuration)}
height={thumbnailHeight}
/> />
</div> </div>
<ul style={{ listStyleType: 'none' } as React.CSSProperties}> <ul className="hidden">
<li>Frames: {mediaSet.audioFrames}</li> <li>Frames: {mediaSet.audioFrames}</li>
<li> <li>
Viewport (frames): {viewport.start} to {viewport.end} Viewport (frames): {viewport.start} to {viewport.end}

View File

@ -1,42 +1,60 @@
import React from 'react'; import React from 'react';
import { PlayState } from './App';
import {
CloudDownloadIcon,
FastForwardIcon,
PauseIcon,
PlayIcon,
RewindIcon,
ZoomInIcon,
ZoomOutIcon,
} from '@heroicons/react/solid';
interface Props { interface Props {
onPlay: () => void; playState: PlayState;
onPause: () => void; onTogglePlay: () => void;
onClip: () => void; onClip: () => void;
onZoomIn: () => void; onZoomIn: () => void;
onZoomOut: () => void; onZoomOut: () => void;
} }
const ControlBar: React.FC<Props> = React.memo((props: Props) => { const ControlBar: React.FC<Props> = React.memo((props: Props) => {
const styles = { width: '100%', flexGrow: 0 }; const buttonStyle =
const buttonStyles = { 'bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded';
cursor: 'pointer',
background: 'black', const largeButtonStyle =
outline: 'none', 'bg-green-700 hover:bg-green-600 text-white font-bold py-2 px-4 rounded absolute right-0';
border: 'none',
color: 'green', const iconStyle = 'inline h-6 w-6 text-white-500';
display: 'inline-block',
margin: '0 2px', const playPauseComponent =
}; props.playState == PlayState.Playing ? (
<PauseIcon className={iconStyle} />
) : (
<PlayIcon className={iconStyle} />
);
return ( return (
<> <>
<div style={styles}> <div className="relative grow-0 w-full py-2 space-x-2">
<button style={buttonStyles} onClick={props.onPlay}> <button className={buttonStyle}>
Play <RewindIcon className={iconStyle} />
</button> </button>
<button style={buttonStyles} onClick={props.onPause}> <button className={buttonStyle} onClick={props.onTogglePlay}>
Pause {playPauseComponent}
</button> </button>
<button style={buttonStyles} onClick={props.onClip}> <button className={buttonStyle}>
Clip <FastForwardIcon className={iconStyle} />
</button> </button>
<button style={buttonStyles} onClick={props.onZoomIn}> <button className={buttonStyle} onClick={props.onZoomIn}>
Zoom In <ZoomInIcon className={iconStyle} />
</button> </button>
<button style={buttonStyles} onClick={props.onZoomOut}> <button className={buttonStyle} onClick={props.onZoomOut}>
Zoom Out <ZoomOutIcon className={iconStyle} />
</button>
<button className={largeButtonStyle} onClick={props.onClip}>
<CloudDownloadIcon className={`${iconStyle} mr-2`} />
Download clip
</button> </button>
</div> </div>
</> </>

View File

@ -10,7 +10,6 @@ interface Styles {
interface Props { interface Props {
width: number; width: number;
height: number; height: number;
zIndex: number;
emptySelectionAction: EmptySelectionAction; emptySelectionAction: EmptySelectionAction;
styles: Styles; styles: Styles;
position: number | null; position: number | null;
@ -48,7 +47,6 @@ const emptySelection: Selection = { start: 0, end: 0 };
export const HudCanvas: React.FC<Props> = ({ export const HudCanvas: React.FC<Props> = ({
width, width,
height, height,
zIndex,
emptySelectionAction, emptySelectionAction,
styles: { styles: {
borderLineWidth, borderLineWidth,
@ -66,7 +64,7 @@ export const HudCanvas: React.FC<Props> = ({
}); });
const [mode, setMode] = useState(Mode.Normal); const [mode, setMode] = useState(Mode.Normal);
const [hoverState, setHoverState] = useState(HoverState.Normal); const [hoverState, setHoverState] = useState(HoverState.Normal);
const [cursor, setCursor] = useState('auto'); const [cursor, setCursor] = useState('cursor-auto');
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const moveOffsetX = useRef(0); const moveOffsetX = useRef(0);
@ -189,11 +187,11 @@ export const HudCanvas: React.FC<Props> = ({
moveOffsetX.current = x; moveOffsetX.current = x;
} else if (isHoveringSelection(x)) { } else if (isHoveringSelection(x)) {
setMode(Mode.Dragging); setMode(Mode.Dragging);
setCursor('pointer'); setCursor('cursor-pointer');
moveOffsetX.current = x; moveOffsetX.current = x;
} else { } else {
setMode(Mode.Selecting); setMode(Mode.Selecting);
setCursor('col-resize'); setCursor('cursor-col-resize');
moveOffsetX.current = x; moveOffsetX.current = x;
setNewSelection({ start: x, end: x }); setNewSelection({ start: x, end: x });
} }
@ -206,15 +204,15 @@ export const HudCanvas: React.FC<Props> = ({
case Mode.Normal: { case Mode.Normal: {
if (isHoveringSelectionStart(x)) { if (isHoveringSelectionStart(x)) {
setHoverState(HoverState.OverSelectionStart); setHoverState(HoverState.OverSelectionStart);
setCursor('col-resize'); setCursor('cursor-col-resize');
} else if (isHoveringSelectionEnd(x)) { } else if (isHoveringSelectionEnd(x)) {
setHoverState(HoverState.OverSelectionEnd); setHoverState(HoverState.OverSelectionEnd);
setCursor('col-resize'); setCursor('cursor-col-resize');
} else if (isHoveringSelection(x)) { } else if (isHoveringSelection(x)) {
setHoverState(HoverState.OverSelection); setHoverState(HoverState.OverSelection);
setCursor('pointer'); setCursor('cursor-pointer');
} else { } else {
setCursor('auto'); setCursor('cursor-auto');
} }
break; break;
} }
@ -275,7 +273,7 @@ export const HudCanvas: React.FC<Props> = ({
} }
setMode(Mode.Normal); setMode(Mode.Normal);
setCursor('auto'); setCursor('cursor-auto');
if (newSelection.start == newSelection.end) { if (newSelection.start == newSelection.end) {
handleEmptySelectionAction(); handleEmptySelectionAction();
@ -300,22 +298,13 @@ export const HudCanvas: React.FC<Props> = ({
setHoverState(HoverState.Normal); setHoverState(HoverState.Normal);
}; };
const canvasStyles = {
display: 'block',
position: 'absolute',
width: '100%',
height: '100%',
zIndex: zIndex,
cursor: cursor,
} as React.CSSProperties;
return ( return (
<> <>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className={`block absolute w-full h-full ${cursor} z-20`}
width={width} width={width}
height={height} height={height}
style={canvasStyles}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}

View File

@ -13,8 +13,6 @@ export interface Selection {
interface Props { interface Props {
peaks: Observable<number[]>; peaks: Observable<number[]>;
mediaSet: MediaSet; mediaSet: MediaSet;
height: number;
offsetPixels: number;
position: VideoPosition; position: VideoPosition;
viewport: Frames; viewport: Frames;
onSelectionChange: (selection: Selection) => void; onSelectionChange: (selection: Selection) => void;
@ -26,8 +24,6 @@ export const CanvasLogicalHeight = 500;
export const Overview: React.FC<Props> = ({ export const Overview: React.FC<Props> = ({
peaks, peaks,
mediaSet, mediaSet,
height,
offsetPixels,
position, position,
viewport, viewport,
onSelectionChange, onSelectionChange,
@ -69,13 +65,6 @@ export const Overview: React.FC<Props> = ({
// render component // render component
const containerStyles = {
flexGrow: 0,
position: 'relative',
margin: `0 ${offsetPixels}px`,
height: `${height}px`,
} as React.CSSProperties;
const hudStyles = { const hudStyles = {
borderLineWidth: 4, borderLineWidth: 4,
borderStrokeStyle: 'red', borderStrokeStyle: 'red',
@ -85,7 +74,7 @@ export const Overview: React.FC<Props> = ({
return ( return (
<> <>
<div style={containerStyles}> <div className={`relative grow-0 h-[80px]`}>
<WaveformCanvas <WaveformCanvas
peaks={peaks} peaks={peaks}
channels={mediaSet.audioChannels} channels={mediaSet.audioChannels}
@ -93,13 +82,11 @@ export const Overview: React.FC<Props> = ({
height={CanvasLogicalHeight} height={CanvasLogicalHeight}
strokeStyle="black" strokeStyle="black"
fillStyle="#003300" fillStyle="#003300"
zIndex={1}
alpha={1} alpha={1}
></WaveformCanvas> ></WaveformCanvas>
<HudCanvas <HudCanvas
width={CanvasLogicalWidth} width={CanvasLogicalWidth}
height={CanvasLogicalHeight} height={CanvasLogicalHeight}
zIndex={1}
emptySelectionAction={EmptySelectionAction.SelectPrevious} emptySelectionAction={EmptySelectionAction.SelectPrevious}
styles={hudStyles} styles={hudStyles}
position={positionPixels} position={positionPixels}

View File

@ -24,7 +24,7 @@ export const SeekBar: React.FC<Props> = ({
onPositionChanged, onPositionChanged,
}: Props) => { }: Props) => {
const [mode, setMode] = useState(Mode.Normal); const [mode, setMode] = useState(Mode.Normal);
const [cursor, setCursor] = useState('auto'); const [cursor, setCursor] = useState('cursor-auto');
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
// render canvas // render canvas
@ -98,9 +98,9 @@ export const SeekBar: React.FC<Props> = ({
// TODO: improve mouse detection around knob. // TODO: improve mouse detection around knob.
if (y > InnerMargin && y < LogicalHeight - InnerMargin) { if (y > InnerMargin && y < LogicalHeight - InnerMargin) {
setCursor('pointer'); setCursor('cursor-pointer');
} else { } else {
setCursor('auto'); setCursor('cursor-auto');
} }
if (mode == Mode.Normal) return; if (mode == Mode.Normal) return;
@ -116,17 +116,10 @@ export const SeekBar: React.FC<Props> = ({
// render component // render component
const styles = {
width: '100%',
height: '30px',
margin: '0 auto',
cursor: cursor,
};
return ( return (
<> <>
<canvas <canvas
style={styles} className={`w-full h-[30px] mx-0 my-auto ${cursor}`}
ref={canvasRef} ref={canvasRef}
width={LogicalWidth} width={LogicalWidth}
height={LogicalHeight} height={LogicalHeight}

View File

@ -6,7 +6,6 @@ interface Props {
mediaSet: MediaSet; mediaSet: MediaSet;
position: VideoPosition; position: VideoPosition;
duration: number; duration: number;
height: number;
video: HTMLVideoElement; video: HTMLVideoElement;
} }
@ -14,7 +13,6 @@ export const VideoPreview: React.FC<Props> = ({
mediaSet, mediaSet,
position, position,
duration, duration,
height,
video, video,
}: Props) => { }: Props) => {
const videoCanvasRef = useRef<HTMLCanvasElement>(null); const videoCanvasRef = useRef<HTMLCanvasElement>(null);
@ -76,30 +74,15 @@ export const VideoPreview: React.FC<Props> = ({
// render component // render component
const containerStyles = {
height: height + 'px',
position: 'relative',
flexGrow: 0,
} as React.CSSProperties;
const canvasStyles = {
position: 'absolute',
width: '100%',
height: '100%',
display: 'block',
zIndex: 1,
} as React.CSSProperties;
return ( return (
<> <>
<div style={containerStyles}> <div className={`relative grow-0 h-[100px]`}>
<canvas <canvas
className="absolute block w-full h-full"
width="500" width="500"
height="100" height="100"
ref={videoCanvasRef} ref={videoCanvasRef}
style={canvasStyles}
></canvas> ></canvas>
<canvas style={canvasStyles}></canvas>
</div> </div>
</> </>
); );

View File

@ -10,7 +10,6 @@ interface Props {
mediaSet: MediaSet; mediaSet: MediaSet;
position: VideoPosition; position: VideoPosition;
viewport: Frames; viewport: Frames;
offsetPixels: number;
onSelectionChange: (selection: Selection) => void; onSelectionChange: (selection: Selection) => void;
} }
@ -21,7 +20,6 @@ export const Waveform: React.FC<Props> = ({
mediaSet, mediaSet,
position, position,
viewport, viewport,
offsetPixels,
onSelectionChange, onSelectionChange,
}: Props) => { }: Props) => {
const [peaks, setPeaks] = useState<Observable<number[]>>(from([])); const [peaks, setPeaks] = useState<Observable<number[]>>(from([]));
@ -115,13 +113,6 @@ export const Waveform: React.FC<Props> = ({
// render component // render component
const containerStyles = {
background: 'black',
margin: '0 ' + offsetPixels + 'px',
flexGrow: 1,
position: 'relative',
} as React.CSSProperties;
const hudStyles = { const hudStyles = {
borderLineWidth: 0, borderLineWidth: 0,
borderStrokeStyle: 'transparent', borderStrokeStyle: 'transparent',
@ -131,7 +122,7 @@ export const Waveform: React.FC<Props> = ({
return ( return (
<> <>
<div style={containerStyles}> <div className={`relative grow`}>
<WaveformCanvas <WaveformCanvas
peaks={peaks} peaks={peaks}
channels={mediaSet.audioChannels} channels={mediaSet.audioChannels}
@ -139,13 +130,11 @@ export const Waveform: React.FC<Props> = ({
height={CanvasLogicalHeight} height={CanvasLogicalHeight}
strokeStyle="green" strokeStyle="green"
fillStyle="black" fillStyle="black"
zIndex={0}
alpha={1} alpha={1}
></WaveformCanvas> ></WaveformCanvas>
<HudCanvas <HudCanvas
width={CanvasLogicalWidth} width={CanvasLogicalWidth}
height={CanvasLogicalHeight} height={CanvasLogicalHeight}
zIndex={1}
emptySelectionAction={EmptySelectionAction.SelectNothing} emptySelectionAction={EmptySelectionAction.SelectNothing}
styles={hudStyles} styles={hudStyles}
position={positionPixels} position={positionPixels}

View File

@ -10,18 +10,10 @@ interface Props {
channels: number; channels: number;
strokeStyle: string; strokeStyle: string;
fillStyle: string; fillStyle: string;
zIndex: number;
alpha: number; alpha: number;
} }
// Canvas is a generic component that renders a waveform to a canvas. // Canvas is a generic component that renders a waveform to a canvas.
//
// Properties:
//
// peaks: a 2d array of uint16s representing the peak values. Each inner array length should match logicalWidth
// strokeStyle: waveform style
// fillStyle: background style
// style: React.CSSProperties applied to canvas element
const WaveformCanvas: React.FC<Props> = React.memo((props: Props) => { const WaveformCanvas: React.FC<Props> = React.memo((props: Props) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
@ -71,21 +63,13 @@ const WaveformCanvas: React.FC<Props> = React.memo((props: Props) => {
})(); })();
}, [props.peaks]); }, [props.peaks]);
const canvasStyles = {
display: 'block',
position: 'absolute',
width: '100%',
height: '100%',
zIndex: props.zIndex,
} as React.CSSProperties;
return ( return (
<> <>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className={`block absolute w-full h-full z-10`}
width={props.width} width={props.width}
height={props.height} height={props.height}
style={canvasStyles}
></canvas> ></canvas>
</> </>
); );

View File

@ -1102,6 +1102,11 @@
minimatch "^3.0.4" minimatch "^3.0.4"
strip-json-comments "^3.1.1" strip-json-comments "^3.1.1"
"@heroicons/react@^1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.5.tgz#2fe4df9d33eb6ce6d5178a0f862e97b61c01e27d"
integrity sha512-UDMyLM2KavIu2vlWfMspapw9yii7aoLwzI2Hudx4fyoPwfKfxU8r3cL8dEBXOjcLG0/oOONZzbT14M1HoNtEcg==
"@humanwhocodes/config-array@^0.5.0": "@humanwhocodes/config-array@^0.5.0":
version "0.5.0" version "0.5.0"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"