15 | {toasts.map(toast => {
16 | const { id, type, title, message } = toast
17 | return (
18 |
26 | )
27 | })}
28 |
29 | )
30 | }
31 |
32 | export default Toasts
33 |
--------------------------------------------------------------------------------
/src/ipc/renderer.js:
--------------------------------------------------------------------------------
1 | import ipcConstants from './constants'
2 |
3 | const typeProxies = {}
4 | Object.keys(ipcConstants).forEach(type => {
5 | typeProxies[type] = new Proxy(ipcConstants[type], {
6 | get: function (target, channel) {
7 | if (!target[channel]) {
8 | throw new Error(`Invalid ipc channel called: ${channel}`)
9 | }
10 |
11 | return window.ipcInvoke[ipcConstants[type][channel]]
12 | },
13 | })
14 | })
15 |
16 | const invoke = new Proxy(typeProxies, {
17 | get: function (target, type) {
18 | if (!window.ipcInvoke) {
19 | throw new Error('ipc bridge missing')
20 | }
21 |
22 | if (!target[type]) {
23 | throw new Error(`Invalid ipc type used: ${type}`)
24 | }
25 |
26 | return target[type]
27 | },
28 | })
29 |
30 | export { invoke }
31 |
--------------------------------------------------------------------------------
/src/state/modals/interface.js:
--------------------------------------------------------------------------------
1 | import { actions, modalIds } from './model'
2 | import { subscribe, unsubscribe } from 'state-management/watcher'
3 |
4 | export const open = (modalId, data) => dispatch => {
5 | return dispatch(actions.openAt(modalId, undefined, data))
6 | }
7 |
8 | export const openAt = (modalId, pos, data) => dispatch => {
9 | if (!modalIds[modalId]) {
10 | console.log(modalIds, modalId)
11 | console.log(modalIds[modalId])
12 | throw new Error(`Modal with id ${modalId} does not exist`)
13 | }
14 |
15 | dispatch(actions.openAt(modalId, pos, data))
16 |
17 | return new Promise(resolve => {
18 | const id = subscribe(`modals.responseData`, response => {
19 | unsubscribe(id)
20 | resolve(response)
21 | })
22 | })
23 | }
24 |
25 | export const close = actions.close
26 |
--------------------------------------------------------------------------------
/src/components/system/loading-button.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { useState } from 'state-management/hooks'
3 | import { Button, Loader } from 'components/system'
4 | import { useIsMounted } from 'utils/hooks'
5 |
6 | const LoadingButton = props => {
7 | const { onClick, icon, children, ...rest } = props
8 | const [loading, setLoading] = useState(false)
9 | const isMounted = useIsMounted()
10 |
11 | return (
12 |
11 |
15 | {id}
16 |
17 |
{
29 | e.preventDefault()
30 | e.stopPropagation()
31 | const offset = e.currentTarget.getBoundingClientRect()
32 | modalActions.openAt(
33 | modalIds.colorPicker,
34 | { x: offset.left, y: offset.top },
35 | { blockId, propId: id }
36 | )
37 | }}
38 | />
39 |
40 | )
41 | }
42 |
43 | export default Color
44 |
--------------------------------------------------------------------------------
/src/electron-app/properties.js:
--------------------------------------------------------------------------------
1 | import ipcConstants from 'ipc/constants'
2 | import { dialog } from 'electron'
3 | import { handle } from 'ipc/main'
4 | import path from 'path'
5 |
6 | const selectFile = async blockPath => {
7 | const res = await dialog.showOpenDialog({
8 | title: 'Select File',
9 | buttonLabel: 'Select File',
10 | properties: ['openFile'],
11 | })
12 | if (res && res.filePaths.length > 0) {
13 | const staticPath = res.filePaths[0]
14 | const programPath = path.join(blockPath, '../../')
15 |
16 | // if the selected path is within the program path, then we return
17 | // a relative path from the block folder. This allows copying blocks,
18 | // and moving projects around without breaking paths. However if the path
19 | // is outside the project, then we want it to remain static so project moving
20 | // doesn't break paths.
21 | const relative = path.relative(programPath, staticPath)
22 | if (relative.startsWith('..')) {
23 | return staticPath
24 | }
25 | return path.relative(blockPath, staticPath)
26 | }
27 | }
28 |
29 | const initProperties = () => {
30 | const propertiesChannels = ipcConstants.properties
31 | handle(propertiesChannels.selectFile, selectFile)
32 | }
33 |
34 | export { initProperties }
35 |
--------------------------------------------------------------------------------
/src/electron-redux-ipc/main.js:
--------------------------------------------------------------------------------
1 | import * as channels from './channels'
2 | import { ipcMain, webContents } from 'electron'
3 |
4 | const forwardActionsToRenderers = () => next => action => {
5 | if (!action.type.startsWith('@@')) {
6 | const { senderProcessId } = action
7 | webContents.getAllWebContents().forEach(wc => {
8 | const type = wc.getType()
9 | // TODO: add ability for renderer to specify whether or not to propagate an action
10 | // or omit certain keys from payloads, or provide a specific payload for propagation.
11 | // This allows us to prevent sentitive information from making it to the engine for example.
12 | if (
13 | (type === 'window' || type === 'browserView') &&
14 | (!senderProcessId || senderProcessId !== wc.getProcessId())
15 | ) {
16 | wc.send(channels.action, action)
17 | }
18 | })
19 | }
20 | return next(action)
21 | }
22 |
23 | const handleRendererRequests = store => {
24 | ipcMain.handle(channels.initialState, () => store.getState())
25 |
26 | ipcMain.on(channels.action, (event, action) => {
27 | store.dispatch({
28 | ...action,
29 | senderProcessId: event.processId,
30 | })
31 | })
32 | }
33 |
34 | export { forwardActionsToRenderers, handleRendererRequests }
35 |
--------------------------------------------------------------------------------
/src/utils/logger.js:
--------------------------------------------------------------------------------
1 | import isDev from './is-dev'
2 | import Deque from 'double-ended-queue'
3 |
4 | const CACHE_SIZE = 50
5 | const logQueue = new Deque(CACHE_SIZE)
6 |
7 | let enabled = isDev()
8 | let realtime = true
9 |
10 | const matchesFilter = (category, filter) => {
11 | return category.startsWith(filter)
12 | }
13 |
14 | const log = item => {
15 | console.log(`%c[${item.category}]`, `color: ${item.colour}`, ...item.args)
16 | }
17 |
18 | export const setEnabled = _enabled => (enabled = _enabled)
19 | export const setRealtime = _realtim => (realtime = _realtime)
20 |
21 | export const createLogger =
22 | (category, colour) =>
23 | (...args) => {
24 | if (enabled) {
25 | const item = {
26 | category,
27 | colour,
28 | args,
29 | }
30 | logQueue.push(item)
31 | if (logQueue.length > CACHE_SIZE) {
32 | logQueue.shift()
33 | }
34 | if (realtime) {
35 | log(item)
36 | }
37 | }
38 | }
39 |
40 | export const dump = filter => {
41 | console.log(
42 | filter
43 | ? `Dumping log for filter: ${filter} --------`
44 | : 'Dumping log --------'
45 | )
46 | logQueue
47 | .toArray()
48 | .filter(item => !filter || matchesFilter(item.category, filter))
49 | .forEach(log)
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/status-bar/user.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import ContextMenu from 'electron-react-context-menu/renderer'
3 | import { useSettings } from 'state/settings/hooks'
4 | import { signOut } from 'block-server/firebase'
5 | import { Icon } from 'components/system'
6 |
7 | const User = props => {
8 | const [settings] = useSettings()
9 | const { user } = settings
10 |
11 | // TODO: add ability to login as well
12 | if (!user) {
13 | return null
14 | }
15 |
16 | return (
17 |
{
23 | signOut()
24 | },
25 | },
26 | ]}>
27 |
38 | {' '}
42 | {user.displayName || user.email}
43 |
44 |
45 | )
46 | }
47 |
48 | export default User
49 |
--------------------------------------------------------------------------------
/src/state/modals/model.js:
--------------------------------------------------------------------------------
1 | import { buildModel } from 'state-management/builder'
2 | import { constants as programConstants } from '../program/model'
3 |
4 | export const modalIds = {
5 | intro: 'intro',
6 | dependencySearch: 'dependencySearch',
7 | onlineBlockSearch: 'onlineBlockSearch',
8 | editProperty: 'editProperty',
9 | confirmation: 'confirmation',
10 | editBlockInformation: 'editBlockInformation',
11 | auth: 'auth',
12 | colorPicker: 'colorPicker',
13 | programSettings: 'programSettings',
14 | }
15 |
16 | const initialState = () => ({
17 | openId: modalIds.intro,
18 | })
19 |
20 | const { actions, reducer, constants } = buildModel(
21 | 'modals',
22 | initialState(),
23 | {
24 | openAt: (modals, id, pos, data) => {
25 | modals.openId = id
26 | modals.position = pos
27 | modals.data = data
28 | delete modals.responseData
29 | },
30 | close: (modals, responseData = false) => {
31 | delete modals.openId
32 | delete modals.position
33 | delete modals.data
34 | modals.responseData = responseData
35 | },
36 | },
37 | () => ({
38 | [programConstants.load]: modals => {
39 | delete modals.openId
40 | delete modals.position
41 | delete modals.data
42 | },
43 | })
44 | )
45 |
46 | export { reducer, actions, constants }
47 |
--------------------------------------------------------------------------------
/src/electron-app/preload-engine.js:
--------------------------------------------------------------------------------
1 | import * as channels from 'electron-redux-ipc/channels'
2 |
3 | import ipcConstants from 'ipc/constants'
4 | import { ipcRenderer } from 'electron'
5 |
6 | // this takes all our ipc channels and turns them into callable
7 | // invoke functions. The electron guides promote not exposing the
8 | // invoke function directly, which is adhered to here, but seems
9 | // unlikely as a security issue either way.
10 | const bridgeFuncs = {}
11 | Object.keys(ipcConstants).forEach(type => {
12 | const channels = ipcConstants[type]
13 | Object.values(channels).forEach(channel => {
14 | bridgeFuncs[channel] = (...args) => {
15 | return ipcRenderer.invoke(channel, ...args)
16 | }
17 | })
18 | })
19 |
20 | window.ipcInvoke = bridgeFuncs
21 |
22 | // in order for the redux ipc to work correctly, ipcRenderer needs to exposed
23 | window.electronReduxIPC = {
24 | onMainAction: func =>
25 | ipcRenderer.on(channels.action, (e, action) => func(action)),
26 | sendAction: action => ipcRenderer.send(channels.action, action),
27 | getInitialState: () => ipcRenderer.invoke(channels.initialState),
28 | }
29 |
30 | // file properties require a custom solution for access
31 | window.properties = {
32 | selectFile: blockPath =>
33 | ipcRenderer.invoke(ipcConstants.properties.selectFile, blockPath),
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/properties/file-selector.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { Button } from 'components/system'
3 | import { invoke } from 'ipc/renderer'
4 |
5 | const MAX_LENGTH = 30
6 |
7 | const fileString = str => {
8 | const parts = (str || '').split(/[\\\/]+/)
9 | const filename = parts[parts.length - 1]
10 |
11 | if (filename.length <= MAX_LENGTH) {
12 | return filename
13 | }
14 |
15 | if (filename.indexOf('.') >= 0) {
16 | const fileParts = filename.split('.')
17 | return fileParts[0].substring(0, MAX_LENGTH - 3) + '...' + fileParts[1]
18 | } else {
19 | return filename.substr(0, MAX_LENGTH - 1) + '…'
20 | }
21 | }
22 |
23 | const FileSelector = props => {
24 | const { id, value, updateValue, blockPath } = props
25 | return (
26 |
27 |
31 | {id}
32 |
33 |
45 |
46 | )
47 | }
48 |
49 | export default FileSelector
50 |
--------------------------------------------------------------------------------
/src/components/sidebar-panel/block-information/block-information.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 |
3 | import Dependencies from './dependencies'
4 | import GeneralInformation from './general-information'
5 | import StateSettings from './state-settings'
6 | import { invoke } from 'ipc/renderer'
7 | import { useBlock } from 'state/blocks/hooks'
8 | import { useToastActions } from 'state/toasts/hooks'
9 |
10 | //import PropertyList from './property-list'
11 |
12 | const BlockInformation = props => {
13 | const { id } = props
14 | const [block] = useBlock(id)
15 |
16 | const toastActions = useToastActions()
17 |
18 | const { dependencies = {} } = block.config
19 |
20 | return (
21 |
26 |
33 |
34 | {/*
*/}
35 |
40 |
41 |
42 |
43 | )
44 | }
45 |
46 | export default BlockInformation
47 |
--------------------------------------------------------------------------------
/src/components/toasts/toast.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { TOAST_TYPES } from 'state/toasts/model'
3 | import { Icon } from 'components/system'
4 |
5 | const colors = {
6 | [TOAST_TYPES.info]: 'blue',
7 | [TOAST_TYPES.success]: 'green',
8 | [TOAST_TYPES.warning]: 'yellow',
9 | [TOAST_TYPES.error]: 'red',
10 | }
11 |
12 | const Toast = props => {
13 | const { id, title, message, type, actions } = props
14 |
15 | return (
16 |
actions.remove(id)}>
27 |
{title}
28 |
33 | {message}
34 |
35 |
actions.remove(id)}
46 | />
47 |
48 | )
49 | }
50 |
51 | export default Toast
52 |
--------------------------------------------------------------------------------
/src/state/workspace/model.js:
--------------------------------------------------------------------------------
1 | import { buildModel } from 'state-management/builder'
2 | import { constants as programConstants } from '../program/model'
3 | import { constants as blockConstants } from '../blocks/model'
4 |
5 | const initialState = () => ({
6 | selectedBlockId: null,
7 | enginePanelAttached: true,
8 | showSidebar: true,
9 | })
10 |
11 | export const { constants, actions, reducer } = buildModel(
12 | 'workspace',
13 | initialState(),
14 | {
15 | selectBlock: (workspace, blockId) => {
16 | workspace.selectedBlockId = blockId
17 | },
18 | toggleSidebar: workspace => {
19 | workspace.showSidebar = !workspace.showSidebar
20 | },
21 | toggleEnginePanelAttached: workspace => {
22 | workspace.enginePanelAttached = !workspace.enginePanelAttached
23 | },
24 | },
25 | () => ({
26 | [programConstants.reset]: () => initialState(),
27 | [blockConstants.remove]: (workspace, blockId) => {
28 | if (workspace.selectedBlockId === blockId) {
29 | workspace.selectedBlockId = null
30 | }
31 | },
32 | [blockConstants.load]: (
33 | workspace,
34 | blockId,
35 | name,
36 | path,
37 | config,
38 | code,
39 | dependencies
40 | ) => {
41 | if (!workspace.selectedBlockId) {
42 | workspace.selectedBlockId = blockId
43 | }
44 | },
45 | })
46 | )
47 |
--------------------------------------------------------------------------------
/src/components/system/button.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { Icon } from 'components/system'
3 |
4 | const Button = props => {
5 | const { icon, primary, children, ...rest } = props
6 |
7 | let finalChildren = children
8 |
9 | if (icon) {
10 | finalChildren = (
11 |
17 |
18 |
{children}
19 |
20 | )
21 | }
22 |
23 | return (
24 |
48 | )
49 | }
50 |
51 | export default Button
52 |
--------------------------------------------------------------------------------
/src/electron-react-context-menu/renderer.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { generateId } from 'utils/ids'
3 |
4 | if (!window.contextMenuIPC) {
5 | console.log('Error: contextMenuIPC bridge missing')
6 | }
7 |
8 | const ContextMenu = props => {
9 | const { menu, leftClick, children } = props
10 |
11 | const showMenu = () => {
12 | const instanceId = generateId()
13 | const clickFuncs = {}
14 | const menuToSend = menu.map((item, i) => {
15 | if (item.click) {
16 | const itemId = `${instanceId}-${i}`
17 | clickFuncs[itemId] = item.click
18 | return {
19 | ...item,
20 | click: itemId,
21 | }
22 | }
23 |
24 | return item
25 | })
26 |
27 | const itemClicked = (event, id) => {
28 | if (!clickFuncs[id]) {
29 | throw new Error(`Invalid clickfunc id for context-menu: ${id}`)
30 | }
31 | clickFuncs[id]()
32 | }
33 |
34 | const menuClosed = event => {
35 | contextMenuIPC.removeItem()
36 | }
37 |
38 | contextMenuIPC.onItem(itemClicked)
39 | contextMenuIPC.onceClose(menuClosed)
40 | contextMenuIPC.sendOpen(menuToSend)
41 | }
42 |
43 | return React.cloneElement(React.Children.only(children), {
44 | ...(leftClick && { onClick: showMenu }),
45 | ...(!leftClick && { onContextMenu: showMenu }),
46 | })
47 | }
48 |
49 | export default ContextMenu
50 |
--------------------------------------------------------------------------------
/src/electron-app/settings.js:
--------------------------------------------------------------------------------
1 | import store from './store'
2 | import chokidar from 'chokidar'
3 | import { app } from 'electron'
4 | import { join } from './disk/path'
5 | import { readJson, readJsonSync, writeJson, existsSync } from './disk/fs'
6 | import { debounce, isEqual } from 'lodash'
7 | import { subscribe } from 'state-management/watcher'
8 | import * as actions from 'state/settings/interface'
9 |
10 | const USER_SETTINGS_FILE = 'user-settings.json'
11 | const USER_SETTINGS_PATH = join(app.getPath('userData'), USER_SETTINGS_FILE)
12 |
13 | const readSettings = () => {
14 | return readJson(USER_SETTINGS_PATH)
15 | }
16 |
17 | const readSettingsSync = () => {
18 | if (existsSync(USER_SETTINGS_PATH)) {
19 | return readJsonSync(USER_SETTINGS_PATH)
20 | }
21 | return {}
22 | }
23 |
24 | const writeSettings = debounce(data => {
25 | writeJson(USER_SETTINGS_PATH, data)
26 | }, 500)
27 |
28 | const initSettings = () => {
29 | subscribe('settings', writeSettings)
30 |
31 | chokidar
32 | .watch(USER_SETTINGS_PATH, {
33 | ignoreInitial: true,
34 | })
35 | .on('change', async () => {
36 | const newSettings = await readSettings()
37 | const { settings } = store.getState()
38 | if (!isEqual(newSettings, settings)) {
39 | store.dispatch(actions.updateExternal(newSettings))
40 | }
41 | })
42 | }
43 |
44 | export { readSettings, readSettingsSync, initSettings }
45 |
--------------------------------------------------------------------------------
/src/electron-app/preload.js:
--------------------------------------------------------------------------------
1 | import ipcConstants from 'ipc/constants'
2 | import { contextBridge, ipcRenderer } from 'electron'
3 | import * as channels from 'electron-redux-ipc/channels'
4 | import 'electron-react-context-menu/preload'
5 |
6 | // this takes all our ipc channels and turns them into callable
7 | // invoke functions. The electron guides promote not exposing the
8 | // invoke function directly, which is adhered to here, but seems
9 | // unlikely as a security issue either way.
10 | const bridgeFuncs = {}
11 | Object.keys(ipcConstants).forEach(type => {
12 | const channels = ipcConstants[type]
13 | Object.values(channels).forEach(channel => {
14 | bridgeFuncs[channel] = (...args) => {
15 | return ipcRenderer.invoke(channel, ...args)
16 | }
17 | })
18 | })
19 |
20 | contextBridge.exposeInMainWorld('ipcInvoke', bridgeFuncs)
21 |
22 | // in order for the redux ipc to work correctly, ipcRenderer needs to exposed
23 | contextBridge.exposeInMainWorld('electronReduxIPC', {
24 | onMainAction: func =>
25 | ipcRenderer.on(channels.action, (e, action) => func(action)),
26 | sendAction: action => ipcRenderer.send(channels.action, action),
27 | getInitialState: () => ipcRenderer.invoke(channels.initialState),
28 | })
29 |
30 | // file properties require a custom solution for access
31 | contextBridge.exposeInMainWorld('properties', {
32 | selectFile: blockPath =>
33 | ipcRenderer.invoke(ipcConstants.properties.selectFile, blockPath),
34 | })
35 |
--------------------------------------------------------------------------------
/src/components/graph-panel/link.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { getBezierPath } from 'react-flow-renderer'
3 | //import ContextMenu from 'electron-react-context-menu/renderer'
4 |
5 | export default function CustomEdge({
6 | id,
7 | sourceX,
8 | sourceY,
9 | targetX,
10 | targetY,
11 | sourcePosition,
12 | targetPosition,
13 | selected,
14 | data,
15 | }) {
16 | const edgePath = getBezierPath({
17 | sourceX,
18 | sourceY,
19 | sourcePosition,
20 | targetX,
21 | targetY,
22 | targetPosition,
23 | })
24 | return (
25 | <>
26 | {/*
{
31 | console.log('removeLink()')
32 | },
33 | },
34 | ]}>*/}
35 |
45 | {/**/}
46 |
56 | >
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/configs/webpack.dev.engine.config.js:
--------------------------------------------------------------------------------
1 | const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin')
2 | const HtmlWebpackPlugin = require('html-webpack-plugin')
3 |
4 | module.exports = {
5 | mode: 'development',
6 | entry: './src/engine/index.js',
7 | target: 'electron-renderer',
8 | devtool: 'source-map',
9 | module: {
10 | exprContextCritical: false, // supress critical warning for require-resolve
11 | rules: [
12 | {
13 | test: /\.jsx?$/,
14 | exclude: [/node_modules/],
15 | use: {
16 | loader: 'babel-loader',
17 | options: {
18 | presets: [
19 | [
20 | '@babel/preset-env',
21 | {
22 | targets: { esmodules: true },
23 | },
24 | ],
25 | [
26 | '@babel/preset-react',
27 | { development: true, runtime: 'automatic' },
28 | ],
29 | ],
30 | plugins: ['react-refresh/babel'],
31 | },
32 | },
33 | },
34 | ],
35 | },
36 | plugins: [
37 | new ReactRefreshPlugin(),
38 | new HtmlWebpackPlugin({
39 | template: './src/engine/index.html',
40 | }),
41 | ].filter(Boolean),
42 | resolve: {
43 | modules: ['./src', 'node_modules'],
44 | extensions: ['.js', '.jsx'],
45 | },
46 | devServer: {
47 | static: {
48 | directory: './src/engine',
49 | },
50 | hot: true,
51 | port: 3099,
52 | },
53 | }
54 |
--------------------------------------------------------------------------------
/src/utils/hooks.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef, useCallback, useReducer } from 'react'
2 |
3 | export const useRafRerender = () => {
4 | const rendering = useRef(false)
5 | const [, forceUpdate] = useReducer(x => x + 1, 0)
6 |
7 | const render = useCallback(() => {
8 | forceUpdate()
9 | rendering.current = false
10 | }, [])
11 |
12 | return () => {
13 | if (!rendering.current) {
14 | rendering.current = true
15 | requestAnimationFrame(render)
16 | }
17 | }
18 | }
19 |
20 | export const useEffectNoInitial = (fn, deps) => {
21 | const isMounted = useRef(false)
22 |
23 | useEffect(() => {
24 | if (isMounted.current) {
25 | return fn()
26 | }
27 | }, deps)
28 |
29 | useEffect(() => {
30 | isMounted.current = true
31 | }, [])
32 | }
33 |
34 | export const useClickOutside = onClick => {
35 | const ref = useRef(null)
36 |
37 | const handleClickOutside = event => {
38 | if (ref.current && !ref.current.contains(event.target)) {
39 | onClick(event)
40 | }
41 | }
42 |
43 | useEffect(() => {
44 | document.addEventListener('click', handleClickOutside)
45 | return () => {
46 | document.removeEventListener('click', handleClickOutside)
47 | }
48 | })
49 |
50 | return ref
51 | }
52 |
53 | export const useIsMounted = () => {
54 | const isMountedRef = useRef(true)
55 | const isMounted = useCallback(() => isMountedRef.current, [])
56 |
57 | useEffect(() => {
58 | return () => void (isMountedRef.current = false)
59 | }, [])
60 |
61 | return isMounted
62 | }
63 |
--------------------------------------------------------------------------------
/src/state/blocks/interface.js:
--------------------------------------------------------------------------------
1 | import { actions } from './model'
2 | import defaultSettings from '../settings/definitions'
3 | import parserBabel from 'prettier/parser-babel'
4 | import prettier from 'prettier/standalone'
5 |
6 | export const updateCode = actions.updateCode
7 | export const saveCode = blockId => async (dispatch, getState) => {
8 | const { settings, blocks } = getState()
9 | const block = blocks[blockId]
10 | try {
11 | const formattedCode = prettier.format(block.hotcode, {
12 | ...defaultSettings.prettier,
13 | ...settings.prettier,
14 | parser: 'babel',
15 | plugins: [parserBabel],
16 | })
17 | dispatch(actions.persistCode(blockId, formattedCode))
18 | } catch (e) {
19 | console.error(e)
20 | dispatch(actions.persistCode(blockId, block.hotcode))
21 | }
22 | }
23 |
24 | export const createProperty =
25 | (blockId, name, property = {}) =>
26 | dispatch => {
27 | dispatch(actions.createProperty(blockId, name, property))
28 | return name
29 | }
30 | export const updateProperty = actions.updateProperty
31 | export const removeProperty = actions.removeProperty
32 | export const reorderPropertyAbove = actions.reorderPropertyAbove
33 | export const reorderPropertyBelow = actions.reorderPropertyBelow
34 | export const unlock = actions.unlock
35 | export const saveStateComplete = actions.saveStateComplete
36 | export const loadStateComplete = actions.loadStateComplete
37 | export const setPaused = actions.setPaused
38 | export const setForceRun = actions.setForceRun
39 | export const showChat = actions.showChat
40 |
--------------------------------------------------------------------------------
/configs/webpack.dev.ui.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin')
3 | const HtmlWebpackPlugin = require('html-webpack-plugin')
4 | const DotenvPlugin = require('dotenv-webpack')
5 |
6 | module.exports = {
7 | mode: 'development',
8 | entry: './src/ui/index.js',
9 | target: 'web',
10 | devtool: 'source-map',
11 | module: {
12 | rules: [
13 | {
14 | test: /\.jsx?$/,
15 | exclude: [/node_modules/],
16 | use: {
17 | loader: 'babel-loader',
18 | options: {
19 | presets: [
20 | [
21 | '@babel/preset-env',
22 | {
23 | targets: { esmodules: true },
24 | },
25 | ],
26 | [
27 | '@babel/preset-react',
28 | { development: true, runtime: 'automatic' },
29 | ],
30 | ],
31 | plugins: ['react-refresh/babel'],
32 | },
33 | },
34 | },
35 | {
36 | test: /\.css$/i,
37 | use: ['style-loader', 'css-loader'],
38 | },
39 | ],
40 | },
41 | plugins: [
42 | new DotenvPlugin(),
43 | new ReactRefreshPlugin(),
44 | new HtmlWebpackPlugin({
45 | template: './src/ui/index.html',
46 | }),
47 | ].filter(Boolean),
48 | resolve: {
49 | modules: ['./src', 'node_modules'],
50 | extensions: ['.js', '.jsx'],
51 | },
52 | devServer: {
53 | static: {
54 | directory: './src/ui',
55 | },
56 | hot: true,
57 | },
58 | }
59 |
--------------------------------------------------------------------------------
/src/ui/index.js:
--------------------------------------------------------------------------------
1 | // this needs to be higher than any other firebase related import
2 | import { initFirebase } from 'block-server/firebase'
3 | import ReactDOM from 'react-dom'
4 | import thunk from 'redux-thunk'
5 | import { createStore, applyMiddleware, compose } from 'redux'
6 | import { Provider } from 'react-redux'
7 | import { reducer } from 'state'
8 | import Initialisation from 'components/initialisation'
9 | import { ThemeProvider } from 'theme-ui'
10 | import { AppLayout } from 'components/layout'
11 | import darkTheme from 'themes/dark'
12 | import {
13 | getInitialState,
14 | forwardActionsToMain,
15 | handleMainActions,
16 | } from 'electron-redux-ipc/renderer'
17 | import { startWatcher } from 'state-management/watcher'
18 |
19 | async function start() {
20 | const initialState = await getInitialState()
21 |
22 | const composeEnhancers =
23 | (typeof window !== 'undefined' &&
24 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ||
25 | compose
26 | const enhancer = composeEnhancers(
27 | applyMiddleware(thunk, forwardActionsToMain)
28 | )
29 | const store = createStore(reducer, initialState, enhancer)
30 | window.store = store
31 |
32 | handleMainActions(store)
33 | startWatcher(store)
34 | initFirebase(store)
35 |
36 | const App = () => (
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | )
45 |
46 | const app = document.getElementById('app')
47 | ReactDOM.render(
, app)
48 | }
49 |
50 | start()
51 |
--------------------------------------------------------------------------------
/src/components/sidebar-panel/block-information/state-settings.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 |
3 | import { FileInput, Flex } from 'components/system'
4 | import { Form, FormItem } from 'components/form'
5 |
6 | import { invoke } from 'ipc/renderer'
7 | import { useProgram } from 'state/program/hooks'
8 |
9 | const StateSettings = props => {
10 | const { blockId } = props
11 | const [program, programActions] = useProgram()
12 |
13 | return (
14 |
20 |
State Settings
21 |
27 |
49 |
50 |
51 | )
52 | }
53 |
54 | export default StateSettings
55 |
--------------------------------------------------------------------------------
/src/state-management/builder.js:
--------------------------------------------------------------------------------
1 | import produce from 'immer'
2 | import { createLogger } from 'utils/logger'
3 |
4 | const log = createLogger('state.builder', 'orchid')
5 |
6 | const buildModel = (
7 | prefix,
8 | initialState,
9 | handlerDefs,
10 | customHandlers = () => ({}),
11 | customReducer
12 | ) => {
13 | const constants = {}
14 | const actions = {}
15 | const handlers = {}
16 |
17 | Object.keys(handlerDefs).forEach(name => {
18 | const constant = `${prefix}/${name}`
19 | constants[name] = constant
20 |
21 | // NOTE: type and arg checking could be added here
22 | actions[name] = (...values) => ({
23 | values,
24 | type: constant,
25 | })
26 |
27 | handlers[constant] = (...args) => {
28 | const [_, ...params] = args
29 | //log(`(${constant}) ->`, params)
30 | log(`[${constant}]`)
31 | return handlerDefs[name](...args)
32 | }
33 | })
34 |
35 | let customHandlerCache
36 | const getCustomHandlers = () => {
37 | if (!customHandlerCache) {
38 | customHandlerCache = customHandlers()
39 | }
40 | return customHandlerCache
41 | }
42 |
43 | const reducer = (state = initialState, action) => {
44 | const handler = handlers[action.type] || getCustomHandlers()[action.type]
45 |
46 | if (handler) {
47 | return produce(state, draft => handler(draft, ...action.values))
48 | }
49 |
50 | if (customReducer) {
51 | return produce(state, draft => customReducer(draft, action))
52 | }
53 |
54 | return state
55 | }
56 |
57 | return {
58 | constants,
59 | actions,
60 | handlers,
61 | reducer,
62 | prefix,
63 | }
64 | }
65 |
66 | export { buildModel }
67 |
--------------------------------------------------------------------------------
/src/state/program/interface.js:
--------------------------------------------------------------------------------
1 | import { actions } from './model'
2 |
3 | function findIdealName(name, names) {
4 | let i = 1
5 | let ideal = name
6 | while (names.includes(ideal)) {
7 | ideal = `${name}${i}`
8 | i++
9 | }
10 | return ideal
11 | }
12 |
13 | export const createLink =
14 | (sourceBlockId, sourcePropId, targetBlockId, targetPropId) =>
15 | (dispatch, getState) => {
16 | const { blocks } = getState()
17 | let newId
18 | if (!sourcePropId) {
19 | const sourceBlock = blocks[sourceBlockId]
20 | const { propertyOrder } = sourceBlock.config.block
21 | newId = sourcePropId = findIdealName(targetPropId, propertyOrder)
22 | } else if (!targetPropId) {
23 | const targetBlock = blocks[targetBlockId]
24 | const { propertyOrder } = targetBlock.config.block
25 | newId = targetPropId = findIdealName(sourcePropId, propertyOrder)
26 | }
27 |
28 | dispatch(
29 | actions.createLink(
30 | sourceBlockId,
31 | sourcePropId,
32 | targetBlockId,
33 | targetPropId
34 | )
35 | )
36 | return newId
37 | }
38 |
39 | export const updateBlockPosition = actions.updateBlockPosition
40 | export const updatePropertyValue = actions.updatePropertyValue
41 | export const updateBlockOrder = actions.updateBlockOrder
42 | export const updateAutoloadStatePath = actions.updateAutoloadStatePath
43 | export const removeLink = actions.removeLink
44 | export const activateLink = actions.activateLink
45 | export const reloadEngine = actions.reloadEngine
46 | export const toggleRunning = actions.toggleRunning
47 | export const runtimeBlockError = actions.runtimeBlockError
48 | export const updateSettings = actions.updateSettings
49 |
--------------------------------------------------------------------------------
/src/components/modals/program_settings.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { useState } from 'state-management/hooks'
3 | import { PopMenu, Button, Input } from 'components/system'
4 | import { Form, FormItem, FormActions } from 'components/form'
5 | import { useProgram } from 'state/program/hooks'
6 |
7 | const ProgramOptions = props => {
8 | const { close, top, left } = props
9 | const [program, programActions] = useProgram()
10 | const [framesPerRAF, setFramesPerRAF] = useState(
11 | program.config.framesPerRAF || 1
12 | )
13 |
14 | return (
15 |
close()}
19 | title={`Program Settings`}>
20 |
52 |
53 | )
54 | }
55 |
56 | export default ProgramOptions
57 |
--------------------------------------------------------------------------------
/src/components/sidebar-panel/block-information/property-list.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { useState } from 'state-management/hooks'
3 | import { useBlock } from 'state/blocks/hooks'
4 | import Property from './property'
5 | import EditProperty from './edit-property'
6 |
7 | const PropertyList = props => {
8 | const { blockId } = props
9 | const [block, blockActions] = useBlock(blockId)
10 | const { properties, propertyOrder } = block.config.block
11 | const [adding, setAdding] = useState(false)
12 |
13 | return (
14 |
20 |
Properties
21 | {propertyOrder.map(propName => {
22 | const property = properties[propName]
23 | return (
24 |
32 | )
33 | })}
34 |
38 |
39 | {adding && (
40 | setAdding(false)}
44 | onSave={(name, property) => {
45 | blockActions.createProperty(name, property)
46 | setAdding(false)
47 | }}
48 | />
49 | )}
50 |
51 |
52 | )
53 | }
54 |
55 | export default PropertyList
56 |
--------------------------------------------------------------------------------
/src/utils/object.js:
--------------------------------------------------------------------------------
1 | function isValidSerialisable(obj, seen = new Set()) {
2 | if (typeof obj === 'function') return false
3 | if (obj === null || typeof obj !== 'object') return true
4 | if (seen.has(obj)) return true
5 | seen.add(obj)
6 |
7 | if (Array.isArray(obj)) {
8 | return obj.every(item => isValidSerialisable(item, seen))
9 | }
10 |
11 | const proto = Object.getPrototypeOf(obj)
12 | const isPlain = proto === Object.prototype || proto === null
13 | if (!isPlain) return true
14 |
15 | for (const key in obj) {
16 | if (!Object.hasOwn(obj, key)) continue
17 | if (!isValidSerialisable(obj[key], seen)) {
18 | return false
19 | }
20 | }
21 |
22 | return true
23 | }
24 |
25 | // recursive object cleaner that allows objects to be written to disk for example
26 | function deepCleanObject(obj, seen = new Map()) {
27 | if (obj === null || typeof obj !== 'object') return obj
28 | if (seen.has(obj)) return seen.get(obj)
29 |
30 | const proto = Object.getPrototypeOf(obj)
31 | const isPlain = proto === Object.prototype || proto === null
32 |
33 | let clone
34 |
35 | if (Array.isArray(obj)) {
36 | clone = []
37 | seen.set(obj, clone)
38 | for (const item of obj) {
39 | clone.push(
40 | typeof item === 'function' ? undefined : deepCleanObject(item, seen)
41 | )
42 | }
43 | return clone
44 | }
45 |
46 | if (isPlain) {
47 | clone = {}
48 | seen.set(obj, clone)
49 | for (const key in obj) {
50 | if (!Object.hasOwn(obj, key)) continue
51 | const val = obj[key]
52 | if (typeof val !== 'function') {
53 | clone[key] = deepCleanObject(val, seen)
54 | }
55 | }
56 | return clone
57 | }
58 |
59 | return obj
60 | }
61 |
62 | export { isValidSerialisable, deepCleanObject }
63 |
--------------------------------------------------------------------------------
/src/components/graph-panel/toolbar.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { Flex, Icon, DropDown, DropDownItem } from 'components/system'
3 | import { useModalActions } from 'state/modals/hooks'
4 | import { modalIds } from 'state/modals/model'
5 | import { invoke } from 'ipc/renderer'
6 |
7 | const Toolbar = props => {
8 | const modalActions = useModalActions()
9 |
10 | return (
11 |
20 |
32 |
33 | Add Block
34 |
35 |
36 | }>
37 | invoke.blocks.create()}>
38 | Add new empty block
39 |
40 | modalActions.open(modalIds.onlineBlockSearch)}>
42 | Search for block online
43 |
44 | invoke.blocks.createFromExisting()}>
45 | Add block from file
46 |
47 |
48 |
49 | )
50 | }
51 |
52 | export default Toolbar
53 |
--------------------------------------------------------------------------------
/src/components/layout/ui-layout.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { Grid, FlexBox } from 'components/system'
3 | import { GraphPanel } from 'components/graph-panel'
4 | import { SideBarPanel } from 'components/sidebar-panel'
5 | import { EditorPanel } from 'components/editor-panel'
6 | import { useWorkspace } from 'state/workspace/hooks'
7 | import StatusBar from 'components/status-bar'
8 | import Modals from 'components/modals'
9 | import Toasts from 'components/toasts'
10 |
11 | const UILayout = props => {
12 | const [workspace] = useWorkspace()
13 | const { showSidebar } = workspace
14 |
15 | return (
16 |
23 | {showSidebar && (
24 |
30 |
31 |
32 | )}
33 |
38 |
39 |
40 |
46 |
47 |
48 |
54 |
55 |
56 |
57 |
58 |
59 | )
60 | }
61 |
62 | export default UILayout
63 |
--------------------------------------------------------------------------------
/src/state-management/watcher.js:
--------------------------------------------------------------------------------
1 | import { get, has, uniqueId } from 'lodash'
2 |
3 | const subscriptions = []
4 | let _store
5 | let _unsubscribeStore
6 |
7 | const defaultComparer = (prev, next) => prev === next
8 |
9 | const processSubscription = (sub, state) => {
10 | const currentValue = get(state, sub.path)
11 | if (!sub.comparer(sub.lastValue, currentValue)) {
12 | sub.fn(currentValue, sub.lastValue, state, sub.id)
13 | sub.lastValue = currentValue
14 | }
15 | }
16 |
17 | const initialiseSubscription = (sub, state) => {
18 | if (!has(sub.lastValue)) {
19 | sub.lastValue = undefined
20 | processSubscription(sub, state)
21 | }
22 | }
23 |
24 | export const startWatcher = store => {
25 | if (_unsubscribeStore) {
26 | throw new Error('redux watcher has already been started')
27 | }
28 |
29 | _store = store
30 |
31 | // initialise all the subscriptions as the store has now been set
32 | subscriptions.forEach(sub => initialiseSubscription(sub, store.getState()))
33 |
34 | // setup the redux subscriber
35 | _unsubscribeStore = store.subscribe(() => {
36 | subscriptions.forEach(sub => processSubscription(sub, store.getState()))
37 | })
38 | }
39 |
40 | export const stopWatcher = () => {
41 | if (_unsubscribeStore) {
42 | _unsubscribeStore()
43 | _unsubscribeStore = null
44 | _store = null
45 | }
46 | }
47 |
48 | export const subscribe = (path, fn, comparer = defaultComparer) => {
49 | const id = uniqueId()
50 | const sub = {
51 | id,
52 | path,
53 | fn,
54 | comparer,
55 | }
56 | subscriptions.push(sub)
57 |
58 | // if the store has already been setup initialise the subscription
59 | if (_store) {
60 | initialiseSubscription(sub, _store.getState())
61 | }
62 |
63 | return id
64 | }
65 |
66 | export const unsubscribe = id => {
67 | const index = subscriptions.findIndex(sub => sub.id === id)
68 | if (index >= 0) {
69 | subscriptions.splice(index, 1)
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/modals/index.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { useModals } from 'state/modals/hooks'
3 | import Intro from './intro'
4 | import DependencySearch from './dependency-search'
5 | import OnlineBlockSearch from './online-block-search'
6 | import EditProperty from './edit-property'
7 | import Confirmation from './confirmation'
8 | import EditBlockInformation from './edit-block-information'
9 | import Auth from './auth'
10 | import { modalIds } from 'state/modals/model'
11 | import ColorPicker from './color-picker'
12 | import ProgramSettings from './program_settings'
13 |
14 | const lookup = {
15 | [modalIds.intro]: Intro,
16 | [modalIds.dependencySearch]: DependencySearch,
17 | [modalIds.onlineBlockSearch]: OnlineBlockSearch,
18 | [modalIds.editProperty]: EditProperty,
19 | [modalIds.confirmation]: Confirmation,
20 | [modalIds.editBlockInformation]: EditBlockInformation,
21 | [modalIds.auth]: Auth,
22 | [modalIds.colorPicker]: ColorPicker,
23 | [modalIds.programSettings]: ProgramSettings,
24 | }
25 |
26 | const Modals = props => {
27 | const [modals, modalsActions] = useModals()
28 |
29 | if (!modals.openId) {
30 | return null
31 | }
32 |
33 | const Modal = lookup[modals.openId]
34 | return (
35 |
{
49 | e.stopPropagation()
50 | if (Modal.clickAway && e.currentTarget === e.target) {
51 | modalsActions.close()
52 | }
53 | }}>
54 | modalsActions.close(response)}
58 | {...modals.data}
59 | />
60 |
61 | )
62 | }
63 |
64 | export default Modals
65 |
--------------------------------------------------------------------------------
/src/components/sidebar-panel/block-information/property.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { useState } from 'state-management/hooks'
3 | import { Flex } from 'components/system'
4 | import EditProperty from './edit-property'
5 | import ContextMenu from 'electron-react-context-menu/renderer'
6 |
7 | const Property = props => {
8 | const {
9 | name,
10 | propertyConfig = {},
11 | usedNames,
12 | updateProperty,
13 | removeProperty,
14 | } = props
15 | const { type } = propertyConfig
16 | const [editing, setEditing] = useState(false)
17 | const [removing, setRemoving] = useState(false)
18 |
19 | const menu = [
20 | {
21 | label: 'Edit',
22 | click: () => setEditing(true),
23 | },
24 | {
25 | label: 'Delete',
26 | click: () => removeProperty(name),
27 | },
28 | ]
29 |
30 | return (
31 |
32 |
33 |
34 | {name} - {type ? type : 'generic'}
35 |
36 | {
38 | setEditing(true)
39 | }}>
40 | EDIT
41 |
42 | {
44 | setRemoving(true)
45 | }}>
46 | REMOVE
47 |
48 | {editing && (
49 | setEditing(false)}
53 | usedNames={usedNames}
54 | onSave={(newName, newConfig) => {
55 | updateProperty(newName, name, newConfig)
56 | setEditing(false)
57 | }}
58 | />
59 | )}
60 | {/*removing && (
61 | setRemoving(false)}
63 | onOK={() => {
64 | removeProperty(name, output)
65 | setRemoving(false)
66 | }}
67 | />
68 | )*/}
69 |
70 |
71 | )
72 | }
73 |
74 | export default Property
75 |
--------------------------------------------------------------------------------
/src/components/system/tags.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import { useState } from 'react'
3 | import { Input } from 'components/system'
4 | import { Icon } from '.'
5 |
6 | const Tags = props => {
7 | const { readOnly, value = [], onChange } = props
8 | const [newTag, setNewTag] = useState('')
9 |
10 | return (
11 | <>
12 |
20 | {value.length <= 0 && (
21 |
25 | [ ]
26 |
27 | )}
28 | {value.map((tag, index) => (
29 |
39 | {tag}
40 | {!readOnly && (
41 | onChange(value.filter((_, i) => i !== index))}
50 | />
51 | )}
52 |
53 | ))}
54 |
55 | {!readOnly && (
56 |
setNewTag(e.target.value)}
63 | placeholder="Add tag and hit enter"
64 | onKeyDown={e => {
65 | if (e.key === 'Enter') {
66 | onChange([...value, newTag])
67 | setNewTag('')
68 | }
69 | }}
70 | />
71 | )}
72 | >
73 | )
74 | }
75 |
76 | export default Tags
77 |
--------------------------------------------------------------------------------
/src/electron-app/disk/fs.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra'
2 | import * as path from './path'
3 | import { shell } from 'electron'
4 | import { generateId } from 'utils/ids'
5 |
6 | export const getFileDetails = filePath => {
7 | return fs.stat(filePath).then(stat => {
8 | const result = {
9 | name: path.basename(filePath),
10 | path: filePath,
11 | }
12 |
13 | if (stat && stat.isDirectory()) {
14 | result.children = []
15 | result.loaded = false
16 | } else {
17 | result.ext = path.extname(filePath)
18 | }
19 |
20 | return result
21 | })
22 | }
23 |
24 | export const readDirectory = async (
25 | dir,
26 | parentId,
27 | idGenerator = generateId
28 | ) => {
29 | const exists = await fs.exists(dir)
30 | if (!exists) {
31 | await fs.mkdir(dir)
32 | }
33 |
34 | const files = await fs.readdir(dir)
35 |
36 | const details = await Promise.all(
37 | files.map(file => {
38 | const filePath = path.resolve(dir, file)
39 | return getFileDetails(filePath)
40 | })
41 | )
42 |
43 | return details.reduce((obj, file) => {
44 | const id = idGenerator()
45 | obj[id] = {
46 | ...file,
47 | }
48 | if (parentId) {
49 | obj[id].parentId = parentId
50 | }
51 | return obj
52 | }, {})
53 | }
54 |
55 | export const createDirectory = _path => {
56 | return fs.mkdir(_path).then(() => getFileDetails(_path))
57 | }
58 |
59 | export const deleteDirectory = _path => {
60 | return shell.trashItem(_path)
61 | }
62 |
63 | export const readFile = (_path, options = 'utf8') => fs.readFile(_path, options)
64 |
65 | export const writeFile = (_path, data, options) =>
66 | fs.writeFile(_path, data, options)
67 |
68 | export const copy = fs.copy
69 | export const readJson = fs.readJson
70 | export const readJsonSync = fs.readJsonSync
71 |
72 | export const writeJson = (
73 | _path,
74 | object,
75 | options = {
76 | spaces: 2,
77 | }
78 | ) => fs.writeJson(_path, object, options)
79 |
80 | export const renameDirectory = fs.rename
81 | export const ensureDir = fs.ensureDir
82 | export const existsSync = fs.existsSync
83 | export const readFileSync = fs.readFileSync
84 | export const writeFileSync = fs.writeFileSync
85 | export const mkdirSync = fs.mkdirSync
86 |
--------------------------------------------------------------------------------
/src/components/system/dropdown.js:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource theme-ui */
2 | import React, { useState } from 'react'
3 | import { useClickOutside } from 'utils/hooks'
4 | import ToolbarButton from './toolbar-button'
5 | import Button from './button'
6 |
7 | const zIndex = 9999
8 |
9 | const DropDown = props => {
10 | const { className, children, buttonStyles, buttonContent } = props
11 | const [showMenu, setShowMenu] = useState(false)
12 |
13 | return (
14 |
21 | {
27 | setShowMenu(true)
28 | }}>
29 | {buttonContent}
30 |
31 | {showMenu && (
32 | setShowMenu(false)}>
33 | {React.Children.map(children, child =>
34 | React.cloneElement(child, { onClose: () => setShowMenu(false) })
35 | )}
36 |
37 | )}
38 |
39 | )
40 | }
41 |
42 | const DropDownMenu = props => {
43 | const { children, hideMenu } = props
44 | const ref = useClickOutside(e => {
45 | e.stopPropagation()
46 | hideMenu()
47 | })
48 | return (
49 |
61 | {children}
62 |
63 | )
64 | }
65 |
66 | const DropDownItem = props => {
67 | const { onClick, onClose, ...rest } = props
68 | return (
69 |