isolate autosuggestion state (#273)

fixes #261
This commit is contained in:
Nolan Lawson 2018-05-06 16:25:17 -07:00 committed by GitHub
parent 67e41e4fb0
commit 07fb5e867c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 271 additions and 183 deletions

View file

@ -0,0 +1,62 @@
import { store } from '../_store/store'
export async function insertUsername (realm, username, startIndex, endIndex) {
let { currentInstance } = store.get()
let oldText = store.getComposeData(realm, 'text')
let pre = oldText.substring(0, startIndex)
let post = oldText.substring(endIndex)
let newText = `${pre}@${username} ${post}`
store.setComposeData(realm, {text: newText})
store.setForAutosuggest(currentInstance, realm, {autosuggestSearchResults: []})
}
export async function clickSelectedAutosuggestionUsername (realm) {
let {
composeSelectionStart,
autosuggestSearchText,
autosuggestSelected,
autosuggestSearchResults
} = store.get()
let account = autosuggestSearchResults[autosuggestSelected]
let startIndex = composeSelectionStart - autosuggestSearchText.length
let endIndex = composeSelectionStart
await insertUsername(realm, account.acct, startIndex, endIndex)
}
export function insertEmojiAtPosition (realm, emoji, startIndex, endIndex) {
let { currentInstance } = store.get()
let oldText = store.getComposeData(realm, 'text') || ''
let pre = oldText.substring(0, startIndex)
let post = oldText.substring(endIndex)
let newText = `${pre}:${emoji.shortcode}: ${post}`
store.setComposeData(realm, {text: newText})
store.setForAutosuggest(currentInstance, realm, {autosuggestSearchResults: []})
}
export async function clickSelectedAutosuggestionEmoji (realm) {
let {
composeSelectionStart,
autosuggestSearchText,
autosuggestSelected,
autosuggestSearchResults
} = store.get()
let emoji = autosuggestSearchResults[autosuggestSelected]
let startIndex = composeSelectionStart - autosuggestSearchText.length
let endIndex = composeSelectionStart
await insertEmojiAtPosition(realm, emoji, startIndex, endIndex)
}
export function selectAutosuggestItem (item) {
let {
currentComposeRealm,
composeSelectionStart,
autosuggestSearchText
} = store.get()
let startIndex = composeSelectionStart - autosuggestSearchText.length
let endIndex = composeSelectionStart
if (item.acct) {
/* no await */ insertUsername(currentComposeRealm, item.acct, startIndex, endIndex)
} else {
/* no await */ insertEmojiAtPosition(currentComposeRealm, item, startIndex, endIndex)
}
}

View file

@ -50,28 +50,6 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
} }
} }
export async function insertUsername (realm, username, startIndex, endIndex) {
let oldText = store.getComposeData(realm, 'text')
let pre = oldText.substring(0, startIndex)
let post = oldText.substring(endIndex)
let newText = `${pre}@${username} ${post}`
store.setComposeData(realm, {text: newText})
}
export async function clickSelectedAutosuggestionUsername (realm) {
let {
composeSelectionStart,
composeAutosuggestionSearchText,
composeAutosuggestionSelected,
composeAutosuggestionSearchResults
} = store.get()
composeAutosuggestionSelected = composeAutosuggestionSelected || 0
let account = composeAutosuggestionSearchResults[composeAutosuggestionSelected]
let startIndex = composeSelectionStart - composeAutosuggestionSearchText.length
let endIndex = composeSelectionStart
await insertUsername(realm, account.acct, startIndex, endIndex)
}
export function setReplySpoiler (realm, spoiler) { export function setReplySpoiler (realm, spoiler) {
let contentWarning = store.getComposeData(realm, 'contentWarning') let contentWarning = store.getComposeData(realm, 'contentWarning')
let contentWarningShown = store.getComposeData(realm, 'contentWarningShown') let contentWarningShown = store.getComposeData(realm, 'contentWarningShown')

View file

@ -28,25 +28,3 @@ export function insertEmoji (realm, emoji) {
let newText = `${pre}:${emoji.shortcode}: ${post}` let newText = `${pre}:${emoji.shortcode}: ${post}`
store.setComposeData(realm, {text: newText}) store.setComposeData(realm, {text: newText})
} }
export function insertEmojiAtPosition (realm, emoji, startIndex, endIndex) {
let oldText = store.getComposeData(realm, 'text') || ''
let pre = oldText.substring(0, startIndex)
let post = oldText.substring(endIndex)
let newText = `${pre}:${emoji.shortcode}: ${post}`
store.setComposeData(realm, {text: newText})
}
export async function clickSelectedAutosuggestionEmoji (realm) {
let {
composeSelectionStart,
composeAutosuggestionSearchText,
composeAutosuggestionSelected,
composeAutosuggestionSearchResults
} = store.get()
composeAutosuggestionSelected = composeAutosuggestionSelected || 0
let emoji = composeAutosuggestionSearchResults[composeAutosuggestionSelected]
let startIndex = composeSelectionStart - composeAutosuggestionSearchText.length
let endIndex = composeSelectionStart
await insertEmojiAtPosition(realm, emoji, startIndex, endIndex)
}

View file

@ -1,10 +1,10 @@
<div class="compose-autosuggest {shown ? 'shown' : ''} {realm === 'dialog' ? 'is-dialog' : ''}" <div class="compose-autosuggest {shown ? 'shown' : ''} {realm === 'dialog' ? 'is-dialog' : ''}"
aria-hidden="true" > aria-hidden="true" >
<ComposeAutosuggestionList <ComposeAutosuggestionList
items={searchResults} items={autosuggestSearchResults}
on:click="onClick(event)" on:click="onClick(event)"
{type} type={autosuggestType}
{selected} selected={autosuggestSelected}
/> />
</div> </div>
<style> <style>
@ -39,134 +39,65 @@
</style> </style>
<script> <script>
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { insertUsername } from '../../_actions/compose'
import { insertEmojiAtPosition } from '../../_actions/emoji'
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { once } from '../../_utils/once'
import ComposeAutosuggestionList from './ComposeAutosuggestionList.html' import ComposeAutosuggestionList from './ComposeAutosuggestionList.html'
import { import get from 'lodash-es/get'
searchAccountsByUsername as searchAccountsByUsernameInDatabase import { selectAutosuggestItem } from '../../_actions/autosuggest'
} from '../../_database/accountsAndRelationships'
import { observe } from 'svelte-extras' import { observe } from 'svelte-extras'
import { once } from '../../_utils/once'
const SEARCH_RESULTS_LIMIT = 4
const DATABASE_SEARCH_RESULTS_LIMIT = 30
const MIN_PREFIX_LENGTH = 1
const ACCOUNT_SEARCH_REGEX = new RegExp(`(?:\\s|^)(@\\S{${MIN_PREFIX_LENGTH},})$`)
const EMOJI_SEARCH_REGEX = new RegExp(`(?:\\s|^)(:[^:]{${MIN_PREFIX_LENGTH},})$`)
export default { export default {
oncreate () { oncreate () {
// perf improves for input responsiveness this._promiseChain = Promise.resolve()
this.observe('composeSelectionStart', () => { this.observe('shouldBeShown', (shouldBeShown) => {
scheduleIdleTask(() => {
let { composeSelectionStart } = this.get()
this.set({composeSelectionStartDeferred: composeSelectionStart})
})
})
this.observe('composeFocused', (composeFocused) => {
let updateFocusedState = () => {
scheduleIdleTask(() => {
let { composeFocused } = this.get()
this.set({composeFocusedDeferred: composeFocused})
})
}
// TODO: hack so that when the user clicks the button, and the textarea blurs, // TODO: hack so that when the user clicks the button, and the textarea blurs,
// we don't immediately hide the dropdown which would cause the click to get lost // we don't immediately hide the dropdown which would cause the click to get lost
if (composeFocused) { this._promiseChain = this._promiseChain.then(() => {
updateFocusedState() if (!shouldBeShown) {
} else { return Promise.race([
Promise.race([ new Promise(resolve => setTimeout(resolve, 200)),
new Promise(resolve => setTimeout(resolve, 200)), new Promise(resolve => this.once('autosuggestItemSelected', resolve))
new Promise(resolve => this.once('autosuggestItemSelected', resolve)) ])
]).then(updateFocusedState) }
} }).then(() => {
}) this.set({shown: shouldBeShown})
this.observe('searchText', async searchText => {
let { thisComposeFocused } = this.get()
if (!thisComposeFocused || !searchText) {
return
}
let type = searchText.startsWith('@') ? 'account' : 'emoji'
let results = (type === 'account')
? await this.searchAccounts(searchText)
: await this.searchEmoji(searchText)
this.store.set({
composeAutosuggestionSelected: 0,
composeAutosuggestionSearchText: searchText,
composeAutosuggestionSearchResults: results,
composeAutosuggestionType: type
}) })
}) })
this.observe('shown', shown => {
let { thisComposeFocused } = this.get()
if (!thisComposeFocused) {
return
}
this.store.set({composeAutosuggestionShown: shown})
})
}, },
methods: { methods: {
observe, observe,
once, once,
onClick (item) { onClick (item) {
this.fire('autosuggestItemSelected') this.fire('autosuggestItemSelected')
let { realm } = this.get() selectAutosuggestItem(item)
let { composeSelectionStart, composeAutosuggestionSearchText } = this.store.get()
let startIndex = composeSelectionStart - composeAutosuggestionSearchText.length
let endIndex = composeSelectionStart
if (item.acct) {
/* no await */ insertUsername(realm, item.acct, startIndex, endIndex)
} else {
/* no await */ insertEmojiAtPosition(realm, item, startIndex, endIndex)
}
},
async searchAccounts (searchText) {
searchText = searchText.substring(1)
let { currentInstance } = this.store.get()
let results = await searchAccountsByUsernameInDatabase(
currentInstance, searchText, DATABASE_SEARCH_RESULTS_LIMIT)
return results.slice(0, SEARCH_RESULTS_LIMIT)
},
searchEmoji (searchText) {
searchText = searchText.toLowerCase().substring(1)
let { currentCustomEmoji } = this.store.get()
let results = currentCustomEmoji.filter(emoji => emoji.shortcode.toLowerCase().startsWith(searchText))
.sort((a, b) => a.shortcode.toLowerCase() < b.shortcode.toLowerCase() ? -1 : 1)
.slice(0, SEARCH_RESULTS_LIMIT)
return results
} }
}, },
computed: { computed: {
composeSelectionStart: ({ $composeSelectionStart }) => $composeSelectionStart, /* eslint-disable camelcase */
composeFocused: ({ $composeFocused }) => $composeFocused, composeSelectionStart: ({ $autosuggestData_composeSelectionStart, $currentInstance, realm }) => (
thisComposeFocused: ({ composeFocusedDeferred, realm }) => composeFocusedDeferred === realm, get($autosuggestData_composeSelectionStart, [$currentInstance, realm], 0)
searchResults: ({ $composeAutosuggestionSearchResults }) => $composeAutosuggestionSearchResults || [], ),
type: ({ $composeAutosuggestionType }) => $composeAutosuggestionType || 'account', composeFocused: ({ $autosuggestData_composeFocused, $currentInstance, realm }) => (
selected: ({ $composeAutosuggestionSelected }) => $composeAutosuggestionSelected || 0, get($autosuggestData_composeFocused, [$currentInstance, realm], false)
searchText: ({ text, composeSelectionStartDeferred, thisComposeFocused }) => { ),
if (!thisComposeFocused) { autosuggestSearchResults: ({ $autosuggestData_autosuggestSearchResults, $currentInstance, realm }) => (
return get($autosuggestData_autosuggestSearchResults, [$currentInstance, realm], [])
} ),
let selectionStart = composeSelectionStartDeferred autosuggestType: ({ $autosuggestData_autosuggestType, $currentInstance, realm }) => (
if (!text || selectionStart < MIN_PREFIX_LENGTH) { get($autosuggestData_autosuggestType, [$currentInstance, realm])
return ),
} autosuggestSelected: ({ $autosuggestData_autosuggestSelected, $currentInstance, realm }) => (
get($autosuggestData_autosuggestSelected, [$currentInstance, realm], 0)
let textUpToCursor = text.substring(0, selectionStart) ),
let match = textUpToCursor.match(ACCOUNT_SEARCH_REGEX) || textUpToCursor.match(EMOJI_SEARCH_REGEX) autosuggestSearchText: ({ $autosuggestData_autosuggestSelected, $currentInstance, realm }) => (
return match && match[1] get($autosuggestData_autosuggestSelected, [$currentInstance, realm])
}, ),
shown: ({ thisComposeFocused, searchText, searchResults }) => { /* eslint-enable camelcase */
return !!(thisComposeFocused && shouldBeShown: ({ realm, $autosuggestShown, composeFocused }) => (
searchText && !!($autosuggestShown && composeFocused)
searchResults.length) )
}
}, },
data: () => ({ data: () => ({
composeFocusedDeferred: void 0, shown: false
composeSelectionStartDeferred: 0
}), }),
store: () => store, store: () => store,
components: { components: {

View file

@ -3,7 +3,7 @@
<li class="compose-autosuggest-list-item"> <li class="compose-autosuggest-list-item">
<button class="compose-autosuggest-list-button {i === selected ? 'selected' : ''}" <button class="compose-autosuggest-list-button {i === selected ? 'selected' : ''}"
tabindex="0" tabindex="0"
on:click="fire('click', item)"> on:click="onClick(event, item)">
<div class="compose-autosuggest-list-grid"> <div class="compose-autosuggest-list-grid">
{#if type === 'account'} {#if type === 'account'}
<Avatar <Avatar
@ -102,6 +102,13 @@
export default { export default {
store: () => store, store: () => store,
methods: {
onClick (event, item) {
event.preventDefault()
event.stopPropagation()
this.fire('click', item)
}
},
components: { components: {
Avatar Avatar
} }

View file

@ -33,8 +33,10 @@
import debounce from 'lodash-es/debounce' import debounce from 'lodash-es/debounce'
import { mark, stop } from '../../_utils/marks' import { mark, stop } from '../../_utils/marks'
import { selectionChange } from '../../_utils/events' import { selectionChange } from '../../_utils/events'
import { clickSelectedAutosuggestionUsername } from '../../_actions/compose' import {
import { clickSelectedAutosuggestionEmoji } from '../../_actions/emoji' clickSelectedAutosuggestionUsername,
clickSelectedAutosuggestionEmoji
} from '../../_actions/autosuggest'
import { observe } from 'svelte-extras' import { observe } from 'svelte-extras'
export default { export default {
@ -95,14 +97,21 @@
stop('autosize.destroy()') stop('autosize.destroy()')
}, },
onBlur () { onBlur () {
this.store.set({composeFocused: null}) scheduleIdleTask(() => {
this.store.setForCurrentAutosuggest({composeFocused: false})
})
}, },
onFocus () { onFocus () {
let { realm } = this.get() scheduleIdleTask(() => {
this.store.set({composeFocused: realm}) let {realm} = this.get()
this.store.set({currentComposeRealm: realm})
this.store.setForCurrentAutosuggest({composeFocused: true})
})
}, },
onSelectionChange (selectionStart) { onSelectionChange (selectionStart) {
this.store.set({composeSelectionStart: selectionStart}) scheduleIdleTask(() => {
this.store.setForCurrentAutosuggest({composeSelectionStart: selectionStart})
})
}, },
onKeydown (e) { onKeydown (e) {
let { keyCode } = e let { keyCode } = e
@ -132,14 +141,14 @@
}, },
clickSelectedAutosuggestion (event) { clickSelectedAutosuggestion (event) {
let { let {
composeAutosuggestionShown, autosuggestShown,
composeAutosuggestionType autosuggestType
} = this.store.get() } = this.store.get()
if (!composeAutosuggestionShown) { if (!autosuggestShown) {
return false return false
} }
let { realm } = this.get() let { realm } = this.get()
if (composeAutosuggestionType === 'account') { if (autosuggestType === 'account') {
/* no await */ clickSelectedAutosuggestionUsername(realm) /* no await */ clickSelectedAutosuggestionUsername(realm)
} else { // emoji } else { // emoji
/* no await */ clickSelectedAutosuggestionEmoji(realm) /* no await */ clickSelectedAutosuggestionEmoji(realm)
@ -150,33 +159,31 @@
}, },
incrementAutosuggestSelected (increment, event) { incrementAutosuggestSelected (increment, event) {
let { let {
composeAutosuggestionShown, autosuggestShown,
composeAutosuggestionSelected, autosuggestSelected,
composeAutosuggestionSearchResults autosuggestSearchResults
} = this.store.get() } = this.store.get()
if (!composeAutosuggestionShown) { if (!autosuggestShown) {
return return
} }
let selected = composeAutosuggestionSelected || 0 autosuggestSelected += increment
let searchResults = composeAutosuggestionSearchResults || [] if (autosuggestSelected >= 0) {
selected += increment autosuggestSelected = autosuggestSelected % autosuggestSearchResults.length
if (selected >= 0) {
selected = selected % searchResults.length
} else { } else {
selected = searchResults.length + selected autosuggestSelected = autosuggestSearchResults.length + autosuggestSelected
} }
this.store.set({composeAutosuggestionSelected: selected}) this.store.setForCurrentAutosuggest({autosuggestSelected})
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
}, },
clearAutosuggestions (event) { clearAutosuggestions (event) {
let { composeAutosuggestionShown } = this.store.get() let { autosuggestShown } = this.store.get()
if (!composeAutosuggestionShown) { if (!autosuggestShown) {
return return
} }
this.store.set({ this.store.setForCurrentAutosuggest({
composeAutosuggestionSearchResults: [], autosuggestSearchResults: [],
composeAutosuggestionSelected: 0 autosuggestSelected: 0
}) })
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()

View file

@ -0,0 +1,56 @@
const MIN_PREFIX_LENGTH = 1
const ACCOUNT_SEARCH_REGEX = new RegExp(`(?:\\s|^)(@\\S{${MIN_PREFIX_LENGTH},})$`)
const EMOJI_SEARCH_REGEX = new RegExp(`(?:\\s|^)(:[^:]{${MIN_PREFIX_LENGTH},})$`)
function computeForAutosuggest (store, key, defaultValue) {
store.compute(key,
['currentInstance', 'currentComposeRealm', `autosuggestData_${key}`],
(currentInstance, currentComposeRealm, root) => {
let instanceData = root && root[currentInstance]
return (currentComposeRealm && instanceData && currentComposeRealm in instanceData) ? instanceData[currentComposeRealm] : defaultValue
})
}
export function autosuggestComputations (store) {
computeForAutosuggest(store, 'composeFocused', false)
computeForAutosuggest(store, 'composeSelectionStart', 0)
computeForAutosuggest(store, 'autosuggestSelected', 0)
computeForAutosuggest(store, 'autosuggestSearchResults', [])
computeForAutosuggest(store, 'autosuggestType', null)
store.compute(
'currentComposeText',
['currentComposeData', 'currentComposeRealm'],
(currentComposeData, currentComposeRealm) => (
currentComposeData[currentComposeRealm] && currentComposeData[currentComposeRealm].text) || ''
)
store.compute(
'autosuggestSearchText',
['currentComposeText', 'composeSelectionStart'],
(currentComposeText, composeSelectionStart) => {
let selectionStart = composeSelectionStart
if (!currentComposeText || selectionStart < MIN_PREFIX_LENGTH) {
return ''
}
let textUpToCursor = currentComposeText.substring(0, selectionStart)
let match = textUpToCursor.match(ACCOUNT_SEARCH_REGEX) || textUpToCursor.match(EMOJI_SEARCH_REGEX)
return (match && match[1]) || ''
}
)
store.compute(
'autosuggestNumSearchResults',
['autosuggestSearchResults'],
(autosuggestSearchResults) => autosuggestSearchResults.length
)
store.compute(
'autosuggestShown',
['composeFocused', 'autosuggestSearchText', 'autosuggestNumSearchResults'],
(composeFocused, autosuggestSearchText, autosuggestNumSearchResults) => (
!!(composeFocused && autosuggestSearchText && autosuggestNumSearchResults)
)
)
}

View file

@ -1,9 +1,11 @@
import { instanceComputations } from './instanceComputations' import { instanceComputations } from './instanceComputations'
import { timelineComputations } from './timelineComputations' import { timelineComputations } from './timelineComputations'
import { navComputations } from './navComputations' import { navComputations } from './navComputations'
import { autosuggestComputations } from './autosuggestComputations'
export function computations (store) { export function computations (store) {
instanceComputations(store) instanceComputations(store)
timelineComputations(store) timelineComputations(store)
navComputations(store) navComputations(store)
autosuggestComputations(store)
} }

View file

@ -0,0 +1,19 @@
export function autosuggestMixins (Store) {
Store.prototype.setForAutosuggest = function (instanceName, realm, obj) {
let valuesToSet = {}
for (let key of Object.keys(obj)) {
let rootKey = `autosuggestData_${key}`
let root = this.get()[rootKey] || {}
let instanceData = root[instanceName] = root[instanceName] || {}
instanceData[realm] = obj[key]
valuesToSet[rootKey] = root
}
this.set(valuesToSet)
}
Store.prototype.setForCurrentAutosuggest = function (obj) {
let { currentInstance, currentComposeRealm } = this.get()
this.setForAutosuggest(currentInstance, currentComposeRealm, obj)
}
}

View file

@ -1,9 +1,11 @@
import { timelineMixins } from './timelineMixins' import { timelineMixins } from './timelineMixins'
import { instanceMixins } from './instanceMixins' import { instanceMixins } from './instanceMixins'
import { statusMixins } from './statusMixins' import { statusMixins } from './statusMixins'
import { autosuggestMixins } from './autosuggestMixins'
export function mixins (Store) { export function mixins (Store) {
instanceMixins(Store) instanceMixins(Store)
timelineMixins(Store) timelineMixins(Store)
statusMixins(Store) statusMixins(Store)
autosuggestMixins(Store)
} }

View file

@ -0,0 +1,42 @@
import {
searchAccountsByUsername as searchAccountsByUsernameInDatabase
} from '../../_database/accountsAndRelationships'
const SEARCH_RESULTS_LIMIT = 4
const DATABASE_SEARCH_RESULTS_LIMIT = 30
async function searchAccounts (store, searchText) {
searchText = searchText.substring(1)
let { currentInstance } = store.get()
let results = await searchAccountsByUsernameInDatabase(
currentInstance, searchText, DATABASE_SEARCH_RESULTS_LIMIT)
return results.slice(0, SEARCH_RESULTS_LIMIT)
}
function searchEmoji (store, searchText) {
searchText = searchText.toLowerCase().substring(1)
let { currentCustomEmoji } = store.get()
let results = currentCustomEmoji.filter(emoji => emoji.shortcode.toLowerCase().startsWith(searchText))
.sort((a, b) => a.shortcode.toLowerCase() < b.shortcode.toLowerCase() ? -1 : 1)
.slice(0, SEARCH_RESULTS_LIMIT)
return results
}
export function autosuggestObservers (store) {
store.observe('autosuggestSearchText', async autosuggestSearchText => {
let { composeFocused } = store.get()
if (!composeFocused || !autosuggestSearchText) {
return
}
let type = autosuggestSearchText.startsWith('@') ? 'account' : 'emoji'
let results = (type === 'account')
? await searchAccounts(store, autosuggestSearchText)
: await searchEmoji(store, autosuggestSearchText)
store.setForCurrentAutosuggest({
autosuggestSelected: 0,
autosuggestSearchText: autosuggestSearchText,
autosuggestSearchResults: results,
autosuggestType: type
})
})
}

View file

@ -3,6 +3,7 @@ import { timelineObservers } from './timelineObservers'
import { notificationObservers } from './notificationObservers' import { notificationObservers } from './notificationObservers'
import { onlineObservers } from './onlineObservers' import { onlineObservers } from './onlineObservers'
import { navObservers } from './navObservers' import { navObservers } from './navObservers'
import { autosuggestObservers } from './autosuggestObservers'
export function observers (store) { export function observers (store) {
instanceObservers(store) instanceObservers(store)
@ -10,4 +11,5 @@ export function observers (store) {
notificationObservers(store) notificationObservers(store)
onlineObservers(store) onlineObservers(store)
navObservers(store) navObservers(store)
autosuggestObservers(store)
} }

View file

@ -76,7 +76,9 @@ module.exports = {
} }
}, },
plugins: [ plugins: [
new LodashModuleReplacementPlugin() new LodashModuleReplacementPlugin({
paths: true
})
].concat(isDev ? [ ].concat(isDev ? [
new webpack.HotModuleReplacementPlugin({ new webpack.HotModuleReplacementPlugin({
requestTimeout: 120000 requestTimeout: 120000