-
-
Save sergeysova/bad861533ec0f34b3abd9f1f7f33ecaa to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.button { | |
border: 1px solid #555; | |
text-align: center; | |
padding: 0 12px; | |
color: #ddd; | |
cursor: pointer; | |
} | |
.button:hover { | |
background: rgba(255, 255, 255, .25) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useStore } from 'effector-react' | |
import * as effector from 'effector' | |
import { combine } from 'effector' | |
import { pathOr } from 'ramda' | |
import React, { useLayoutEffect, useRef } from 'react' | |
import ReactDOM from 'react-dom' | |
import { chromeDark, ObjectInspector, ObjectLabel } from 'react-inspector' | |
import { throttle } from 'lodash' | |
import './effector-addon.css' | |
const trackCreateStore = effector.createEvent('trackCreateStore') | |
const trackCreateEvent = effector.createEvent('trackCreateEvent') | |
const trackCreateEffect = effector.createEvent('trackCreateEffect') | |
export function createStore(...params) { | |
const store = effector.createStore(...params) | |
process.env.NODE_ENV === 'development' && trackCreateStore({ | |
store, | |
params, | |
}) | |
return store | |
} | |
export function createEvent(...params) { | |
// console.log('createEvent', params) | |
const event = effector.createEvent(...params) | |
const name = params[0] | |
const file = params[1].loc.file | |
const module = file.split('/').slice(-1)[0].split('.')[0] | |
process.env.NODE_ENV === 'development' && trackCreateEvent({ | |
event, | |
name, | |
module, | |
}) | |
return event | |
} | |
export function createEffect(...params) { | |
// console.log('createEffect', params) | |
const effect = effector.createEffect(...params) | |
process.env.NODE_ENV === 'development' && trackCreateEffect({ | |
effect, | |
params, | |
}) | |
return effect | |
} | |
const toggleVisibility = effector.createEvent('toggleVisibility') | |
const visibility = effector.createStore(true) | |
.on(toggleVisibility, state => !state) | |
const $winParams = effector.createStoreObject({ | |
visibility, | |
}) | |
const getNewPromise = () => new Promise((resolve) => resolver = resolve) | |
let resolver = null | |
let sync = Promise.resolve() | |
const $eventMap = effector.createStore({}) | |
.on(trackCreateEvent, (state, { event, name, module }) => { | |
event.watch(async () => { | |
await sync | |
sync = getNewPromise() | |
debouncedTimeSlice() | |
addEvent({ type: 'event', module, name, fullname: `${module}.${name}` }) | |
resolver && resolver() | |
}) | |
return { | |
...state, | |
[module]: [ | |
...pathOr([], [module], state), | |
`${module}.${name}`, | |
], | |
} | |
}) | |
const $storeMap = effector.createStore({}) | |
.on(trackCreateStore, (state, { store, params }) => { | |
store.updates.watch(async () => { | |
await sync | |
sync = getNewPromise() | |
debouncedTimeSlice() | |
addEvent({ type: 'store', name: store.shortName }) | |
resolver && resolver() | |
}) | |
return { | |
...state, | |
[store.shortName]: store, | |
} | |
}) | |
const addEvent = effector.createEvent() | |
const eventCall = addEvent.filter({ | |
fn: (params) => params.type === 'event', | |
}) | |
const timeSlice = effector.createEvent() | |
const debouncedTimeSlice = throttle(timeSlice, 1000, { leading: true, trailing: false }) | |
let eventCounter = 0 | |
const clearStack = effector.createEvent() | |
const $eventCallStack = effector.createStore([]) | |
.reset(clearStack) | |
.on(addEvent, (state, event) => { | |
if (event.type === 'store') { | |
const lastEvent = state.slice(-1)[0] | |
if (lastEvent) { | |
return [...state.slice(0, -1), { ...lastEvent, store: event.name }] | |
} | |
} | |
return [ | |
...state.slice(-100), | |
{ ...event, index: event.type === 'event' ? ++eventCounter : 0 }, | |
] | |
}) | |
.on(timeSlice, (state) => [ | |
...state, | |
{ type: 'time', time: Date.now() }, | |
]) | |
const $eventCalls = effector.createStore({}) | |
.reset(clearStack) | |
.on(eventCall, (state, { module, name }) => { | |
return ({ | |
...state, | |
[`${module}.${name}`]: pathOr(0, [`${module}.${name}`], state) + 1, | |
}) | |
}) | |
const toggleEventFilter = effector.createEvent() | |
const toggleEventGroupFilter = effector.createEvent() | |
const $eventFilter = effector.createStore({}) | |
.reset(clearStack) | |
.on(trackCreateEvent, (state, { event, name, module }) => ({ | |
...state, | |
[module]: { | |
checked: pathOr(true, [module, 'checked'], state), | |
data: { | |
...pathOr({}, [module, 'data'], state), | |
[`${module}.${name}`]: true, | |
}, | |
}, | |
})) | |
.on(toggleEventFilter, (state, name) => { | |
const module = name.split('.')[0] | |
const checked = !pathOr(true, [module, 'data', name], state) | |
const newState = { | |
...state, | |
[module]: { | |
// checked: moduleChecked, | |
data: { | |
...pathOr({}, [module, 'data'], state), | |
[name]: checked, | |
}, | |
}, | |
} | |
const moduleChecked = checked | |
? Object.values(pathOr({}, [module, 'data'], newState)).every(item => item) | |
: false | |
newState[module].checked = moduleChecked | |
return newState | |
}) | |
.on(toggleEventGroupFilter, (state, module) => { | |
const checked = !pathOr(true, [module, 'checked'], state) | |
return ({ | |
...state, | |
[module]: { | |
checked, | |
data: Object.keys(pathOr({}, [module, 'data'], state)) | |
.reduce((acc, item) => Object.assign(acc, { [item]: checked }), {}), | |
}, | |
}) | |
}) | |
const $filteredCallStack = combine($eventCallStack, $eventFilter, (eventCallStack, eventFilter) => { | |
return eventCallStack.filter(event => { | |
if (event.type !== 'event') return event | |
return eventFilter[event.module].data[event.fullname] | |
}) | |
}) | |
function formatDate(date) { | |
let diff = new Date() - date // the difference in milliseconds | |
if (diff < 1000) { // less than 1 second | |
return 'right now' | |
} | |
let sec = Math.floor(diff / 1000) // convert diff to seconds | |
if (sec < 60) { | |
return sec + ' sec. ago' | |
} | |
let min = Math.floor(diff / 60000) // convert diff to minutes | |
if (min < 60) { | |
return min + ' min. ago' | |
} | |
// format the date | |
// add leading zeroes to single-digit day/month/hours/minutes | |
let d = date | |
d = [ | |
'0' + d.getDate(), | |
'0' + (d.getMonth() + 1), | |
'' + d.getFullYear(), | |
'0' + d.getHours(), | |
'0' + d.getMinutes(), | |
].map(component => component.slice(-2)) // take last 2 digits of every component | |
// join the components into date | |
return d.slice(0, 3).join('.') + ' ' + d.slice(3).join(':') | |
} | |
const storeNodeRenderer = ({ depth, name, data, isNonenumerable, expanded }) => { | |
switch (depth) { | |
case 0: | |
return <span style={{ color: 'rgb(232, 234, 246)' }}>Store list</span> | |
case 1: | |
return typeof data === 'object' || Array.isArray(data) | |
? <span style={{ color: '#9ecbe0' }}>{name}</span> | |
: <ObjectLabel | |
name={name} | |
data={data} | |
isNonenumerable={isNonenumerable} | |
/> | |
default: | |
return <ObjectLabel name={name} data={data} isNonenumerable={isNonenumerable} /> | |
} | |
} | |
const eventNodeRenderer = (eventCalls, eventFilter) => ({ depth, name, data, isNonenumerable, expanded }) => { | |
switch (depth) { | |
case 0: | |
return <label style={{ color: 'rgb(232, 234, 246)' }}> | |
{name} | |
</label> | |
case 1: { | |
return ( | |
<span style={{ color: '#9ecbe0', lineHeight: 1.2 }}> | |
<input | |
type="checkbox" | |
checked={pathOr(true, [name, 'checked'], eventFilter)} | |
onClick={e => { | |
e.preventDefault() | |
e.stopPropagation() | |
}} | |
onChange={() => toggleEventGroupFilter(name)} | |
style={{ | |
height: 12, | |
width: 12, | |
marginLeft: 2, | |
marginRight: 5, | |
// float: 'left', | |
}} | |
/> | |
{name} | |
</span> | |
) | |
} | |
case 2: { | |
const splitPath = data.split('.') | |
const module = splitPath[0] | |
const eventName = splitPath.slice(-1) | |
return ( | |
<label style={{ color: 'rgb(161, 198, 89)', lineHeight: 1.2 }}> | |
<input | |
type="checkbox" | |
checked={pathOr(true, [module, 'data', data], eventFilter)} | |
onChange={() => toggleEventFilter(data)} | |
style={{ | |
height: 12, | |
width: 12, | |
marginRight: 5, | |
// float: 'left', | |
}} | |
/> | |
<span style={{ cursor: 'pointer', color: pathOr(0, [data], eventCalls) ? '#baf742' : 'inherit' }}> | |
{eventName} | |
</span> | |
{eventCalls[data] > 0 && ( | |
<span style={{ color: 'rgb(232, 234, 246)', paddingLeft: 6 }}> | |
[ | |
<span style={{ color: pathOr(0, [data], eventCalls) ? '#baf742' : 'inherit' }}> | |
{eventCalls[data]} | |
</span> | |
] | |
</span> | |
)} | |
</label> | |
) | |
} | |
default: | |
return null | |
} | |
} | |
const EventStackItemRenderer = ({ eventCallStack, item, index }) => { | |
switch (item.type) { | |
case 'store': | |
return ( | |
<div style={{ | |
borderTop: pathOr('', [index - 1, 'type'], eventCallStack) === 'event' ? '1px dotted #7777' : 'none', | |
textAlign: 'right', | |
color: '#e0e042', | |
padding: '0 10px', | |
}} | |
> | |
{item.name || '<NONAME>'} | |
</div> | |
) | |
case 'event': | |
return ( | |
<div style={{ | |
color: 'lightgray', | |
display: 'flex', | |
justifyContent: 'space-between', | |
marginTop: pathOr('', [index - 1, 'type'], eventCallStack) === 'store' ? 10 : 0, | |
padding: '0 5px 0 2px', | |
}} | |
> | |
<div> | |
<span style={{ color: '#789', marginRight: 5 }}>{item.index}.</span> | |
<span style={{ color: '#bbb' }}>{item.name || '<NONAME>'}</span> | |
</div> | |
<div style={{ | |
flex: '1 0', | |
margin: '0 0 12px 10px', | |
borderBottom: item.store ? '1px dotted #7777' : 'none', | |
}} | |
/> | |
{item.store && ( | |
<div style={{ marginRight: 10, marginLeft: -1, color: '#777' }}> | |
⭬ | |
</div> | |
)} | |
<div style={{ | |
textAlign: 'right', | |
color: '#e0e042', | |
}} | |
> | |
{item.store} | |
</div> | |
</div> | |
) | |
case 'time': | |
return null | |
return ( | |
<div style={{ textAlign: 'center', color: '#9ecbe0', marginTop: 10, borderBottom: '1px dotted #7771' }}> | |
{formatDate(item.time)} | |
</div> | |
) | |
default: | |
return ( | |
<div style={{ color: 'red', marginTop: 5 }}> | |
Unknown event | |
</div> | |
) | |
} | |
} | |
// const tick = effector.createEvent() | |
// const $refresher = effector.createStore(Date.now()) | |
// .on(tick, state => Date.now()) | |
// | |
// setInterval(tick, 5000) | |
export const DevToolWindow = () => { | |
const winParams = useStore($winParams) | |
const eventCallStack = useStore($filteredCallStack) | |
const eventCalls = useStore($eventCalls) | |
const storeMap = useStore($storeMap) | |
const eventMap = useStore($eventMap) | |
const storeList = Object.keys(storeMap) | |
const eventFilter = useStore($eventFilter) | |
// const refresher = useStore($refresher) | |
const ref = useRef(null) | |
useLayoutEffect(() => { | |
if (ref.current) { | |
ref.current.scrollTop = ref.current.scrollHeight | |
} | |
}, [eventCallStack[eventCallStack.length - 1]]) | |
// if (!winParams.visibility) return null | |
const storeObject = storeList.reduce((acc, storeName) => | |
Object.assign(acc, { [storeName]: storeMap[storeName].getState() }), {}, | |
) | |
return ( | |
<div | |
style={{ | |
fontSize: 16, | |
position: 'absolute', | |
width: winParams.visibility ? '50%' : 0, | |
transition: 'width 50ms', | |
top: 0, | |
right: 0, | |
bottom: 0, | |
backgroundColor: 'rgb(53, 59, 70)', | |
zIndex: 9999999, | |
boxShadow: '-4px 0 10px rgba(0, 0, 0, .35)', | |
overflow: 'auto', | |
color: 'rgb(232, 234, 246)', | |
}} | |
onMouseDown={e => e.stopPropagation()} | |
onMouseUp={e => e.stopPropagation()} | |
onClick={e => e.stopPropagation()} | |
> | |
<div style={{ | |
padding: '5px 10px', | |
backgroundColor: 'rgba(0, 0, 0, .25)', | |
width: '100%', | |
fontSize: 20, | |
borderBottom: '1px solid rgba(255, 255, 255, .25)', | |
}} | |
> | |
Effector Inspector | |
</div> | |
<div style={{ | |
display: 'flex', | |
padding: 5, | |
height: 'calc(100% - 42px)', | |
}} | |
> | |
<div style={{ | |
flex: '0 0 40%', | |
display: 'flex', | |
flexDirection: 'column', | |
overflow: 'auto', | |
}} | |
> | |
<div style={{ | |
height: 290, | |
marginBottom: 10, | |
overflow: 'auto', | |
border: '1px solid #555', | |
backgroundColor: '#2A2F3A', | |
userSelect: 'none', | |
}} | |
> | |
<ObjectInspector | |
nodeRenderer={eventNodeRenderer(eventCalls, eventFilter)} | |
expandLevel={1} | |
data={eventMap} | |
theme={{ | |
...chromeDark, | |
BASE_FONT_SIZE: '14px', | |
TREENODE_FONT_SIZE: '14px', | |
OBJECT_NAME_COLOR: '#9ecbe0', | |
OBJECT_VALUE_STRING_COLOR: 'rgb(161, 198, 89)', | |
OBJECT_VALUE_BOOLEAN_COLOR: 'rgb(252, 109, 36)', | |
BASE_BACKGROUND_COLOR: '#2A2F3A', | |
}} | |
name='Event list' | |
/> | |
</div> | |
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 5 }}> | |
<div>Events stack:</div> | |
<div className="button" onClick={clearStack}> | |
Clear | |
</div> | |
</div> | |
<div | |
ref={ref} | |
style={{ | |
flex: '1 0 60%', | |
overflow: 'auto', | |
border: '1px solid #555', | |
backgroundColor: '#2A2F3A', | |
}} | |
> | |
{eventCallStack.map((item, index) => ( | |
<EventStackItemRenderer key={index} index={index} item={item} eventCallStack={eventCallStack} /> | |
))} | |
</div> | |
</div> | |
<div style={{ | |
flex: '1 0', | |
overflow: 'auto', | |
marginLeft: 5, | |
border: '1px solid #555', | |
backgroundColor: '#2A2F3A', | |
padding: 5, | |
}} | |
> | |
<ObjectInspector | |
style={{ border: '5px solid red' }} | |
nodeRenderer={storeNodeRenderer} | |
expandLevel={1} | |
data={storeObject} | |
theme={{ | |
...chromeDark, | |
BASE_FONT_SIZE: '14px', | |
TREENODE_FONT_SIZE: '14px', | |
OBJECT_NAME_COLOR: '#9ecbe0', | |
OBJECT_VALUE_STRING_COLOR: 'rgb(161, 198, 89)', | |
OBJECT_VALUE_BOOLEAN_COLOR: 'rgb(252, 109, 36)', | |
BASE_BACKGROUND_COLOR: '#2A2F3A', | |
}} | |
/> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export function bindEffectorInspectorHotKey({ alt = false, ctrl = false, shift = false, key = '`' } = {}) { | |
window.addEventListener('keydown', (e) => { | |
e.key === key && e.altKey === alt && e.ctrlKey === ctrl && e.shiftKey === shift && toggleVisibility() | |
}) | |
} | |
export const effectorInspector = (hotKeyOptions) => { | |
bindEffectorInspectorHotKey(hotKeyOptions) | |
const devtool = document.createElement('div') | |
ReactDOM.render(<DevToolWindow />, devtool) | |
document.body.appendChild(devtool) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment