paperjs / paper.js

The Swiss Army Knife of Vector Graphics Scripting – Scriptographer ported to JavaScript and the browser, using HTML5 Canvas. Created by @lehni & @puckey
http://paperjs.org
Other
14.45k stars 1.22k forks source link

event.stopPropagation() doesn't stop event propagating to view #995

Open georeith opened 8 years ago

georeith commented 8 years ago

Calling event.stopPropagation() does not stop an event propagating to the view.

item.on('mousedown', function(event) {
    event.stopPropagation();
    console.log('item');
});
view.on('mousedown', function(event) {
   console.log('view');
});

Also the event on the view has the target set as the view, although the original target was the item, is this done on purpose?

Edit: The issue is here:

function emitMouseEvent(obj, type, event, point, prevPoint, stopItem) {
    var target = obj,
        prevented = false,
        mouseEvent;

    function emit(obj, type) {
        if (obj.responds(type)) {
            if (!mouseEvent) {
                mouseEvent = new MouseEvent(type, event, point, target,
                        prevPoint ? point.subtract(prevPoint) : null);
            }
            mouseEvent.id = point;
            if (obj.emit(type, mouseEvent)) {
                called = true;
                if (mouseEvent.prevented)
                    prevented = true;
                if (mouseEvent.stopped)
                    return true;
            }
        } else {
            var fallback = fallbacks[type];
            if (fallback)
                return emit(obj, fallback);
        }
    }

    while (obj && obj !== stopItem) {
        if (emit(obj, type))
            break;
        obj = obj._parent;
    }
    return prevented;
}

function emitMouseEvents(view, item, type, event, point, prevPoint) {
    view._project.removeOn(type);
    called = false;
    return (dragItem && emitMouseEvent(dragItem, type, event, point,
                prevPoint)
        || item && item !== dragItem && !item.isDescendant(dragItem)
            && emitMouseEvent(item, fallbacks[type] || type, event, point,
                prevPoint, dragItem)
        || emitMouseEvent(view, type, event, point, prevPoint));
}

Propagation to view seems to be handled by emitMouseEvents if the first two OR statements are false. However propagation detection is in emitMouseEvent and uses a closured MouseEvent so the view receives a different one with the wrong target and without the stopped property.

georeith commented 8 years ago

Yeah it is, nothing major just context can always be changed, especially with ES6 arrow methods and lexical scoping would be nice to have consistent access to that value. 99% of the time though this is fine.

lehni commented 8 years ago

We could probably add it in a really simple way, through Emitter#emit(). If there's an event object, it could set #currentTarget to this before emitting the event...

lehni commented 8 years ago

If that's too extreme (probably), then maybe the Event classes that should behave this way could define a "private" property called #_updateTarget, and if that's set to true, it would do the above.

georeith commented 8 years ago

Sounds good.

One question now I've had time to think about it. Are we being consistent with the hitTest as sometimes its the this value of the first triggered listener and other times its the highest item under the mouse no? I might just be misunderstanding the possible values of Event#target though.

Perhaps we should just set Event#target to whatever the first currentTarget is and ensure it is always the same throughout. If its the view its the view, if it fires on a object before propagating to view then it is that, assuming we do child -> parent propagation and not just view (haven't actually tested that).

E.g., http://sketch.paperjs.org/#S/nZKxbsMgEEB/BbE4kRCyGV11ytC9GWMPFF9sZHpYmCRKIv97wbFrR0qXMqDjne7ugbhTlN9Ac7pvwauGMqpsFc9n6YgD5SXWBjLyThAuZN/IDvjnjDf3AklYndXoc3JIGUlL9mC9vkFAWRpg2GZ81MbsrLEuJ4m6SkwmHi0C+p0oYmLYvhVY4JOK+LeKiCritYqD6i+TLHkSqZ09dZPDR4znyVPdmJ97qUabygGG6csF2Opdy6W3hgu3uDNataH92GZ1XkpeQbGCxxMqry1u4Azot2TSUxZ7a4AbWz8y3EtXg+fRmxHf6H4Mo80QvsGXA9mOr9nT/FAOPw==

click on rectangle1:

  1. target: rectangle1, currentTarget: rectangle1
  2. target: rectangle1, currentTarget: group
  3. target: rectangle1, currentTarget: view

click on rectangle2:

  1. target: rectangle2, currentTarget: rectangle2
  2. target: rectangle2, currentTarget: group
  3. target: rectangle2, currentTarget: view

click on group:

  1. target: group, currentTarget: group
  2. target: group, currentTarget: view

click on view:

  1. target: view, currentTarget: view

Unless I'm misunderstanding Event#target there and it is always the highest visible item like the DOM (if so ignore this I'm being a numpty). This lets you do everything, determine that the event is propagated and from where, and if you need to delegate events just hitTest the item with the event.point to get the child it hit.

lehni commented 8 years ago

Yeah that's what I was wondering about too. So this whole hitTest() / getTarget() magic wasn't even necessary. Oh well. : )

georeith commented 8 years ago

Ha sorry I can't really remember what we were going over before it might have already been suggested, was just running it over my head last night and seems like the most elegant without degrading performance. Also I messed up the naming a bit in that sketch this is a better one:

http://sketch.paperjs.org/#S/nZKxbsMgEIZfBbE4kZDlMLrqlKF7M8YeKL7YyOTOwiRRG/ndC44tO1K6lAFx34n/PiHuHNUZeM4PLXjdcME1VbG+KsccaK+wtrBj7wzhxg6N6iD9nPHmXiALqyODPmfHTLCsFA/Wmx8IaJcFGLYZn4y1e7Lkcpbob4XJxKNFQMvE2Bi2bwUW+KQi/60io4p8reKg+stEJk8itaNLNzl8xPM8ebo39ucs3RhbOcAwfckTq3ctl2wDt5Rwb41uQ/wYs6qXK6+gXMHTBbU3hBu4Avotm/Q0YU8WUkv1o5N65WrwafQWzDemH4/RZgjf4MuBasfX7Hl+LIdf

lehni commented 8 years ago

Yeah I think I had a misconception about what #target should return, hence our endless disagreements and looping earlier in the year, probably. Apologies for that. I will revert the hitTest() magic.

georeith commented 8 years ago

@lehni Not at all. I think we both just had a stressful day and felt we were right I was making assumptions on snippets of code, I know you were fed up with the events just having refactored them and rightly so, I know the feeling. I think we are on to a winner here though.

lehni commented 8 years ago

@georeith yeah I do remember some preexisting levels of stress that day, unrelated : )

I do like where we ended up here, code looks good! I wasn't keen on the hitTest() hack.

It would be good to have unit tests in place for all this mouse event stuff... I guess we could just emulate prerecorded mouse sequences by the use of dispatchEvent()?

elliotbonneville commented 8 years ago

That's what we've settled on doing with our unit tests. Here's what we've been using to dispatch events:

const scope = new paper.PaperScope();
const document = scope.document;

export const mouse = {
    move(x, y) {
        document.dispatchEvent(new window.MouseEvent('mousemove', {
            clientX: x,
            clientY: y,
        }));
    },

    down(x, y) {
        scope.project.view.element.dispatchEvent(new window.MouseEvent('mousedown', {
            clientX: x,
            clientY: y,
        }));
    },

    up(x, y) {
        document.dispatchEvent(new window.MouseEvent('mouseup', {
            clientX: x,
            clientY: y,
        }));
    },
};
lehni commented 8 years ago

@elliotbonneville that's great! We should do this too. What are you testing for this way?

lehni commented 8 years ago

@georeith now with event.currentTargetand docs.

lehni commented 8 years ago

@georeith I've been juggling too many things this week, didn't realize we made a wrong assumption, and this was masked by the accidental removal of a code optimization that only executes the hit-test when it's sure to be needed. I've added this optimization back now (ab24f92373245754621bb156e340a79b894853b7), and it shows that we do need the hitTest() getter magic. See this example:

https://jsfiddle.net/lehni/ou13xkzs/

The event is installed on the outmost <div> only, yet event.target points at whatever element is on top of the stack where you click, wether it subscribes to the event or not. This is what I wanted to achieve with this additional getTarget() code, and also what I pointed out in your suggestion earlier this year as a short-coming.

I hope we can finally clear this confusion up : ) I think we have two options: Keep the code simple and always run a hit-test, wether we need it or not (slow), or bring back my getTarget() getter that uses the already calculated hit-test result if it exists or performs a hit-test if it's the first time it's requested (depending on the value of hitItems).

georeith commented 8 years ago

@lehni Indeed that is the HTML way. I was thinking we would only do top of stack if it subscribes to the event (which deviates from HTML but saves additional hit-testing) leaving it up to the developer to do it or not.

So what I was thinking was the first emitted callback sets event#target to its owner. And any additional propagation has the same value just setting event#currentTarget to its owner.

Although I'm happy with if we do match the HTML spec too. Just worried it will make mousemove listeners slow for scenes with a large number of items. Not sure how optimised hit-test itself is. Under what circumstances does the hit-test already exist? Originally I imagined you ran a hit-test only on items with listeners for that event.

Edit: It could be possible to run the full hit-test once, cache it and invalidate it whenever there is a geometrical change. Is that what the hit-test optimisation does? That would still be slow for what I imagine are common cases like mousemove based translation or resize which would invalidate it on every event though.

lehni commented 8 years ago

The way I had written is, the hit-test is only run when it's directly required (e.g. because of click, mouseenter, mouseleave events), or when event.target is first accessed. And per handling of event, the hit-test is never run more than once. So if you don't use event.target, there is never any impact on performance. And if you do, then it is necessary to behave the same as HTML, which I think is the only logic way now really...

georeith commented 8 years ago

@lehni Ah ok I like that it is in a getter on event.target. Although is it not possible that could be messed up? As in my event callback I move an item over event#point and then access event#target would it not wrongly report that item as being the event#target?

lehni commented 8 years ago

I guess it would, but I guess we could live with that? : ) But yeah,you've got a point. Damn,...

georeith commented 8 years ago

@lehni Hmmm I'm just worried it would create more issues. Its a bit of a strange side effect to include in code, having to remember to read event.target before modifying anything.

lehni commented 8 years ago

Yeah I agree.

lehni commented 8 years ago

Always run the test?

georeith commented 8 years ago

@lehni I guess we have to to match HTML spec.

lehni commented 8 years ago

@georeith matching the spec means always running the hit-test. We could also activate it by default but offer a flag that turns it off?

lehni commented 8 years ago

What I mean is: There could be a need for no "correct" item-level event support. It could be nice to be able to install events on the view only, and not have paper.js take care of event#target, for performance reasons.

georeith commented 8 years ago

@lehni Sounds good, I would just return null or throw an error for the target if you switch it off instead of returning a possibly incorrect value.