How to compare HTML elements by real z-index?

16

Given two arbitrary HTML elements A and B on the same page, how can I find which one is "closest" to the user (i.e. if they overlap, which one would obscure the other)?

The W3C CSS specification describes "stacking contexts" ( stacking contexts ), that the rendering engines that follow the standards should implement. However, I could not find a way to access this information via JavaScript. All I have access to is the css z-index property, which by itself does not say much, since in most cases it is auto and - even when expressed by a number - is not a reliable indicator of how the elements are actually displayed (because if they belong to different stacking contexts, comparing z-indexes is irrelevant).

Note: I'm interested in arbitrary elements; if both are under the mouse pointer, only one will be considered "hovered" , so determining the closest is trivial. Likewise, if I know they intersect at a particular point, I can use document.elementFromPoint() as suggested by in that answer in the English OS. However, it may not be the case that I know a point like this, or even that it exists - the elements may have no intersection with each other.

Is there a general solution to this problem, preferably one that does not involve reimplementing the stacking algorithm that the render engine is already doing anyway?

Motivation: There is a limitation on the drag-and-drop functionality of jQuery where you can not decide for sure where to drop a dragged element:

OldversionsofjQuerywouldchooseoneofthemratherthanrandom,whilerecentversionswilldothedropinboth(moredetailsonthat#). The ideal would be to choose just one of them - the one that is "closest to the screen" - and make the drop only in it, and for that I need a correct way to determine its actual z- even though this involves "re-inventing the wheel" as long as the result is consistent with the browser stack ).

* Another option, as pointed out by @utluiz, would be to take the intersection area into account when determining the correct target - either requiring the helper to be fully contained in (for example, if it is visually clear which is the correct target, but a helper tip touches a different element). But unfortunately, even to correctly calculate this area of intersection it is necessary to know the relative z-indexes, as it changes if one obscures the other.

Update: A seemingly complete solution was posted on SOen , full code is on GitHub and there is a usage example no jsFiddle . At first glance it looks ok, but any additional feedback would be very welcome. If everything is correct, post here as an answer.

    
asked by anonymous 22.12.2013 / 21:47

3 answers

6

Considering only elements that have explicitly defined z-indexes, it is possible to greatly simplify the CSS specification algorithm . Basically, when comparing two elements, it is necessary to locate the first stacking context they share, and to compare the z-indices of the elements that created that context.

Given the functions css (which returns the computed value of a property), contexto (which returns the element's stacking context) and descendente (which detects if the first element passed descends from the second) the algorithm would be:

function naFrente(a, b, memo) {
    // Guardando o elemento passado na chamada original,
    // em caso de recursão
    memo = memo || [[],[]];
    memo[0].push(a);
    memo[1].push(b);

    // Contextos de empilhamento dos elementos passados
    var ctxA = contexto(a);
    var ctxB = contexto(b);

    // Se a é descendente de b, considera que a está na frente
    if(descendente(a, b)) return a;

    // Se b é descendente de ba, considera que b está na frente
    if(descendente(b, a)) return b;    

    // Se no mesmo contexto, compara z-índices
    if(ctxA === ctxB) {
        var zA = +css(a, 'z-index');
        var zB = +css(b, 'z-index');

        // Comparando dois z-índices definidos
        if(!isNaN(zA) && !isNaN(zB)) {
           return zB > zA ? memo[1][0] : memo[0][0];

        // Primeiro termo não definido:
        // retorna o segundo se não for NaN
        } else if(isNaN(zA)) {
            return isNaN(zB) ? memo[0][0] : memo[1][0];

        // Ambos NaN, retorna o primeiro termo
        } else {
            return memo[0][0];
        }

    // Recursão no caso de contextos diferentes
    } else {
        // Se subiu até o body, restaura o contexto anterior
        // para achamada recursiva
        ctxA = ctxA === document.body ? a : ctxA;
        ctxB = ctxB === document.body ? b : ctxB;
        return naFrente(ctxA, ctxB, memo);
    }
}

However, the APIs available in the browser do not provide any method to get the stacking context in which an element is. To implement our own function, let's rely on the specification says about the generation of contexts :

  

The root element forms the root stacking context. Other stacking contexts are generated by any positioned element (including relatively positioned elements) having a computed value of 'z-index' other than 'auto'. Stacking contexts are not necessarily related to containing blocks. In future levels of CSS, other properties may introduce stacking contexts, for example 'opacity'

Free translation:

  

The root element forms the first commit context. Other contexts are generated by any positioned element (including those with relative position) that have a computed value of 'z-index' other than 'auto'. Stacking contexts do not necessarily correspond to container blocks. In future versions of CSS, other properties can generate stacking contexts, such as 'opacity'.

Therefore, to generate a stacking context, the element must be positioned, and have a z-index or opacity (in CSS3) declared. And as one context may contain others, we will again need a recursive function:

function contexto(el) {
    var ctx = el.parentElement;
    if(ctx && ctx !== document.body) {
        // Verifica se o elemento está posicionado, 
        // e se tem z-index ou opacity
        var posicionado = css(ctx, 'position') !== 'static';
        var possuiZIndex = css(ctx, 'z-index') !== 'auto';
        var naoOpaco = +css(ctx, 'opacity') < 1;

        // Se ctx for um contexto, retorna
        if(posicionado && (possuiZIndex || naoOpaco)) {
            return ctx;

        // Procura um contexto mais acima, via recursão
        } else {
            return contexto(ctx);   
        }
    // Chegamos ao elemento raiz    
    } else {
        return document.body;   
    }
}

This is pretty crude yet, and I believe you still need testing and tuning, but I hope you have managed to convey the idea.

@mgibsonbr TESTS

My tests give slightly different results from the in cases where there is no z-index defined, or in case of a tie of z-indexes. In these cases, the order in which the elements are passed makes a difference: the code returns the first element, a . Yes, this was an arbitrary decision, which I do not like, but they are limiting situations.

Auxiliary methods

(Already in the jsfiddles linked above, but it is good to register here too)

// Retorna true se a for descendente de b
function descendente(a, b) {
    var el = a.parentNode;
    while(el) {
      if(el === b) return true;
      el = el.parentNode;
    }
    return false;
}

// Retorna valor computado da propriedade prop do elemento el
function css(el, prop) {
     return window.getComputedStyle(el).getPropertyValue(prop);
}
    
23.12.2013 / 18:12
5

I do not have an absolute answer, but I will sketch some ideas about it.

Thinking About Usability

Regarding the example figure of the question, although it is technically coherent to drop the object being dragged into what is closest to the user, this may cause some confusion for some people.

Unless the user knows that A is "in front" of B (or vice versa), it would not make sense to him that object being dragged would be left preferentially over A or B , since it does not have this knowledge about layers . It would not be intuitive.

In this context, considering that the elements all seem to be in a flat layer, in terms of usability, the current jQuery action of triggering the event on the two elements would make more sense, is "dropping where the helper object is touching."

Approaches to "Drag and Drop" ( Drag & Drop )

It's important to note that there are different approaches that you can take to "drag and drop". Example:

  • The object can be dropped ( drop ) at a particular potential target when it intersects with it. This is the concept used in the question.
  • The object can be dropped on a given target if it is fully contained within the target area.
  • Another possible approach would be to consider the position of the mouse cursor as the deciding factor for setting the target.
  • I think approach # 1 is interesting in terms of user experience, but technically it addresses the problem described in the question. In other cases, you can use the document.elementFromPoint() method to get the element at the mouse position (# 3) or at some point of the object being dragged (# 2).

    Note: In approach # 3, the object being dragged ( helper ) could not be ob the mouse cursor for the document.elementFromPoint() method to work. It would have to be positioned a certain distance from the cursor.

    Approach # 3 would also solve the case of the target objects being partially overlapping, since we would have only one point as a reference, avoiding ambiguity. In the figure below, it is noted that approach # 3 is the one that would make it easier for the user to drop an element in A or B .

    Usingz-index

    Ididsomeresearchandtestingbyretrievingz-indexviathecssmethodofjQueryandalsowiththegetComputedStylemethod(assuggestedby@BrunoLM).Thesemethodsonlyreturnausefulvalueiftheobjecthasthez-indexattributesetnumerically,otherwisethereturnedvalueisauto.

    Let'ssupposethattheelementsparticipatingintheeventsareunderourcontrolsothatallhavez-indezdefined,uniqueandareatthesamelevelofHTMLhierarchy,beingchildrenofthesameancestor.Inthiscase,tofindtheclosestelementofthescreen,simplycomparethevalueofthez-indexattributeofeachpotentialtarget.

    Morecomplexapproachescouldbeused,forexample:

    • Iftheelementsdonothavez-indexdefinedbuthavethesameancestor,youcancheckthepositionofeachone(methodindex()ofjQuery).Theelementwiththehighestindexiswhatwillbemore"up front."
    • If the elements do not have z-index , but their ancestors have, this can be used to calculate an approximation, adding the z-index of the "parent" to the position of the child element.

    However, any such approach is a simplified implementation of the engine stacking algorithm of the browser, which I believe we should avoid, since it will easily be "broken" by a new use case.

    Conclusion

    A different approach to drag-and-drop can simplify implementation.

    However, it is also possible to resolve the issue in a simpler way by limiting the scope of the solution to a more controlled environment, for example, where all the participating elements have a defined and unique%% and all are in the same local in the element tree ( siblings ).

        
    23.12.2013 / 12:37
    5

    In the absence of simpler solutions than "reinventing the wheel," it follows my attempt to write a function consistent with the stacking algorithm used by browsers :

    function naFrente(a, b) {
        // Salta todos os ancestrais em comum, pois não importa seu contexto de empilamento:
        // ele afeta a e b de maneira igual
        var pa = $(a).parents(), ia = pa.length;
        var pb = $(b).parents(), ib = pb.length;
        while ( ia >= 0 && ib >= 0 && pa[--ia] == pb[--ib] ) { }
    
        // Aqui temos o primeiro elemento diferente nas árvores de a e b
        var ctxA = (ia >= 0 ? pa[ia] : a), za = zIndex(ctxA);
        var ctxB = (ib >= 0 ? pb[ib] : b), zb = zIndex(ctxB);
    
        // Em último caso, olha as posições relativas
        // (Esse valor só será usado se não tiver nenhum z-index explícito)
        var relativo = posicaoRelativa(ctxA, ctxB, a, b);
    
        // Acha o primeiro com zIndex definido
        // O ancestral "mais fundo" é que importa, uma vez que ele define o contexto de
        // empilhamento mais geral.
        while ( ctxA && za === undefined ) {
            ctxA = ia < 0 ? null : --ia < 0 ? a : pa[ia];
            za = zIndex(ctxA);
        }
        while ( ctxB && zb === undefined ) {
            ctxB = ib < 0 ? null : --ib < 0 ? b : pb[ib];
            zb = zIndex(ctxB);
        }
    
        // Compara os z-indices, ou usa o método relativo
        if ( za !== undefined ) {
            if ( zb !== undefined )
                return za > zb ? a : za < zb ? b : relativo;
            return za > 0 ? a : za < 0 ? b : relativo;
        }
        else if ( zb !== undefined )
            return zb < 0 ? a : zb > 0 ? b : relativo;
        else
            return relativo;
    }
    
    /* Adaptado do código de @bfavaretto
       Retorna um valor se o z-index for definido, undefined caso contrário.
    */   
    function zIndex(ctx) {
        if ( !ctx || ctx === document.body ) return;
    
        // Verifica se o elemento está posicionado, 
        // e se tem z-index ou opacity
        var posicionado = css(ctx, 'position') !== 'static';
        var possuiZIndex = css(ctx, 'z-index') !== 'auto';
        var naoOpaco = +css(ctx, 'opacity') < 1;
    
        // Se ctx for um contexto, retorna
        //if(posicionado && (possuiZIndex || naoOpaco)) {
        if(posicionado && possuiZIndex) // Ignorando CSS3 por ora
            return +css(ctx, 'z-index');
    }
    
    /* Utilitário sugerido por @BrunoLM
       Obtém o valor de uma propriedade CSS levando em consideração a herança, sem jQuery.
    */
    function css(el, prop) {
         return window.getComputedStyle(el).getPropertyValue(prop);
    }
    
    /* Na ausência de z-index definido, deve comparar os elementos em pré-ordem,
       profundidade primeiro. Há edge cases ainda não tratados.
    */
    function posicaoRelativa(ctxA, ctxB, a, b) {
        // Se um elemento é ancestral do outro, o descendente está na frente
        if ( $.inArray(b, $(a).parents()) >= 0 )
            return a;
        if ( $.inArray(a, $(b).parents()) >= 0 )
            return b;
        // Se dois contextos são irmãos, o declarado depois está na frente
        return ($(ctxA).index() - $(ctxB).index() > 0 ? a : b);
    }
    

    Example 1 . Example 2 . Example 3 . I am sure that the above code except will be corrected by posicaoRelativa : even though it is consistent with all tests presented, it has been compiled by trial and error and therefore requires a revision to ensure compliance with the specifications.

        
    23.12.2013 / 23:17