diff --git a/src/core/ReactComponentWithPureRenderMixin.js b/src/core/ReactComponentWithPureRenderMixin.js index 1ac4c23aad..c3a198721f 100644 --- a/src/core/ReactComponentWithPureRenderMixin.js +++ b/src/core/ReactComponentWithPureRenderMixin.js @@ -18,6 +18,8 @@ "use strict"; +var shallowEqual = require('shallowEqual'); + /** * If your React component's render function is "pure", e.g. it will render the * same result given the same props and state, provide this Mixin for a @@ -49,30 +51,4 @@ var ReactComponentWithPureRenderMixin = { } }; -/** - * Performs equality by iterating through keys on an object and returning - * false when any key has values which are not strictly equal between - * objA and objB. Returns true when the values of all keys are strictly equal. - */ -function shallowEqual(objA, objB) { - if (objA === objB) { - return true; - } - var key; - // Test for A's keys different from B. - for (key in objA) { - if (objA.hasOwnProperty(key) && - (!objB.hasOwnProperty(key) || objA[key] !== objB[key])) { - return false; - } - } - // Test for B'a keys missing from A. - for (key in objB) { - if (objB.hasOwnProperty(key) && !objA.hasOwnProperty(key)) { - return false; - } - } - return true; -} - module.exports = ReactComponentWithPureRenderMixin; diff --git a/src/core/ReactDOMSelection.js b/src/core/ReactDOMSelection.js index 99dd0d51bf..3f7b0c8359 100644 --- a/src/core/ReactDOMSelection.js +++ b/src/core/ReactDOMSelection.js @@ -33,8 +33,9 @@ var getTextContentAccessor = require('getTextContentAccessor'); * differ between the two APIs. * * @param {DOMElement} node + * @return {object} */ -function getIESelection(node) { +function getIEOffsets(node) { var selection = document.selection; var selectedRange = selection.createRange(); var selectedLength = selectedRange.text.length; @@ -55,8 +56,9 @@ function getIESelection(node) { /** * @param {DOMElement} node + * @return {object} */ -function getModernSelection(node) { +function getModernOffsets(node) { var selection = window.getSelection(); var anchorNode = selection.anchorNode; var anchorOffset = selection.anchorOffset; @@ -90,7 +92,7 @@ function getModernSelection(node) { * @param {DOMElement|DOMTextNode} node * @param {object} offsets */ -function setIESelection(node, offsets) { +function setIEOffsets(node, offsets) { var range = document.selection.createRange().duplicate(); var start, end; @@ -124,7 +126,7 @@ function setIESelection(node, offsets) { * @param {DOMElement|DOMTextNode} node * @param {object} offsets */ -function setModernSelection(node, offsets) { +function setModernOffsets(node, offsets) { var selection = window.getSelection(); var length = node[getTextContentAccessor()].length; @@ -149,18 +151,18 @@ var ReactDOMSelection = { /** * @param {DOMElement} node */ - get: function(node) { - var getSelection = document.selection ? getIESelection : getModernSelection; - return getSelection(node); + getOffsets: function(node) { + var getOffsets = document.selection ? getIEOffsets : getModernOffsets; + return getOffsets(node); }, /** * @param {DOMElement|DOMTextNode} node * @param {object} offsets */ - set: function(node, offsets) { - var setSelection = document.selection ? setIESelection : setModernSelection; - setSelection(node, offsets); + setOffsets: function(node, offsets) { + var setOffsets = document.selection ? setIEOffsets : setModernOffsets; + setOffsets(node, offsets); } }; diff --git a/src/core/ReactDefaultInjection.js b/src/core/ReactDefaultInjection.js index 4710ebc1f4..be66489e37 100644 --- a/src/core/ReactDefaultInjection.js +++ b/src/core/ReactDefaultInjection.js @@ -32,14 +32,15 @@ var ReactPerf = require('ReactPerf'); var DefaultDOMPropertyConfig = require('DefaultDOMPropertyConfig'); var DOMProperty = require('DOMProperty'); -var DefaultEventPluginOrder = require('DefaultEventPluginOrder'); -var EnterLeaveEventPlugin = require('EnterLeaveEventPlugin'); var ChangeEventPlugin = require('ChangeEventPlugin'); var CompositionEventPlugin = require('CompositionEventPlugin'); +var DefaultEventPluginOrder = require('DefaultEventPluginOrder'); +var EnterLeaveEventPlugin = require('EnterLeaveEventPlugin'); var EventPluginHub = require('EventPluginHub'); -var ReactInstanceHandles = require('ReactInstanceHandles'); -var SimpleEventPlugin = require('SimpleEventPlugin'); var MobileSafariClickEventPlugin = require('MobileSafariClickEventPlugin'); +var ReactInstanceHandles = require('ReactInstanceHandles'); +var SelectEventPlugin = require('SelectEventPlugin'); +var SimpleEventPlugin = require('SimpleEventPlugin'); var ReactDefaultBatchingStrategy = require('ReactDefaultBatchingStrategy'); var ReactUpdates = require('ReactUpdates'); @@ -61,7 +62,8 @@ function inject() { 'EnterLeaveEventPlugin': EnterLeaveEventPlugin, 'ChangeEventPlugin': ChangeEventPlugin, 'CompositionEventPlugin': CompositionEventPlugin, - 'MobileSafariClickEventPlugin': MobileSafariClickEventPlugin + 'MobileSafariClickEventPlugin': MobileSafariClickEventPlugin, + 'SelectEventPlugin': SelectEventPlugin }); ReactDOM.injection.injectComponentClasses({ diff --git a/src/core/ReactInputSelection.js b/src/core/ReactInputSelection.js index 63eacba302..f680717277 100644 --- a/src/core/ReactInputSelection.js +++ b/src/core/ReactInputSelection.js @@ -111,7 +111,7 @@ var ReactInputSelection = { } } else { // Content editable or old IE textarea. - selection = ReactDOMSelection.get(input); + selection = ReactDOMSelection.getOffsets(input); } return selection || {start: 0, end: 0}; @@ -140,7 +140,7 @@ var ReactInputSelection = { range.moveEnd('character', end - start); range.select(); } else { - ReactDOMSelection.set(input, offsets); + ReactDOMSelection.setOffsets(input, offsets); } } }; diff --git a/src/eventPlugins/ChangeEventPlugin.js b/src/eventPlugins/ChangeEventPlugin.js index 835651fecd..99d5a3f018 100644 --- a/src/eventPlugins/ChangeEventPlugin.js +++ b/src/eventPlugins/ChangeEventPlugin.js @@ -46,7 +46,6 @@ var activeElementID = null; var activeElementValue = null; var activeElementValueProp = null; - /** * SECTION: handle `change` event */ diff --git a/src/eventPlugins/DefaultEventPluginOrder.js b/src/eventPlugins/DefaultEventPluginOrder.js index 9e41cd9612..5506de1bc2 100644 --- a/src/eventPlugins/DefaultEventPluginOrder.js +++ b/src/eventPlugins/DefaultEventPluginOrder.js @@ -35,6 +35,7 @@ var DefaultEventPluginOrder = [ keyOf({TapEventPlugin: null}), keyOf({EnterLeaveEventPlugin: null}), keyOf({ChangeEventPlugin: null}), + keyOf({SelectEventPlugin: null}), keyOf({CompositionEventPlugin: null}), keyOf({AnalyticsEventPlugin: null}), keyOf({MobileSafariClickEventPlugin: null}) diff --git a/src/eventPlugins/SelectEventPlugin.js b/src/eventPlugins/SelectEventPlugin.js new file mode 100644 index 0000000000..2914292208 --- /dev/null +++ b/src/eventPlugins/SelectEventPlugin.js @@ -0,0 +1,212 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * 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. + * + * @providesModule SelectEventPlugin + */ + +"use strict"; + +var EventConstants = require('EventConstants'); +var EventPluginHub = require('EventPluginHub'); +var EventPropagators = require('EventPropagators'); +var ExecutionEnvironment = require('ExecutionEnvironment'); +var SyntheticEvent = require('SyntheticEvent'); + +var getActiveElement = require('getActiveElement'); +var isEventSupported = require('isEventSupported'); +var keyOf = require('keyOf'); +var shallowEqual = require('shallowEqual'); + +var topLevelTypes = EventConstants.topLevelTypes; + +var eventTypes = { + select: { + phasedRegistrationNames: { + bubbled: keyOf({onSelect: null}), + captured: keyOf({onSelectCapture: null}) + } + } +}; + +var useSelectionChange = false; +var useSelect = false; + +if (ExecutionEnvironment.canUseDOM) { + useSelectionChange = 'onselectionchange' in document; + useSelect = isEventSupported('select'); +} + +var activeElement = null; +var activeElementID = null; +var activeNativeEvent = null; +var lastSelection = null; +var mouseDown = false; + +/** + * Get an object which is a unique representation of the current selection. + * + * The return value will not be consistent across nodes or browsers, but + * two identical selections on the same node will return identical objects. + * + * @param {DOMElement} node + * @param {object} + */ +function getSelection(node) { + if ('selectionStart' in node) { + return { + start: node.selectionStart, + end: node.selectionEnd + }; + } else if (document.selection) { + var range = document.selection.createRange(); + return { + parentElement: range.parentElement(), + text: range.text, + top: range.boundingTop, + left: range.boundingLeft + }; + } else { + var selection = window.getSelection(); + return { + anchorNode: selection.anchorNode, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode, + focusOffset: selection.focusOffset + }; + } +} + +/** + * Poll selection to see whether it's changed. + * + * @param {object} nativeEvent + * @return {?SyntheticEvent} + */ +function constructSelectEvent(nativeEvent) { + // Ensure we have the right element, and that the user is not dragging a + // selection (this matches native `select` event behavior). + if (mouseDown || activeElement != getActiveElement()) { + return; + } + + // Only fire when selection has actually changed. + var currentSelection = getSelection(activeElement); + if (!lastSelection || !shallowEqual(lastSelection, currentSelection)) { + lastSelection = currentSelection; + + return SyntheticEvent.getPooled( + eventTypes.select, + activeElementID, + nativeEvent + ); + } +} + +/** + * Handle deferred event. And manually dispatch synthetic events. + */ +function handleDeferredEvent() { + if (!activeNativeEvent) { + return; + } + + var event = constructSelectEvent(activeNativeEvent); + activeNativeEvent = null; + + if (event) { + EventPropagators.accumulateTwoPhaseDispatches(event); + + // Enqueue and process the abstract event manually. + EventPluginHub.enqueueEvents(event); + EventPluginHub.processEventQueue(); + } +} + +/** + * This plugin creates an `onSelect` event that normalizes select events + * across form elements. + * + * Supported elements are: + * - input (see `supportedInputTypes`) + * - TEXTAREA + * - contentEditable + * + * This differs from native browser implementations in the following ways: + * - Fires on contentEditable fields as well as inputs. + * - Fires for collapsed selection. + * - Fires after user input. + */ +var SelectEventPlugin = { + + eventTypes: eventTypes, + + /** + * @param {string} topLevelType Record from `EventConstants`. + * @param {DOMEventTarget} topLevelTarget The listening component root node. + * @param {string} topLevelTargetID ID of `topLevelTarget`. + * @param {object} nativeEvent Native browser event. + * @return {*} An accumulation of synthetic events. + * @see {EventPluginHub.extractEvents} + */ + extractEvents: function( + topLevelType, + topLevelTarget, + topLevelTargetID, + nativeEvent) { + + switch (topLevelType) { + case topLevelTypes.topFocus: + // Track the input node that has focus. + activeElement = topLevelTarget; + activeElementID = topLevelTargetID; + lastSelection = null; + mouseDown = false; + break; + case topLevelTypes.topBlur: + activeElement = null; + activeElementID = null; + lastSelection = null; + mouseDown = false; + break; + case topLevelTypes.topSelectionChange: + // Chrome and IE fire non-standard event whenever selection is + // changed (and sometimes when it hasn't). + var event = constructSelectEvent(nativeEvent); + if (event) { + EventPropagators.accumulateTwoPhaseDispatches(event); + return event; + } + break; + case topLevelTypes.topMouseDown: + // Don't fire the event while the user is dragging. + mouseDown = true; + break; + case topLevelTypes.topMouseUp: + mouseDown = false; + activeNativeEvent = nativeEvent; + handleDeferredEvent.defer(); + break; + case topLevelTypes.topKeyDown: + // For Firefox we check seleciton after each key. + if (!useSelectionChange) { + activeNativeEvent = nativeEvent; + handleDeferredEvent.defer(); + } + break; + } + } +}; + +module.exports = SelectEventPlugin; diff --git a/src/utils/shallowEqual.js b/src/utils/shallowEqual.js new file mode 100644 index 0000000000..fbb60de0a1 --- /dev/null +++ b/src/utils/shallowEqual.js @@ -0,0 +1,49 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * 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. + * + * @providesModule shallowEqual + */ + +"use strict"; + +/** + * Performs equality by iterating through keys on an object and returning + * false when any key has values which are not strictly equal between + * objA and objB. Returns true when the values of all keys are strictly equal. + * + * @return {boolean} + */ +function shallowEqual(objA, objB) { + if (objA === objB) { + return true; + } + var key; + // Test for A's keys different from B. + for (key in objA) { + if (objA.hasOwnProperty(key) && + (!objB.hasOwnProperty(key) || objA[key] !== objB[key])) { + return false; + } + } + // Test for B'a keys missing from A. + for (key in objB) { + if (objB.hasOwnProperty(key) && !objA.hasOwnProperty(key)) { + return false; + } + } + return true; +} + +module.exports = shallowEqual;