From f031dc740193897ba0f61ea0caec218814415e46 Mon Sep 17 00:00:00 2001 From: liaoxuezhi Date: Tue, 19 Nov 2019 16:35:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20ContextMenu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scss/_variables.scss | 3 +- scss/components/_context-menu.scss | 174 +++++++++++++++++++++++++ scss/themes/cxd.scss | 1 + scss/themes/dark.scss | 25 ++-- scss/themes/default.scss | 165 ++++++++++++------------ src/components/ContextMenu.tsx | 199 +++++++++++++++++++++++++++++ src/components/index.tsx | 3 + src/index.tsx | 4 + src/renderers/Form/List.tsx | 14 +- src/renderers/Form/Options.tsx | 14 +- 10 files changed, 494 insertions(+), 108 deletions(-) create mode 100644 scss/components/_context-menu.scss create mode 100644 src/components/ContextMenu.tsx diff --git a/scss/_variables.scss b/scss/_variables.scss index 216204ff..9ff079b1 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -125,7 +125,8 @@ $zindex-sticky: 1100 !default; $zindex-fixed: 1200 !default; $zindex-modal: 1300 !default; $zindex-popover: 1400 !default; -$zindex-tooltip: 1500 !default; +$zindex-contextmenu: 1500 !default; +$zindex-tooltip: 1600 !default; $zindex-toast: 2000 !default; $gap-xs: px2rem(5px) !default; diff --git a/scss/components/_context-menu.scss b/scss/components/_context-menu.scss new file mode 100644 index 00000000..e4b8ca99 --- /dev/null +++ b/scss/components/_context-menu.scss @@ -0,0 +1,174 @@ +@keyframes contextMenuIn { + from { + opacity: 0; + } +} + +@keyframes contextMenuOut { + to { + opacity: 0; + } +} + +.#{$ns}ContextMenu { + &-menu { + position: absolute; + z-index: $zindex-contextmenu; + + &.in, + &.out { + // opacity: 1; + // transform: translateY(0) scale(1); + animation-duration: 0.35s; + animation-fill-mode: both; + } + + &.in { + animation-name: contextMenuIn; + } + + &.out { + animation-name: contextMenuOut; + } + + display: block; + position: absolute; + + margin: 0; + padding: 4px 0 5px; + + background: rgba(239, 239, 239, 0.95); + box-shadow: 0px 4px 9px rgba(0, 0, 0, 0.34); + border-radius: 7px; + + color: rgba(0, 0, 0, 0.75); + font-family: -apple-system, Lucida Grande; + font-size: 14px; + line-height: 15px; + + &::before { + display: block; + position: absolute; + content: ''; + top: -1px; + left: -1px; + bottom: -1px; + right: -1px; + + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.125); + z-index: -1; + } + } + + &-divider { + border: none; + height: 1px; + background: rgba(0, 0, 0, 0.1); + margin: 6px 1px 5px; + padding: 0; + } + + &-list { + list-style: none; + margin: 0; + padding: 0; + width: 185px; + } + + &-item { + position: relative; + > a { + white-space: nowrap; + display: block; + padding: 0 20px; + border-top: 1px solid rgba(0, 0, 0, 0); + border-bottom: 1px solid rgba(0, 0, 0, 0); + color: inherit; + } + + &:hover > a { + text-decoration: none; + color: #fff; + background: -webkit-linear-gradient(top, #648bf5, #2866f2); + background: linear-gradient(to bottom, #648bf5 0%, #2866f2 100%); + border-top: 1px solid #5a82eb; + border-bottom: 1px solid #1758e7; + } + + &.is-disabled > a { + color: #999; + pointer-events: none; + } + + &.has-child > a::after { + content: ''; + width: 0; + height: 0; + border-width: 4px 7px; + border-style: solid; + border-color: transparent transparent transparent rgba(0, 0, 0, 0.75); + text-shadow: 0px 4px 9px rgba(0, 0, 0, 0.34); + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + } + &.has-child:hover > a::after { + border-color: transparent transparent transparent #fff; + } + } + + &-subList { + display: none; + list-style: none; + } + + &-item:hover > &-subList { + display: block; + animation-duration: 0.35s; + animation-fill-mode: both; + animation-name: contextMenuIn; + + display: block; + position: absolute; + left: 100%; + top: -3px; + + margin: 0; + padding: 4px 0 5px; + + background: rgba(239, 239, 239, 0.95); + box-shadow: 0px 4px 9px rgba(0, 0, 0, 0.34); + border-radius: 7px; + + color: rgba(0, 0, 0, 0.75); + font-family: -apple-system, Lucida Grande; + font-size: 14px; + line-height: 15px; + + &::before { + display: block; + position: absolute; + content: ''; + top: -1px; + left: -1px; + bottom: -1px; + right: -1px; + + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.125); + z-index: -1; + } + } + + &-overlay { + position: fixed !important; + top: 0; + left: 0; + right: 0; + z-index: 1; + bottom: 0; + background: transparent; + } +} diff --git a/scss/themes/cxd.scss b/scss/themes/cxd.scss index e6602fdc..92822197 100644 --- a/scss/themes/cxd.scss +++ b/scss/themes/cxd.scss @@ -517,6 +517,7 @@ $Card-actions-onChecked-onHover-bg: $white; @import '../components/dropdown'; @import '../components/collapse'; @import '../components/color'; +@import '../components/context-menu'; @import '../components/wizard'; @import '../components/crud'; @import '../components/table'; diff --git a/scss/themes/dark.scss b/scss/themes/dark.scss index 40574f28..fc93b793 100644 --- a/scss/themes/dark.scss +++ b/scss/themes/dark.scss @@ -123,33 +123,33 @@ $Wizard-steps-li-onActive-bg: $Panel-bg; button[disabled], html input[disabled] { - cursor: not-allowed; + cursor: not-allowed; } input { - color: $text-color; - background: $Form-input-bg; + color: $text-color; + background: $Form-input-bg; } pre { - color: $text-color; - background: $background; + color: $text-color; + background: $background; } .rdtPicker { - background: $background; + background: $background; } .rdtPicker th { - border-bottom: none; + border-bottom: none; } .fr-toolbar { - background: $background; + background: $background; } .markdown-body { - color: $text-color; + color: $text-color; } @import '../functions'; @@ -184,7 +184,8 @@ pre { @import '../components/button-group'; @import '../components/dropdown'; @import '../components/collapse'; -@import "../components/color"; +@import '../components/color'; +@import '../components/context-menu'; @import '../components/wizard'; @import '../components/crud'; @import '../components/table'; @@ -216,7 +217,7 @@ pre { @import '../components/form/date'; @import '../components/form/date-range'; @import '../components/form/image'; -@import "../components/form/file"; +@import '../components/form/file'; @import '../components/form/editor'; @import '../components/form/rich-text'; @import '../components/form/range'; @@ -235,4 +236,4 @@ pre { @import '../components/form/nested-select'; @import '../components/form/icon-picker'; -@import '../utilities'; \ No newline at end of file +@import '../utilities'; diff --git a/scss/themes/default.scss b/scss/themes/default.scss index 9a4c88f2..537aa7c2 100644 --- a/scss/themes/default.scss +++ b/scss/themes/default.scss @@ -1,7 +1,7 @@ // 默认主题 // 更新时间: 2019-08-14 14:31:00 -$ns: "a-"; +$ns: 'a-'; $primary: #7266ba; $info: #23b7e5; @@ -17,87 +17,88 @@ $link-color: $info; $Form-input-borderColor: #cfdadd; -@import "../functions"; -@import "../variables"; -@import "../mixins"; -@import "../base/reset"; -@import "../base/normalize"; -@import "../base/typography"; +@import '../functions'; +@import '../variables'; +@import '../mixins'; +@import '../base/reset'; +@import '../base/normalize'; +@import '../base/typography'; -@import "../layout/layout"; -@import "../layout/grid"; -@import "../layout/aside"; -@import "../layout/hbox"; -@import "../layout/vbox"; -@import "../components/modal"; -@import "../components/drawer"; -@import "../components/tooltip"; -@import "../components/popover"; -@import "../components/toast"; -@import "../components/alert"; -@import "../components/tabs"; -@import "../components/nav"; -@import "../components/page"; -@import "../components/remark"; -@import "../components/chart"; -@import "../components/video"; -@import "../components/audio"; -@import "../components/panel"; -@import "../components/service"; -@import "../components/spinner"; -@import "../components/button"; -@import "../components/button-group"; -@import "../components/dropdown"; -@import "../components/collapse"; -@import "../components/color"; -@import "../components/wizard"; -@import "../components/crud"; -@import "../components/table"; -@import "../components/list"; -@import "../components/cards"; -@import "../components/card"; -@import "../components/quick-edit"; -@import "../components/popoverable"; -@import "../components/copyable"; -@import "../components/divider"; -@import "../components/pagination"; -@import "../components/wrapper"; -@import "../components/status"; -@import "../components/carousel"; +@import '../layout/layout'; +@import '../layout/grid'; +@import '../layout/aside'; +@import '../layout/hbox'; +@import '../layout/vbox'; +@import '../components/modal'; +@import '../components/drawer'; +@import '../components/tooltip'; +@import '../components/popover'; +@import '../components/toast'; +@import '../components/alert'; +@import '../components/tabs'; +@import '../components/nav'; +@import '../components/page'; +@import '../components/remark'; +@import '../components/chart'; +@import '../components/video'; +@import '../components/audio'; +@import '../components/panel'; +@import '../components/service'; +@import '../components/spinner'; +@import '../components/button'; +@import '../components/button-group'; +@import '../components/dropdown'; +@import '../components/collapse'; +@import '../components/color'; +@import '../components/context-menu'; +@import '../components/wizard'; +@import '../components/crud'; +@import '../components/table'; +@import '../components/list'; +@import '../components/cards'; +@import '../components/card'; +@import '../components/quick-edit'; +@import '../components/popoverable'; +@import '../components/copyable'; +@import '../components/divider'; +@import '../components/pagination'; +@import '../components/wrapper'; +@import '../components/status'; +@import '../components/carousel'; -@import "../components/form/fieldset"; -@import "../components/form/group"; -@import "../components/form/input-group"; -@import "../components/form/text"; -@import "../components/form/textarea"; -@import "../components/form/checks"; -@import "../components/form/city"; -@import "../components/form/switch"; -@import "../components/form/number"; -@import "../components/form/select"; -@import "../components/form/list"; -@import "../components/form/matrix"; -@import "../components/form/color"; -@import "../components/form/date"; -@import "../components/form/date-range"; -@import "../components/form/image"; -@import "../components/form/file"; -@import "../components/form/editor"; -@import "../components/form/rich-text"; -@import "../components/form/range"; -@import "../components/form/repeat"; -@import "../components/form/tree"; -@import "../components/form/tree-select"; -@import "../components/form/combo"; -@import "../components/form/sub-form"; -@import "../components/form/chained-select"; -@import "../components/form/picker"; -@import "../components/form/qr-code"; -@import "../components/form/tag"; -@import "../components/form/rating"; -@import "../components/form/form"; -@import "../components/form/transfer-select"; -@import "../components/form/nested-select"; -@import "../components/form/icon-picker"; +@import '../components/form/fieldset'; +@import '../components/form/group'; +@import '../components/form/input-group'; +@import '../components/form/text'; +@import '../components/form/textarea'; +@import '../components/form/checks'; +@import '../components/form/city'; +@import '../components/form/switch'; +@import '../components/form/number'; +@import '../components/form/select'; +@import '../components/form/list'; +@import '../components/form/matrix'; +@import '../components/form/color'; +@import '../components/form/date'; +@import '../components/form/date-range'; +@import '../components/form/image'; +@import '../components/form/file'; +@import '../components/form/editor'; +@import '../components/form/rich-text'; +@import '../components/form/range'; +@import '../components/form/repeat'; +@import '../components/form/tree'; +@import '../components/form/tree-select'; +@import '../components/form/combo'; +@import '../components/form/sub-form'; +@import '../components/form/chained-select'; +@import '../components/form/picker'; +@import '../components/form/qr-code'; +@import '../components/form/tag'; +@import '../components/form/rating'; +@import '../components/form/form'; +@import '../components/form/transfer-select'; +@import '../components/form/nested-select'; +@import '../components/form/icon-picker'; -@import "../utilities"; \ No newline at end of file +@import '../utilities'; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx new file mode 100644 index 00000000..d810f695 --- /dev/null +++ b/src/components/ContextMenu.tsx @@ -0,0 +1,199 @@ +import {ClassNamesFn, themeable} from '../theme'; +import React from 'react'; +import {render} from 'react-dom'; +import {autobind} from '../utils/helper'; +import Transition, { + ENTERED, + ENTERING, + EXITING +} from 'react-transition-group/Transition'; +import {Portal} from 'react-overlays'; +const fadeStyles: { + [propName: string]: string; +} = { + [ENTERING]: 'in', + [ENTERED]: 'in', + [EXITING]: 'out' +}; + +interface ContextMenuProps { + className?: string; + classPrefix: string; + classnames: ClassNamesFn; + container?: HTMLElement | null | (() => HTMLElement); +} + +export type MenuItem = { + label: string; + icon?: string; + disabled?: boolean; + children?: Array; + data?: any; + onSelect?: (data: any) => void; +}; + +export type MenuDivider = '|'; + +interface ContextMenuState { + isOpened: boolean; + menus: Array; + x: number; + y: number; +} + +export class ContextMenu extends React.Component< + ContextMenuProps, + ContextMenuState +> { + static instance: any = null; + static getInstance() { + if (!ContextMenu.instance) { + const container = document.body; + const div = document.createElement('div'); + container.appendChild(div); + render(, div); + } + + return ContextMenu.instance; + } + + state = { + isOpened: false, + menus: [], + x: -99999, + y: -99999 + }; + + menuRef: React.RefObject = React.createRef(); + componentWillMount() { + ContextMenu.instance = this; + } + + componentDidMount() { + document.body.addEventListener('click', this.handleOutClick, true); + } + + componentWillUnmount() { + ContextMenu.instance = null; + document.body.removeEventListener('click', this.handleOutClick, true); + } + + @autobind + openContextMenus(info: {x: number; y: number}, menus: Array) { + this.setState({ + isOpened: true, + x: info.x, + y: info.y, + menus: menus + }); + } + + @autobind + close() { + this.setState({ + isOpened: false, + x: -99999, + y: -99999, + menus: [] + }); + } + + @autobind + handleOutClick(e: Event) { + if ( + !e.target || + !this.menuRef.current || + this.menuRef.current.contains(e.target as HTMLElement) + ) { + return; + } + this.close(); + } + + handleClick(item: MenuItem) { + item.disabled || + (Array.isArray(item.children) && item.children.length) || + this.setState( + { + isOpened: false, + x: -99999, + y: -99999, + menus: [] + }, + () => (item.onSelect ? item.onSelect(item.data) : null) + ); + } + + renderMenus(menus: Array) { + const {classnames: cx} = this.props; + + return menus.map((item, index) => { + if (item === '|') { + return
  • ; + } + + const hasChildren = Array.isArray(item.children) && item.children.length; + return ( +
  • + + {item.icon ? : null} + {item.label} + + {hasChildren ? ( +
      + {this.renderMenus(item.children!)} +
    + ) : null} +
  • + ); + }); + } + + render() { + const {className, container, classnames: cx} = this.props; + + return ( + + + {(status: string) => ( +
    +
    +
      + {this.renderMenus(this.state.menus)} +
    +
    +
    + )} +
    +
    + ); + } +} + +export const ThemedContextMenu = themeable(ContextMenu); +export default ThemedContextMenu; + +export function openContextMenus( + info: Event | {x: number; y: number}, + menus: Array +) { + return ContextMenu.getInstance().openContextMenus(info, menus); +} diff --git a/src/components/index.tsx b/src/components/index.tsx index fee57627..2de2acd0 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -6,6 +6,7 @@ import NotFound from './404'; import {default as Alert, alert, confirm} from './Alert'; +import {default as ContextMenu, openContextMenus} from './ContextMenu'; import AsideNav from './AsideNav'; import Button from './Button'; import Checkbox from './Checkbox'; @@ -43,6 +44,8 @@ export { NotFound, Alert as AlertComponent, alert, + ContextMenu, + openContextMenus, Alert2, confirm, AsideNav, diff --git a/src/index.tsx b/src/index.tsx index 6c188e61..ca3e1a52 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,6 +25,8 @@ import { NotFound, AlertComponent, alert, + ContextMenu, + openContextMenus, Alert2, confirm, AsideNav, @@ -197,6 +199,8 @@ export { NotFound, AlertComponent, alert, + ContextMenu, + openContextMenus, Alert2, confirm, AsideNav, diff --git a/src/renderers/Form/List.tsx b/src/renderers/Form/List.tsx index 681b8c2a..7cd81072 100644 --- a/src/renderers/Form/List.tsx +++ b/src/renderers/Form/List.tsx @@ -19,16 +19,10 @@ export default class ListControl extends React.Component { }; handleDBClick(option: Option, e: React.MouseEvent) { - this.props.onToggle(option); - - // 差不多有 250ms 的防抖。 - setTimeout( - () => - this.props.onAction(null, { - type: 'submit' - }), - 250 - ); + this.props.onToggle(option, false, true); + this.props.onAction(null, { + type: 'submit' + }); } handleClick(option: Option, e: React.MouseEvent) { diff --git a/src/renderers/Form/Options.tsx b/src/renderers/Form/Options.tsx index 09576a17..21069834 100644 --- a/src/renderers/Form/Options.tsx +++ b/src/renderers/Form/Options.tsx @@ -43,7 +43,11 @@ export interface OptionsConfig extends OptionsBasicConfig { export interface OptionsControlProps extends FormControlProps, OptionProps { source?: Api; name?: string; - onToggle: (option: Option, submitOnChange?: boolean) => void; + onToggle: ( + option: Option, + submitOnChange?: boolean, + changeImmediately?: boolean + ) => void; onToggleAll: () => void; selectedOptions: Array