Commit 25818cd9 authored by Casey Ydenberg's avatar Casey Ydenberg
Browse files

Disentangle plugin and node props

parent a52e2113
......@@ -3,3 +3,4 @@
/.docz/
/node_modules/
/dist/
.cache
\ No newline at end of file
......@@ -16,10 +16,11 @@
"build:cjs": "tsc --outDir dist/cjs --module commonjs --project tsconfig.build.json",
"build:es": "tsc --outDir dist/es --declarationDir dist/types --declaration --project tsconfig.build.json",
"dev": "concurrently 'yarn:build:* --watch'",
"lint": "eslint src --ext .ts,.tsx --max-warnings=0",
"lint": "eslint src test --ext .ts,.tsx --max-warnings=0",
"prebuild": "rimraf dist",
"prettier": "prettier --write 'src/**/*.{ts,tsx}'",
"preversion": "concurrently 'yarn:typecheck' 'yarn:lint' 'yarn:test'",
"sandbox": "parcel ./test/index.html",
"test": "jest --runInBand",
"typecheck": "tsc --noEmit",
"version": "yarn build"
......@@ -132,6 +133,7 @@
"husky": "^4.3.0",
"jest": "^26.5.3",
"lodash": "^4.17.20",
"parcel-bundler": "^1.12.4",
"prettier": "^2.1.2",
"react": "^16.13.1",
"react-dnd-html5-backend": "^9.5.1",
......
/*!
* © 2019 Atypon Systems LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import Modal from 'react-modal'
import styled from 'styled-components'
import { MenuItemContainer, MenuList, Text } from './MenuItemContainer'
import { MenuItem } from './types'
Modal.setAppElement('#root')
const ApplicationMenuContainer = styled.div`
display: flex;
font-size: 14px;
`
const MenuHeading = styled.div<{ isOpen: boolean }>`
padding: 4px 8px;
cursor: pointer;
border: 1px solid transparent;
border-bottom: none;
`
const MenuContainer = styled.div<{ isEnabled: boolean }>`
position: relative;
& ${MenuHeading} {
background-color: #fff;
color: ${(props) => (props.isEnabled ? '#353535' : '#e2e2e2')};
&:hover {
background-color: ${(props) => (props.isEnabled ? '#f2fbfc' : '#fff')};
}
}
`
interface Props {
menuState: MenuItem[]
wrapperRef: React.Ref<HTMLDivElement>
handleItemClick: (indices: number[]) => void
}
export const ApplicationMenus: React.FC<Props> = ({
menuState,
wrapperRef,
handleItemClick,
}) => {
return (
<ApplicationMenuContainer ref={wrapperRef}>
{menuState.map((menu, index) => {
return (
<MenuContainer key={`menu-${index}`} isEnabled={menu.enable}>
<MenuHeading
onMouseDown={(e) => {
e.preventDefault()
handleItemClick([index])
}}
isOpen={menu.isOpen}
>
<Text>{menu.label}</Text>
</MenuHeading>
{menu.enable && menu.isOpen && (
<MenuList>
{menu.submenu &&
menu.submenu.map((submenu, menuIndex) => (
<MenuItemContainer
key={`menu-${menuIndex}`}
item={submenu}
depth={1}
handleMenuItemClick={() =>
handleItemClick([index, menuIndex])
}
handleSubmenuItemClick={(submenuIndex: number) =>
handleItemClick([index, menuIndex, submenuIndex])
}
/>
))}
</MenuList>
)}
</MenuContainer>
)
})}
</ApplicationMenuContainer>
)
}
......@@ -15,20 +15,14 @@
*/
import TriangleCollapsed from '@manuscripts/assets/react/TriangleCollapsed'
import {
ManuscriptEditorState,
ManuscriptEditorView,
} from '@manuscripts/manuscript-transform'
import React from 'react'
import { createPortal } from 'react-dom'
import { Manager, Popper, Reference } from 'react-popper'
import styled from 'styled-components'
import { MenuItem, MenuSeparator } from './ApplicationMenu'
import { Shortcut } from './Shortcut'
import { MenuItem } from './types'
export const Text = styled.div`
flex: 1;
flex: 1 0 auto;
`
export const MenuList = styled.div`
......@@ -57,14 +51,23 @@ export const MenuList = styled.div`
}
`
const SubmenuContainer = styled.div`
position: relative;
`
const SubmenuList = styled(MenuList)`
top: 0;
left: 100%;
`
const Separator = styled.div`
height: 0;
border-bottom: 1px solid ${(props) => props.theme.colors.border.secondary};
margin: ${(props) => props.theme.grid.unit}px 0;
border-bottom: 1px solid #e2e2e2;
margin: 4px 0;
`
const Active = styled.div`
width: ${(props) => props.theme.grid.unit * 4}px;
width: 16px;
display: inline-flex;
flex-shrink: 0;
justify-content: center;
......@@ -72,21 +75,19 @@ const Active = styled.div`
`
const Arrow = styled(TriangleCollapsed)`
margin-left: ${(props) => props.theme.grid.unit * 2}px;
margin-left: 8px;
`
const Container = styled.div`
const Container = styled.div<{ isOpen: boolean }>`
align-items: center;
cursor: pointer;
display: flex;
padding: ${(props) => props.theme.grid.unit * 2}px
${(props) => props.theme.grid.unit * 4}px
${(props) => props.theme.grid.unit * 2}px
${(props) => props.theme.grid.unit}px;
padding: 8px 16px 8px 4px;
position: relative;
${(props) => props.isOpen && 'background: #f2fbfc;'}
&:hover {
background: ${(props) => props.theme.colors.background.fifth};
background: #f2fbfc;
}
&.disabled {
......@@ -95,144 +96,79 @@ const Container = styled.div`
}
`
interface MenuItemProps {
item: MenuItem | MenuSeparator
view: ManuscriptEditorView
closeMenu: () => void
interface Props {
item: MenuItem
handleMenuItemClick: () => void
handleSubmenuItemClick?: (index: number) => void
depth: number
}
interface MenuItemState {
isOpen: boolean
isDropdownOpen: boolean
}
const classNameFromState = (item: MenuItem, state: ManuscriptEditorState) =>
item.enable && !item.enable(state) ? 'disabled' : ''
const classNameFromState = (item: MenuItem) => (item.enable ? '' : 'disabled')
const activeContent = (item: MenuItem, state: ManuscriptEditorState) =>
item.active && item.active(state) ? '' : ''
const activeContent = (item: MenuItem) => (item.active ? '' : '')
const isSeparator = (item: MenuItem | MenuSeparator): item is MenuSeparator =>
item.role === 'separator'
const isSeparator = (item: MenuItem) => item.role === 'separator'
export class MenuItemContainer extends React.Component<
MenuItemProps,
MenuItemState
> {
public state = {
isOpen: false,
isDropdownOpen: false,
export const MenuItemContainer: React.FC<Props> = ({
item,
handleMenuItemClick,
handleSubmenuItemClick,
depth,
}) => {
if (isSeparator(item)) {
return <Separator />
}
private menuTimeout?: number
public render(): React.ReactNode | null {
const { item, view, closeMenu } = this.props
const { isOpen } = this.state
if (isSeparator(item)) {
return <Separator />
}
if (!item.submenu) {
return (
<Container
className={classNameFromState(item, view.state)}
onMouseDown={(event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
if (item.run) {
item.run(view.state, view.dispatch)
closeMenu()
} else {
// console.warn('No dropdown or run')
}
}}
>
<Active>{activeContent(item, view.state)}</Active>
<Text>{item.label(view.state)}</Text>
{item.accelerator && <Shortcut accelerator={item.accelerator} />}
</Container>
)
}
if (!item.submenu) {
return (
<div onMouseLeave={this.closeMenu}>
<Manager>
<Reference>
{({ ref }) => (
<Container
// @ts-ignore: styled
ref={ref}
onMouseEnter={() => {
if (!item.enable || item.enable(view.state)) {
this.openMenu()
}
}}
className={classNameFromState(item, view.state)}
>
<Active>{activeContent(item, view.state)}</Active>
<Text>{item.label(view.state)}</Text>
{item.submenu && <Arrow />}
{item.accelerator && (
<Shortcut accelerator={item.accelerator} />
)}
</Container>
)}
</Reference>
{isOpen &&
createPortal(
<Popper
placement="right-start"
modifiers={{
preventOverflow: {
enabled: false,
},
}}
>
{({ ref, style, placement }) => (
<MenuList
// @ts-ignore: styled
ref={ref}
style={style}
data-placement={placement}
>
{item.submenu &&
item.submenu.map((menu, index) => (
<MenuItemContainer
key={`menu-${index}`}
item={menu}
view={view}
closeMenu={closeMenu}
/>
))}
</MenuList>
)}
</Popper>,
document.getElementById('menu') as HTMLDivElement
)}
</Manager>
</div>
<Container
isOpen={item.isOpen}
className={classNameFromState(item)}
onMouseDown={(e) => {
e.preventDefault()
handleMenuItemClick()
}}
>
<Active>{activeContent(item)}</Active>
<Text>{item.label}</Text>
{item.accelerator && <Shortcut accelerator={item.accelerator} />}
</Container>
)
}
private openMenu = () => {
window.clearTimeout(this.menuTimeout)
this.menuTimeout = window.setTimeout(() => {
this.setState({
isOpen: true,
})
}, 100)
}
private closeMenu = () => {
window.clearTimeout(this.menuTimeout)
this.menuTimeout = window.setTimeout(() => {
this.setState({
isOpen: false,
})
}, 100)
}
return (
<SubmenuContainer>
<Container
onMouseDown={(e) => {
e.preventDefault()
handleMenuItemClick()
}}
isOpen={item.isOpen}
className={classNameFromState(item)}
>
<Active>{activeContent(item)}</Active>
<Text>{item.label}</Text>
{item.submenu && <Arrow />}
{item.accelerator && <Shortcut accelerator={item.accelerator} />}
</Container>
{item.isOpen && (
<SubmenuList>
{item.submenu &&
item.submenu.map((menu, index) => (
<MenuItemContainer
key={`menu-${index}`}
item={menu}
handleMenuItemClick={() => {
if (!handleSubmenuItemClick) {
return
}
handleSubmenuItemClick(index)
}}
depth={depth + 1}
/>
))}
</SubmenuList>
)}
</SubmenuContainer>
)
}
......@@ -18,12 +18,16 @@ import React from 'react'
import styled from 'styled-components'
import { isMac } from '../../lib/platform'
import { Accelerator } from './ApplicationMenu'
export interface Accelerator {
mac: string
pc: string
}
export const ShortcutContainer = styled.div`
display: inline-flex;
color: ${(props) => props.theme.colors.text.secondary};
margin-left: ${(props) => props.theme.grid.unit * 4}px;
color: #6e6e6e;
margin-left: 16px;
flex-shrink: 0;
justify-content: flex-end;
`
......@@ -38,13 +42,11 @@ const modifiers: { [key in Keys]: string } & { [key: string]: string } = isMac
Option: '',
CommandOrControl: '',
Shift: '',
Enter: '',
}
: {
Option: 'Alt',
CommandOrControl: 'Ctrl',
Shift: '',
Enter: '',
Shift: 'Shift',
}
const system = isMac ? 'mac' : 'pc'
......
/*!
* © 2019 Atypon Systems LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ApplicationMenus } from './ApplicationMenu'
export { useApplicationMenus } from './useApplicationMenus'
export type { MenuSpec } from './types'
/*!
* © 2019 Atypon Systems LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MenuItem, MenuPointer, MenuSpec } from './types'
export const getMenuState = (
spec: MenuSpec[],
pointer: MenuPointer,
depth = 0
): MenuItem[] => {
const pointerPart = pointer[depth]
/* tslint:disable-next-line:cyclomatic-complexity */
return spec.map((itemSpec, index) => {
return {
id: itemSpec.id || '',
role: itemSpec.role || '',
accelerator: itemSpec.accelerator || null,
label: itemSpec.label || '',
active: itemSpec.active || false,
enable: typeof itemSpec.enable === 'boolean' ? itemSpec.enable : true,
run: itemSpec.run || null,
submenu: itemSpec.submenu
? getMenuState(itemSpec.submenu, pointer, depth + 1)
: null,
isOpen: index === pointerPart,
}
})
}
export const getMenuItem = (
menuState: MenuItem[],
indices: number[]
): MenuItem | null => {
const [head, ...tail] = indices.filter((item) => item !== -1)
const item = menuState[head]
if (!tail.length) {
return item
} else if (item.submenu) {
return getMenuItem(item.submenu, tail)
}
return null
}
/*!
* © 2019 Atypon Systems LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ManuscriptEditorState } from '@manuscripts/manuscript-transform'
import { Transaction } from 'prosemirror-state'
export type Dispatch = (tr: Transaction) => void
export interface Accelerator {
mac: string
pc: string
}
export type FromState<T> = ((state: ManuscriptEditorState) => T) | T
export interface MenuSpec {
id?: string
label?: React.ReactNode | string
role?: string
accelerator?: Accelerator