|
|
@@ -0,0 +1,537 @@ |
|
|
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) |
|
|
} |