Large refactoring of mouse-handling code on View and CanvasView.

Added support for:
- Better event bubbling
- mouseenter / mouseleave events on view
- Better handling of mousedrag / mousemove events on item and view
- Support for #removeOn() call in item / view handlers

Closes #845
This commit is contained in:
Jürg Lehni 2016-01-13 02:11:29 +01:00
parent ab68c5b272
commit db2beba831
7 changed files with 245 additions and 234 deletions

View file

@ -24,15 +24,15 @@ var Event = Base.extend(/** @lends Event# */{
this.event = event;
},
isPrevented: false,
isStopped: false,
prevented: false,
stopped: false,
/**
* Cancels the event if it is cancelable, without stopping further
* propagation of the event.
*/
preventDefault: function() {
this.isPrevented = true;
this.prevented = true;
this.event.preventDefault();
},
@ -40,7 +40,7 @@ var Event = Base.extend(/** @lends Event# */{
* Prevents further propagation of the current event.
*/
stopPropagation: function() {
this.isStopped = true;
this.stopped = true;
this.event.stopPropagation();
},

View file

@ -2659,7 +2659,7 @@ var Item = Base.extend(Emitter, /** @lends Item# */{
isDescendant: function(item) {
var parent = this;
while (parent = parent._parent) {
if (parent == item)
if (parent === item)
return true;
}
return false;

View file

@ -351,7 +351,7 @@ var Segment = Base.extend(/** @lends Segment# */{
},
/**
* The curve location that describes this segment's position ont the path.
* The curve location that describes this segment's position on the path.
*
* @bean
* @type CurveLocation

View file

@ -709,6 +709,30 @@ var Project = PaperScopeItem.extend(/** @lends Project# */{
* SVG content
*/
removeOn: function(type) {
var sets = this._removeSets;
if (sets) {
// Always clear the drag set on mouseup
if (type === 'mouseup')
sets.mousedrag = null;
var set = sets[type];
if (set) {
for (var id in set) {
var item = set[id];
// If we remove this item, we also need to erase it from all
// other sets.
for (var key in sets) {
var other = sets[key];
if (other && other != set)
delete other[item._id];
}
item.remove();
}
sets[type] = null;
}
}
},
draw: function(ctx, matrix, pixelRatio) {
// Increase the _updateVersion before the draw-loop. After that, items
// that are visible will have their _updateVersion set to the new value.

View file

@ -322,33 +322,11 @@ var Tool = PaperScopeItem.extend(/** @lends Tool# */{
},
_fireEvent: function(type, event) {
// Handle items marked in removeOn*() calls first,.
var sets = paper.project._removeSets;
if (sets) {
// Always clear the drag set on mouseup
if (type === 'mouseup')
sets.mousedrag = null;
var set = sets[type];
if (set) {
for (var id in set) {
var item = set[id];
// If we remove this item, we also need to erase it from all
// other sets.
for (var key in sets) {
var other = sets[key];
if (other && other != set)
delete other[item._id];
}
item.remove();
}
sets[type] = null;
}
}
return this.responds(type)
&& this.emit(type, new ToolEvent(this, type, event));
},
_handleEvent: function(type, point, event) {
_handleEvent: function(type, event, point) {
// Update global reference to this scope.
paper = this._scope;
// Now handle event callbacks

View file

@ -31,7 +31,7 @@ var CanvasView = View.extend(/** @lends CanvasView# */{
* @name CanvasView#initialize
* @param {Size} size the size of the canvas to be created
*/
initialize: function CanvasView(project, canvas) {
initialize: function(project, canvas) {
// Handle canvas argument
if (!(canvas instanceof HTMLCanvasElement)) {
// See if the arguments describe the view size:
@ -43,8 +43,6 @@ var CanvasView = View.extend(/** @lends CanvasView# */{
canvas = CanvasProvider.getCanvas(size);
}
this._context = canvas.getContext('2d');
// Have Item count installed mouse events.
this._eventCounters = {};
this._pixelRatio = 1;
/*#*/ if (__options.environment == 'browser') {
if (!/^off|false$/.test(PaperScope.getAttribute(canvas, 'hidpi'))) {
@ -144,150 +142,6 @@ var CanvasView = View.extend(/** @lends CanvasView# */{
project._needsUpdate = false;
return true;
}
},
new function() { // Item based mouse handling:
var downPoint,
lastPoint,
overPoint,
downItem,
lastItem,
overItem,
dragItem,
dblClick,
clickTime;
// Returns true if event was stopped, false otherwise, whether handler was
// called or not!
function callEvent(view, type, event, point, target, lastPoint) {
var item = target,
mouseEvent;
function call(obj, type) {
if (obj.responds(type)) {
// Only produce the event object if we really need it, and then
// reuse it if we're bubbling.
if (!mouseEvent) {
mouseEvent = new MouseEvent(type, event, point, target,
// Calculate delta if lastPoint was passed
lastPoint ? point.subtract(lastPoint) : null);
}
if (obj.emit(type, mouseEvent) && mouseEvent.isStopped) {
// Call preventDefault() on native event if mouse event was
// handled here.
event.preventDefault();
return true;
}
} else if (type === 'doubleclick') {
// If obj doesn't respond to doubleclick, fall back to click:
return call(obj, 'click');
}
}
// Bubble up the parents and call this event until we're told to stop.
while (item) {
if (call(item, type))
return true;
item = item.getParent();
}
// Also call event handler on view, if installed.
if (call(view, type))
return true;
return false;
}
return /** @lends CanvasView# */{
/**
* Returns true if event was stopped, false otherwise, whether handler
* was called or not!
*/
_handleEvent: function(type, point, event) {
// Drop out if we don't have any event handlers for this type
if (!this._eventCounters[type])
return;
// Run the hit-test first
var project = this._project,
hit = project.hitTest(point, {
tolerance: 0,
fill: true,
stroke: true
}),
item = hit && hit.item,
stopped = false;
// Now handle the mouse events
switch (type) {
case 'mousedown':
stopped = callEvent(this, type, event, point, item);
// See if we're clicking again on the same item, within the
// double-click time. Firefox uses 300ms as the max time
// difference:
dblClick = lastItem == item && (Date.now() - clickTime < 300);
downItem = lastItem = item;
downPoint = lastPoint = overPoint = point;
// Only start dragging if none of the mosedown events have
// stopped propagation.
dragItem = !stopped && item;
// Find the first item pu the chain that responds to drag.
// NOTE: Drag event don't bubble
while (dragItem && !dragItem.responds('mousedrag'))
dragItem = dragItem._parent;
break;
case 'mouseup':
// stopping mousup events does not prevent mousedrag / mousemove
// hanlding here, but it does click / doubleclick
stopped = callEvent(this, type, event, point, item, downPoint);
if (dragItem) {
// If the point has changed since the last mousedrag event,
// send another one
if (lastPoint && !lastPoint.equals(point))
callEvent(this, 'mousedrag', event, point, dragItem,
lastPoint);
// If we end up over another item, send it a mousemove event
// now. Use point as overPoint, so delta is (0, 0) since
// this will be the first mousemove event for this item.
if (item !== dragItem) {
overPoint = point;
callEvent(this, 'mousemove', event, point, item,
overPoint);
}
}
if (!stopped && item && item === downItem) {
clickTime = Date.now();
callEvent(this, dblClick ? 'doubleclick' : 'click', event,
downPoint, item);
dblClick = false;
}
downItem = dragItem = null;
break;
case 'mousemove':
// Allow both mousedrag and mousemove events to stop mousemove
// events from reaching tools.
if (dragItem)
stopped = callEvent(this, 'mousedrag', event, point,
dragItem, lastPoint);
// TODO: Consider implementing this again? "If we have a
// mousedrag event, do not send mousemove events to any
// item while we're dragging."
// For now, we let other items receive mousemove events even
// during a drag event.
// If we change the overItem, reset overPoint to point so
// delta is (0, 0)
if (!stopped) {
if (item !== overItem)
overPoint = point;
stopped = callEvent(this, type, event, point, item,
overPoint);
}
lastPoint = overPoint = point;
if (item !== overItem) {
callEvent(this, 'mouseleave', event, point, overItem);
overItem = item;
callEvent(this, 'mouseenter', event, point, item);
}
break;
}
return stopped;
}
};
});
/*#*/ if (__options.environment == 'node') {

View file

@ -119,6 +119,8 @@ var View = Base.extend(Emitter, /** @lends View# */{
// Items that need the onFrame handler called on them
this._frameItems = {};
this._frameItemCount = 0;
// Count the installed events, see _installEvent() / _uninstallEvent().
this._eventCounters = {};
},
/**
@ -150,7 +152,8 @@ var View = Base.extend(Emitter, /** @lends View# */{
return true;
},
_events: Base.each(['onResize', 'onMouseDown', 'onMouseUp', 'onMouseMove'],
_events: Base.each(['onResize', 'onMouseDown', 'onMouseUp', 'onMouseMove',
'onMouseDrag', 'onMouseEnter', 'onMouseLeave'],
function(name) {
this[name] = {
install: function(type) {
@ -681,16 +684,23 @@ var View = Base.extend(Emitter, /** @lends View# */{
},
new function() { // Injection scope for mouse events on the browser
/*#*/ if (__options.environment == 'browser') {
/**
* Native event handling, coordinate conversion, focus handling and
* delegation to view and tool objects.
*/
var tool,
prevFocus,
tempFocus,
dragging = false;
mouseDown = false;
function getView(event) {
// Get the view from the current event target.
var target = DomEvent.getTarget(event);
// Some node do not have the getAttribute method, e.g. SVG nodes.
return target.getAttribute && View._viewsById[target.getAttribute('id')];
return target.getAttribute && View._viewsById[
target.getAttribute('id')];
}
function viewToProject(view, event) {
@ -710,16 +720,33 @@ new function() { // Injection scope for mouse events on the browser
}
}
function handleMouseMove(view, point, event) {
view._handleEvent('mousemove', point, event);
var tool = view._scope.tool;
if (tool) {
// If there's no onMouseDrag, fire onMouseMove while dragging.
tool._handleEvent(dragging && tool.responds('mousedrag')
? 'mousedrag' : 'mousemove', point, event);
function handleEvent(type, view, event, point) {
var eventType = type === 'mousemove' && mouseDown ? 'mousedrag' : type,
project = paper.project,
tool = view._scope.tool;
function handle(obj) {
obj._handleEvent(eventType, event, point);
}
if (!point)
point = viewToProject(view, event);
if (project)
project.removeOn(eventType);
// Always first call the view's mouse handlers, as required by
// CanvasView, and then handle the active tool, if any.
// No need to call view if it doesn't have event handlers for this type.
if (view._eventCounters[type])
handle(view);
if (tool)
handle(tool);
// In the end we always call update(), which only updates the view if
// anything has changed in the above calls.
view.update();
return tool;
}
function handleMouseMove(view, event, point) {
handleEvent('mousemove', view, event, point);
}
// Touch handling inspired by Hammer.js
@ -747,9 +774,9 @@ new function() { // Injection scope for mouse events on the browser
var viewEvents = {
'selectstart dragstart': function(event) {
// Only stop this even if we're dragging already, since otherwise no
// text whatsoever can be selected on the page.
if (dragging)
// Only stop this even if we're mouseDown already, since otherwise
// no text whatsoever can be selected on the page.
if (mouseDown)
event.preventDefault();
}
};
@ -766,10 +793,12 @@ new function() { // Injection scope for mouse events on the browser
// TODO: Remove again after Dec 2016 once it is fixed in Chrome.
var offset = DomEvent.getOffset(event, view._element),
x = offset.x,
abs = Math.abs(x),
max = 1 << 25;
offset.x = abs - max < abs ? (abs - max) * (x < 0 ? -1 : 1) : x;
handleMouseMove(view, view.viewToProject(offset), event);
abs = Math.abs,
ax = abs(x),
max = 1 << 25,
diff = ax - max;
offset.x = abs(diff) < ax ? diff * (x < 0 ? -1 : 1) : x;
handleMouseMove(view, event, view.viewToProject(offset));
}
},
@ -783,22 +812,14 @@ new function() { // Injection scope for mouse events on the browser
viewEvents[mousedown] = function(event) {
// Get the view from the event, and store a reference to the view that
// should receive keyboard input.
var view = View._focused = getView(event),
point = viewToProject(view, event);
dragging = true;
// Always first call the view's mouse handlers, as required by
// CanvasView, and then handle the active tool, if any.
view._handleEvent('mousedown', point, event);
if (tool = view._scope.tool)
tool._handleEvent('mousedown', point, event);
// In the end we always call update(), which only updates the view if
// anything has changed in the above calls.
view.update();
var view = View._focused = getView(event);
mouseDown = true;
handleEvent('mousedown', view, event);
};
docEvents[mousemove] = function(event) {
var view = View._focused;
if (!dragging) {
if (!mouseDown) {
// See if we can get the view from the current event target, and
// handle the mouse move over it.
var target = getView(event);
@ -808,7 +829,7 @@ new function() { // Injection scope for mouse events on the browser
// If we switch view, fire one last mousemove in the old view,
// to give items the change to receive a mouseleave, etc.
if (view !== target)
handleMouseMove(view, viewToProject(view, event), event);
handleMouseMove(view, event);
prevFocus = view;
view = View._focused = tempFocus = target;
} else if (tempFocus && tempFocus === view) {
@ -817,23 +838,15 @@ new function() { // Injection scope for mouse events on the browser
updateFocus();
}
}
if (view) {
var point = viewToProject(view, event);
if (dragging || view.getBounds().contains(point))
tool = handleMouseMove(view, point, event);
}
if (view)
handleMouseMove(view, event);
};
docEvents[mouseup] = function(event) {
var view = View._focused;
if (!view || !dragging)
return;
var point = viewToProject(view, event);
dragging = false;
view._handleEvent('mouseup', point, event);
if (tool)
tool._handleEvent('mouseup', point, event);
view.update();
if (view && mouseDown)
handleEvent('mouseup', view, event);
mouseDown = false;
};
DomEvent.add(document, docEvents);
@ -842,9 +855,84 @@ new function() { // Injection scope for mouse events on the browser
load: updateFocus
});
// Flags defining which native events are required by which Paper events
// as required for counting amount of necessary natives events.
// The mapping is native -> virtual
/**
* Higher level event handling, hit-testing, and emitting of normal mouse
* events along with "virtual" events such as mouseenter, mouseleave,
* mousedrag, click, doubleclick, on both the hit-test item and the view,
* with support for bubbling (event-propagation).
*/
var downPoint,
lastPoint,
downItem,
overItem,
dragItem,
clickItem,
clickTime,
dblClick,
overView,
// Event fallbacks for "virutal" events, e.g. if an item doesn't respond
// to doubleclick, fall back to click:
fallbacks = {
doubleclick: 'click',
mousedrag: 'mousemove'
};
// Returns true if event was stopped, false otherwise, whether handler was
// called or not!
function emitEvent(obj, type, event, point, prevPoint, stopItem) {
var target = obj,
mouseEvent;
function emit(obj, type) {
if (obj.responds(type)) {
// Only produce the event object if we really need it, and then
// reuse it if we're bubbling.
if (!mouseEvent) {
mouseEvent = new MouseEvent(
type, event, point, target, mouseDown,
// Calculate delta if prevPoint was passed
prevPoint ? point.subtract(prevPoint) : null);
}
// Bail out if propagation is stopped
if (obj.emit(type, mouseEvent) && mouseEvent.stopped)
return true;
} else {
var fallback = fallbacks[type];
if (fallback)
return emit(obj, fallback);
}
}
// Bubble up the parents and emit this event until we're told to stop.
while (obj && obj !== stopItem) {
if (emit(obj, type))
return true;
obj = obj._parent;
}
return false;
}
function emitEvents(view, item, type, event, point, prevPoint) {
// First handle the drag-item and its parents, through bubbling.
return (dragItem && emitEvent(dragItem, type, event, point,
prevPoint)
// Next handle the hit-test item, if it's different from the drag
// item and not a descendant of it (in which case it would already
// have received an event in the call above). Use fallbacks to
// translate mousedrag to mousemove, since drag is handled above.
|| item && item !== dragItem && !item.isDescendant(dragItem)
&& emitEvent(item, fallbacks[type] || type, event, point,
prevPoint, dragItem)
// Lastly handle the move / drag on the view, if we're still here.
|| emitEvent(view, type, event, point, prevPoint));
}
/**
* Flags defining which native events are required by which Paper events
* as required for counting amount of necessary natives events.
* The mapping is native -> virtual
*/
var mouseFlags = {
mousedown: {
mousedown: 1,
@ -869,18 +957,87 @@ new function() { // Injection scope for mouse events on the browser
return {
_viewEvents: viewEvents,
// To be defined in subclasses
_handleEvent: function(/* type, point, event */) {},
/**
* Returns true if event was stopped, false otherwise, whether handler
* was called or not!
*/
_handleEvent: function(type, event, point) {
// Run the hit-test first
var hit = this._project.hitTest(point, {
tolerance: 0,
fill: true,
stroke: true
}),
item = hit && hit.item,
inView = this.getBounds().contains(point),
stopped = false,
mouse = {};
// Create a simple lookup object to quickly check for different
// mouse event types.
mouse[type.substr(5)] = true;
// Handle mousemove first, even if this is not actually a mousemove
// event but the mouse has moved since the last event, but do not
// allow it to stop the other events in that case.
var nativeMove = mouse.move || mouse.drag;
moveType = nativeMove && type
|| lastPoint && !lastPoint.equals(point) && 'mousemove';
if (moveType) {
// Handle mouseenter / leave between items, as well as views.
if (item !== overItem) {
if (overItem)
emitEvent(overItem, 'mouseleave', event, point);
if (item)
emitEvent(item, 'mouseenter', event, point);
}
overItem = item;
if (overView && !overView.getBounds().contains(point)) {
emitEvent(overView, 'mouseleave', event, point);
overView = null;
}
if (this !== overView && inView) {
emitEvent(this, 'mouseenter', event, point);
overView = this;
}
if (inView || mouse.drag)
stopped = emitEvents(this, item, moveType, event, point,
lastPoint);
}
if (!nativeMove) {
// Now handle mousedown / mouseup
stopped = emitEvents(this, item, type, event, point, downPoint);
if (mouse.down) {
// See if we're clicking again on the same item, within the
// double-click time. Firefox uses 300ms as the max time
// difference:
dblClick = clickItem === item
&& (Date.now() - clickTime < 300);
downItem = clickItem = item;
downPoint = lastPoint = point;
// Only start dragging if the mousedown event has not
// stopped propagation.
dragItem = !stopped && item;
} else if (mouse.up) {
// Emulate click / doubleclick, but only on item, not view
if (!stopped && item && item === downItem) {
clickTime = Date.now();
emitEvent(item, dblClick ? 'doubleclick' : 'click',
event, point, downPoint);
dblClick = false;
}
downItem = dragItem = null;
}
}
lastPoint = point;
return stopped;
},
_installEvent: function(type) {
// If the view requires counting of installed mouse events,
// increase the counters now according to mouseFlags
var counters = this._eventCounters;
if (counters) {
for (var key in mouseFlags) {
counters[key] = (counters[key] || 0)
+ (mouseFlags[key][type] || 0);
}
for (var key in mouseFlags) {
counters[key] = (counters[key] || 0)
+ (mouseFlags[key][type] || 0);
}
},
@ -888,10 +1045,8 @@ new function() { // Injection scope for mouse events on the browser
// If the view requires counting of installed mouse events,
// decrease the counters now according to mouseFlags
var counters = this._eventCounters;
if (counters) {
for (var key in mouseFlags)
counters[key] -= mouseFlags[key][type] || 0;
}
for (var key in mouseFlags)
counters[key] -= mouseFlags[key][type] || 0;
},
statics: {