diff --git a/src/state/create-store.js b/src/state/create-store.js index 653a97b46a..2c3c27ded2 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -5,13 +5,14 @@ import reducer from './reducer'; import lift from './middleware/lift'; import style from './middleware/style'; import drop from './middleware/drop'; +import hooks from './middleware/hooks'; import dropAnimationFinish from './middleware/drop-animation-finish'; import dimensionMarshalStopper from './middleware/dimension-marshal-stopper'; import autoScroll from './middleware/auto-scroll'; import pendingDrop from './middleware/pending-drop'; import type { DimensionMarshal } from './dimension-marshal/dimension-marshal-types'; import type { StyleMarshal } from '../view/style-marshal/style-marshal-types'; -import type { Store, Hooks } from '../types'; +import type { Store, Hooks, Announce } from '../types'; // We are checking if window is available before using it. // This is needed for universal apps that render the component server side. @@ -24,13 +25,14 @@ type Args = {| getDimensionMarshal: () => DimensionMarshal, styleMarshal: StyleMarshal, getHooks: () => Hooks, - + announce: Announce, |} export default ({ getDimensionMarshal, styleMarshal, getHooks, + announce, }: Args): Store => createStore( reducer, composeEnhancers( @@ -67,7 +69,7 @@ export default ({ autoScroll, // TODO: where should this go? - // hooks(getHooks), + hooks(getHooks, announce), ), ), diff --git a/src/state/hooks/hook-caller.js b/src/state/hooks/hook-caller.js deleted file mode 100644 index 20865873ad..0000000000 --- a/src/state/hooks/hook-caller.js +++ /dev/null @@ -1,317 +0,0 @@ -// @flow -import messagePreset from './message-preset'; -import * as timings from '../../debug/timings'; -import type { HookCaller } from './hooks-types'; -import type { - Announce, - Hooks, - HookProvided, - State as AppState, - DragState, - DragStart, - DragUpdate, - DropResult, - DraggableLocation, - DraggableDescriptor, - DroppableDimension, - OnDragStartHook, - OnDragUpdateHook, - OnDragEndHook, -} from '../../types'; - -type State = { - isDragging: boolean, - start: ?DraggableLocation, - lastDestination: ?DraggableLocation, - hasMovedFromStartLocation: boolean, -} - -type AnyHookFn = OnDragStartHook | OnDragUpdateHook | OnDragEndHook; -type AnyHookData = DragStart | DragUpdate | DropResult; - -const withTimings = (key: string, fn: Function) => { - timings.start(key); - fn(); - timings.finish(key); -}; - -const notDragging: State = { - isDragging: false, - start: null, - lastDestination: null, - hasMovedFromStartLocation: false, -}; - -const areLocationsEqual = (current: ?DraggableLocation, next: ?DraggableLocation) => { - // if both are null - we are equal - if (current == null && next == null) { - return true; - } - - // if one is null - then they are not equal - if (current == null || next == null) { - return false; - } - - // compare their actual values - return current.droppableId === next.droppableId && - current.index === next.index; -}; - -const getAnnouncerForConsumer = (announce: Announce) => { - let wasCalled: boolean = false; - let isExpired: boolean = false; - - // not allowing async announcements - setTimeout(() => { - isExpired = true; - }); - - const result = (message: string): void => { - if (wasCalled) { - console.warn('Announcement already made. Not making a second announcement'); - return; - } - - if (isExpired) { - console.warn(` - Announcements cannot be made asynchronously. - Default message has already been announced. - `); - return; - } - - wasCalled = true; - announce(message); - }; - - // getter for isExpired - // using this technique so that a consumer cannot - // set the isExpired or wasCalled flags - result.wasCalled = (): boolean => wasCalled; - - return result; -}; - -export default (announce: Announce): HookCaller => { - let state: State = notDragging; - - const setState = (partial: Object): void => { - const newState: State = { - ...state, - ...partial, - }; - state = newState; - }; - - const getDragStart = (appState: AppState): ?DragStart => { - if (!appState.drag) { - return null; - } - - const descriptor: DraggableDescriptor = appState.drag.initial.descriptor; - const home: ?DroppableDimension = appState.dimension.droppable[descriptor.droppableId]; - - if (!home) { - return null; - } - - const source: DraggableLocation = { - index: descriptor.index, - droppableId: descriptor.droppableId, - }; - - const start: DragStart = { - draggableId: descriptor.id, - type: home.descriptor.type, - source, - }; - - return start; - }; - - const execute = ( - hook: ?AnyHookFn, - data: AnyHookData, - getDefaultMessage: (data: any) => string, - ) => { - // if no hook: announce the default message - if (!hook) { - announce(getDefaultMessage(data)); - return; - } - - const managed: Announce = getAnnouncerForConsumer(announce); - const provided: HookProvided = { - announce: managed, - }; - - hook((data: any), provided); - - if (!managed.wasCalled()) { - announce(getDefaultMessage(data)); - } - }; - - const onDrag = (current: AppState, onDragUpdate: ?OnDragUpdateHook) => { - if (!state.isDragging) { - console.error('Cannot process dragging update if drag has not started'); - return; - } - - const drag: ?DragState = current.drag; - const start: ?DragStart = getDragStart(current); - if (!start || !drag) { - console.error('Cannot update drag when there is invalid state'); - return; - } - - const destination: ?DraggableLocation = drag.impact.destination; - const update: DragUpdate = { - draggableId: start.draggableId, - type: start.type, - source: start.source, - destination, - }; - - if (!state.hasMovedFromStartLocation) { - // has not moved past the home yet - if (areLocationsEqual(start.source, destination)) { - return; - } - - // We have now moved past the home location - setState({ - lastDestination: destination, - hasMovedFromStartLocation: true, - }); - - execute(onDragUpdate, update, messagePreset.onDragUpdate); - - // announceMessage(update, onDragUpdate); - return; - } - - // has not moved from the previous location - if (areLocationsEqual(state.lastDestination, destination)) { - return; - } - - setState({ - lastDestination: destination, - }); - - execute(onDragUpdate, update, messagePreset.onDragUpdate); - }; - - const onStateChange = (hooks: Hooks, previous: AppState, current: AppState): void => { - const { onDragStart, onDragUpdate, onDragEnd } = hooks; - const currentPhase = current.phase; - const previousPhase = previous.phase; - - // Dragging in progress - if (currentPhase === 'DRAGGING' && previousPhase === 'DRAGGING') { - onDrag(current, onDragUpdate); - } - - // We are not in the dragging phase so we can clear this state - if (state.isDragging) { - setState(notDragging); - } - - // From this point we only care about phase changes - - if (currentPhase === previousPhase) { - return; - } - - // Drag start - if (currentPhase === 'DRAGGING' && previousPhase !== 'DRAGGING') { - const start: ?DragStart = getDragStart(current); - - if (!start) { - console.error('Unable to publish onDragStart'); - return; - } - - setState({ - isDragging: true, - hasMovedFromStartLocation: false, - start, - }); - - // onDragStart is optional - withTimings('hook:onDragStart', () => execute(onDragStart, start, messagePreset.onDragStart)); - return; - } - - // Drag end - if (currentPhase === 'DROP_COMPLETE' && previousPhase !== 'DROP_COMPLETE') { - if (!current.drop || !current.drop.result) { - console.error('cannot fire onDragEnd hook without drag state', { current, previous }); - return; - } - const result: DropResult = current.drop.result; - - withTimings('hook:onDragEnd', () => execute(onDragEnd, result, messagePreset.onDragEnd)); - return; - } - - // Drag ended while dragging - if (currentPhase === 'IDLE' && previousPhase === 'DRAGGING') { - if (!previous.drag) { - console.error('cannot fire onDragEnd for cancel because cannot find previous drag'); - return; - } - - const descriptor: DraggableDescriptor = previous.drag.initial.descriptor; - const home: ?DroppableDimension = previous.dimension.droppable[descriptor.droppableId]; - - if (!home) { - console.error('cannot find dimension for home droppable'); - return; - } - - const source: DraggableLocation = { - index: descriptor.index, - droppableId: descriptor.droppableId, - }; - const result: DropResult = { - draggableId: descriptor.id, - type: home.descriptor.type, - source, - destination: null, - reason: 'CANCEL', - }; - - withTimings('hook:onDragEnd (cancel)', () => execute(onDragEnd, result, messagePreset.onDragEnd)); - return; - } - - // Drag ended during a drop animation. Not super sure how this can even happen. - // This is being really safe - if (currentPhase === 'IDLE' && previousPhase === 'DROP_ANIMATING') { - if (!previous.drop || !previous.drop.pending) { - console.error('cannot fire onDragEnd for cancel because cannot find previous pending drop'); - return; - } - - const result: DropResult = { - draggableId: previous.drop.pending.result.draggableId, - type: previous.drop.pending.result.type, - source: previous.drop.pending.result.source, - destination: null, - reason: 'CANCEL', - }; - - execute(onDragEnd, result, messagePreset.onDragEnd); - } - }; - - const caller: HookCaller = { - onStateChange, - }; - - return caller; -}; - diff --git a/src/state/hooks/hooks-types.js b/src/state/hooks/hooks-types.js deleted file mode 100644 index 6854aeacdc..0000000000 --- a/src/state/hooks/hooks-types.js +++ /dev/null @@ -1,9 +0,0 @@ -// @flow -import type { - State, - Hooks, -} from '../../types'; - -export type HookCaller = {| - onStateChange: (hooks: Hooks, previous: State, current: State) => void, -|} diff --git a/src/state/middleware/hooks.js b/src/state/middleware/hooks.js index dd0ac45a8b..423e74e9e6 100644 --- a/src/state/middleware/hooks.js +++ b/src/state/middleware/hooks.js @@ -1,53 +1,144 @@ // @flow import invariant from 'tiny-invariant'; import messagePreset from './util/message-preset'; +import * as timings from '../../debug/timings'; import type { Store, State, DropResult, Action, Hooks, + HookProvided, Critical, DraggableLocation, DragStart, Announce, + DragUpdate, + OnDragStartHook, + OnDragUpdateHook, + OnDragEndHook, } from '../../types'; -const execute = ( - hook: ?AnyHookFn, - data: AnyHookData, - defaultMessage: string, -) => { +type AnyHookFn = OnDragStartHook | OnDragUpdateHook | OnDragEndHook; +type AnyHookData = DragStart | DragUpdate | DropResult; +const withTimings = (key: string, fn: Function) => { + timings.start(key); + fn(); + timings.finish(key); +}; + +const areLocationsEqual = (current: ?DraggableLocation, next: ?DraggableLocation) => { + // if both are null - we are equal + if (current == null && next == null) { + return true; + } + + // if one is null - then they are not equal + if (current == null || next == null) { + return false; + } + + // compare their actual values + return current.droppableId === next.droppableId && + current.index === next.index; +}; + +const getExpiringAnnounce = (announce: Announce) => { + let wasCalled: boolean = false; + let isExpired: boolean = false; + + // not allowing async announcements + setTimeout(() => { + isExpired = true; + }); + + const result = (message: string): void => { + if (wasCalled) { + console.warn('Announcement already made. Not making a second announcement'); + return; + } + + if (isExpired) { + console.warn(` + Announcements cannot be made asynchronously. + Default message has already been announced. + `); + return; + } + + wasCalled = true; + announce(message); + }; + + // getter for isExpired + // using this technique so that a consumer cannot + // set the isExpired or wasCalled flags + result.wasCalled = (): boolean => wasCalled; + + return result; }; export default (getHooks: () => Hooks, announce: Announce) => { + const execute = ( + hook: ?AnyHookFn, + data: AnyHookData, + getDefaultMessage: (data: any) => string, + ) => { + if (!hook) { + announce(getDefaultMessage(data)); + return; + } + + const willExpire: Announce = getExpiringAnnounce(announce); + const provided: HookProvided = { + announce: willExpire, + }; + + // Casting because we are not validating which data type is going into which hook + hook((data: any), provided); + + if (!willExpire.wasCalled()) { + announce(getDefaultMessage(data)); + } + }; + const publisher = (() => { - let isDragStartPublished: boolean = false; let publishedStart: ?DragStart = null; - let lastDestination: ?DraggableLocation = null; + let lastLocation: ?DraggableLocation = null; + const isDragStartPublished = (): boolean => Boolean(publishedStart); const start = (initial: DragStart) => { - invariant(!isDragStartPublished, 'Cannot fire onDragStart as a drag start has already been published'); - isDragStartPublished = true; - lastDestination = initial.source; + invariant(!isDragStartPublished(), 'Cannot fire onDragStart as a drag start has already been published'); publishedStart = initial; - execute(getHooks().onDragStart, initial, messagePreset.onDragStart(initial)); + lastLocation = initial.source; + withTimings('onDragStart', () => execute(getHooks().onDragStart, initial, messagePreset.onDragStart)); }; - const move = () => { + const move = (location: ?DraggableLocation) => { + invariant(publishedStart, 'Cannot fire onDragMove when onDragStart has not been called'); + // No change to publish + if (areLocationsEqual(lastLocation, location)) { + return; + } + const update: DragUpdate = { + ...publishedStart, + destination: location, + }; + + withTimings('onDragMove', () => execute(getHooks().onDragEnd, update, messagePreset.onDragUpdate)); }; const end = (result: DropResult) => { invariant(isDragStartPublished, 'Cannot fire onDragEnd when there is no matching onDragStart'); - isDragStartPublished = false; - lastDestination = null; - execute(getHooks().onDragEnd, result, messagePreset.onDragEnd(result)); + publishedStart = null; + lastLocation = null; + withTimings('onDragEnd', () => execute(getHooks().onDragEnd, result, messagePreset.onDragEnd)); }; const cancel = () => { - invariant(isDragStartPublished && publishedStart, 'Cannot cancel when onDragStart not fired'); + invariant(publishedStart, 'Cannot cancel when onDragStart not fired'); const result: DropResult = { draggableId: publishedStart.draggableId, @@ -65,7 +156,7 @@ export default (getHooks: () => Hooks, announce: Announce) => { move, end, cancel, - isDragStartPublished: () => isDragStartPublished, + isDragStartPublished, }; })(); @@ -117,10 +208,9 @@ export default (getHooks: () => Hooks, announce: Announce) => { // Calling next() first so that we reduce the impact of the action next(action); - if() - const state: State = store.getState(); - invariant(state.phase === 'IDLE' || state.phase === - `drag start should be published in phase ${state.phase}`); + if (state.phase === 'DRAGGING') { + publisher.move(state.impact.destination); + } }; }; diff --git a/src/view/drag-drop-context/drag-drop-context.jsx b/src/view/drag-drop-context/drag-drop-context.jsx index c948e4d19a..0ac9361e50 100644 --- a/src/view/drag-drop-context/drag-drop-context.jsx +++ b/src/view/drag-drop-context/drag-drop-context.jsx @@ -4,7 +4,6 @@ import { type Position } from 'css-box-model'; import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; import createStore from '../../state/create-store'; -import createHookCaller from '../../state/hooks/hook-caller'; import createDimensionMarshal from '../../state/dimension-marshal/dimension-marshal'; import createStyleMarshal, { resetStyleContext } from '../style-marshal/style-marshal'; import canStartDrag from '../../state/can-start-drag'; @@ -22,15 +21,9 @@ import type { DraggableId, Store, State, - DraggableDimension, - DroppableDimension, - DroppableId, Hooks, Viewport, } from '../../types'; -import type { - HookCaller, -} from '../../state/hooks/hooks-types'; import { storeKey, dimensionMarshalKey, @@ -65,7 +58,6 @@ export default class DragDropContext extends React.Component { dimensionMarshal: DimensionMarshal styleMarshal: StyleMarshal autoScroller: AutoScroller - hookCaller: HookCaller announcer: Announcer unsubscribe: Function @@ -74,21 +66,19 @@ export default class DragDropContext extends React.Component { this.announcer = createAnnouncer(); - // create the hook caller - this.hookCaller = createHookCaller(this.announcer.announce); - // create the style marshal this.styleMarshal = createStyleMarshal(); this.store = createStore({ // Lazy reference to dimension marshal get around circular dependency - getDimensionMarshal: (): ?DimensionMarshal => this.dimensionMarshal, + getDimensionMarshal: (): DimensionMarshal => this.dimensionMarshal, styleMarshal: this.styleMarshal, getHooks: (): Hooks => ({ onDragStart: this.props.onDragStart, onDragEnd: this.props.onDragEnd, onDragUpdate: this.props.onDragUpdate, }), + announce: this.announcer.announce, }); const callbacks: DimensionMarshalCallbacks = bindActionCreators({ bulkReplace, @@ -109,61 +99,6 @@ export default class DragDropContext extends React.Component { this.store.dispatch(move(id, client, viewport, shouldAnimate)); }, }); - - let previous: State = this.store.getState(); - - this.unsubscribe = this.store.subscribe(() => { - const current = this.store.getState(); - const previousInThisExecution: State = previous; - const isPhaseChanging: boolean = current.phase !== previous.phase; - // setting previous now rather than at the end of this function - // incase a function is called that syncorously causes a state update - // which will re-invoke this function before it has completed a previous - // invocation. - previous = current; - - // Style updates do not cause more actions. It is important to update styles - // before hooks are called: specifically the onDragEnd hook. We need to clear - // the transition styles off the elements before a reorder to prevent strange - // post drag animations in firefox. Even though we clear the transition off - // a Draggable - if it is done after a reorder firefox will still apply the - // transition. - // if (isPhaseChanging) { - // this.styleMarshal.onPhaseChange(current); - // } - - const isDragEnding: boolean = previousInThisExecution.phase === 'DRAGGING' && current.phase !== 'DRAGGING'; - - // in the case that a drag is ending we need to instruct the dimension marshal - // to stop listening to changes. Otherwise it will try to process - // changes after the reorder in onDragEnd - // if (isDragEnding) { - // this.dimensionMarshal.onPhaseChange(current); - // } - - // We recreate the Hook object so that consumers can pass in new - // hook props at any time (eg if they are using arrow functions) - const hooks: Hooks = { - onDragStart: this.props.onDragStart, - onDragEnd: this.props.onDragEnd, - onDragUpdate: this.props.onDragUpdate, - }; - // this.hookCaller.onStateChange(hooks, previousInThisExecution, current); - - // The following two functions are dangerous. They can both syncronously - // create new actions that update the application state. That will cause - // this subscription function to be called again before the next line is called. - - // if isDragEnding we have already called the marshal - // if (isPhaseChanging && !isDragEnding) { - // this.dimensionMarshal.onPhaseChange(current); - // } - - // We could block this action from being called if this function has been reinvoked - // before completing and dragging and autoScrollMode === 'FLUID'. - // However, it is not needed at this time - // this.autoScroller.onStateChange(previousInThisExecution, current); - }); } // Need to declare childContextTypes without flow // https://github.com/brigand/babel-plugin-flow-react-proptypes/issues/22