Class: Goat::DOMTools::DOMDiff

Inherits:
Object
  • Object
show all
Extended by:
Goat::DOMTools
Includes:
Goat::DOMTools, DiffTools
Defined in:
lib/goat/dom.rb

Class Method Summary collapse

  • .dom_diff(old, new, id) ⇒ Object
  • .preproc(tree, id) ⇒ Object
  • .unproc(tree) ⇒ Object
  • Instance Method Summary collapse

    Methods included from Goat::DOMTools

    attrs, body, car_tag, dom_components, dom_node?, domid, expanded_dom, find_component, inject_prefixes, is_attrs?, nested_body?, normalized_tags, tag, transpose, traverse

    Methods included from DiffTools

    #added, #dom_node?, #lcs_is_addition?, #lcs_is_removal?, #lcs_is_replacement?, #lcs_old_and_new, #removed, #type_of

    Constructor Details

    #initialize(old, new) ⇒ DOMDiff

    Returns a new instance of DOMDiff.

    
    
    522
    523
    524
    525
    # File 'lib/goat/dom.rb', line 522
    
    def initialize(old, new)
      @old = old
      @new = new
    end
    

    Class Method Details

    .diff(old, new) ⇒ Object

    the constraints:

    • arrays might be tags or they might be arrays
    • we can't directly address textual entities; they must be addressed by their enclosing tag (i.e.
    • foo
    • ->
    • bar
    • is a change to an li tag, not a change to the contents of an li tag. Why, you ask? Here:

      $("#foo").innerHTML "blubfooquxbar" $("#foo").children [blub, qux] Text isn't a thing in the DOM tree. So, in order to traverse, we have to restrict ourselves to DOM nodes, i.e. tags, not raw textual elements in the tree. I don't know why the DOM works this way.

    • we only try to deal with changes where the parent element of the changed element has a [unique] ID. We could address by route through the tree, but we don't attempt to store this.
    • we can't always tell in advance where we should make a change. For example, [:ul, [:li, [:a, => 'hello', [:b, 'test']]]] changing to: [:ul, [:li, [:a, => 'hello', [:b, 'blub']]]] When we're looking at the ul, and before we know what exactly is different below us, it may be the case that the change can be encapsulated more neatly somewhere in the subtree, but it may not -- and even if the change is nice and isolated further down, there might not be a parent ID for us to hook off. This is what NoParentDOMID exceptions are used for.
    • we want to ignore artefacts from randomization of IDs. (i.e. cases where nothing has changed except the dom_XXXXXXXX ID of some element. This is pretty easy intuitively, but a bit more subtle in practice. The obvious thing to do is to use an alternate comparison function, instead of ==, but this may be slow, and would (worse) need a lot of care to make sure that your code (and outside libraries, such as Diff::LCS, which we use) never call it. Next obvious thing is to create a subclass of Hash, make all hash attributes (=> 'dom_XXXXXXX', :class => '...') instances of it, and then redefine == on the subclass. This doesn't work, though, because ruby's crappy C implementation won't dispatch to the subclass's == -- for hashes, arrays, etc., the comparison code is wired in and can't be overridden. So instead, we have our subclass actually remove the unwanted elements, and store them in a separate @deleted ivar. The crappy part of this is that you need to manually go through the dom and stick them all back in the "actual" hash once you've finished diffing.
    • we don't want to remove IDs that don't change. For example, during a rerender, we preserve the ID of the rerendered-component (just not the IDs of any child components). If we deleted the IDs of these, constraint #3 would mean we'd miss a lot of potential diffs, since we'd think there were changes we couldn't make. (I.e. think a child's parent doesn't have an ID when in fact it does.) One todo here is to make the differ check the @deleted ivar of a DOMAttrs instance for IDs, since they're perfectly valid hooks to use when removing or adding components. If you do this, conflicts become much more likely though -- say you have multiple changes to apply; a node whose ID you depend on may be removed.
    
    
    492
    493
    494
    # File 'lib/goat/dom.rb', line 492
    
    def self.diff(old, new)
      d = self.new(old, new).diff
    end
    

    .dom_diff(old, new, id) ⇒ Object

    
    
    510
    511
    512
    513
    514
    515
    516
    517
    518
    519
    520
    # File 'lib/goat/dom.rb', line 510
    
    def self.dom_diff(old, new, id)
      trees = []
      [old, new].each {|tree| trees << preproc(tree, id)}
      diff(*trees).map do |diff|
        if diff.first == :add
          diff[0..-2] + [unproc(diff[3])]
        else
          diff
        end
      end
    end
    

    .preproc(tree, id) ⇒ Object

    
    
    496
    497
    498
    499
    500
    501
    502
    503
    # File 'lib/goat/dom.rb', line 496
    
    def self.preproc(tree, id)
      DOMTools.transpose(tree) do |elt, update|
        if dom_node?(elt) && attrs(elt)
          update.call([tag(elt), DOMAttrs.from_hash(attrs(elt), id), *normalized_tags(body(elt))])
        end
        true
      end
    end
    

    .unproc(tree) ⇒ Object

    
    
    505
    506
    507
    508
    # File 'lib/goat/dom.rb', line 505
    
    def self.unproc(tree)
      tree.flatten.each{|elt| elt.reconstitute if elt.is_a?(DOMAttrs)}
      tree
    end
    

    Instance Method Details

    #cmp(old, new, par) ⇒ Object

    
    
    599
    600
    601
    602
    603
    604
    605
    606
    607
    608
    609
    610
    611
    612
    613
    614
    615
    616
    617
    618
    619
    620
    621
    622
    623
    624
    # File 'lib/goat/dom.rb', line 599
    
    def cmp(old, new, par)
      return [] if old == new
    
      told, tnew = type_of(old), type_of(new)
      if told != tnew
        if told == :string || tnew == :string
          raise TextualTranspose
        else
          return transpose(old, new, par)
        end
      end
    
      if told == :string || tnew == :string && old != new
        raise TextualTranspose
      end
    
      type = told
    
      if type == :node
        cmp_node(old, new, par)
      elsif type == :array
        cmp_array(old, new, par)
      else
        raise "Unknown thing: #{old.inspect}"
      end
    end
    

    #cmp_array(old, new, par) ⇒ Object

    
    
    551
    552
    553
    554
    555
    556
    557
    558
    559
    560
    561
    562
    563
    564
    565
    566
    567
    568
    569
    570
    571
    572
    573
    574
    575
    576
    577
    578
    579
    580
    581
    582
    583
    584
    585
    586
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    # File 'lib/goat/dom.rb', line 551
    
    def cmp_array(old, new, par)
      if old.size == 1 && new.size == 1
        return cmp(old.first, new.first, par)
      end
    
      patch = Diff::LCS.diff(old, new).flatten(1)
    
      left, right = patch
      if patch.size == 2 && lcs_is_replacement?(left, right)
        # if we're just replacing an element, maybe we can do it more elegantly. e.g.,
        # [:p, [:li, 'foo'], [:li, 'bar']] -> [:p, [:li, 'foo'], [:li, 'bar'], [:li, 'baz']]
        # can be represented as a replacement of the p tag itself, or the insertion of
        # [:li, 'baz']. If the patch looks like a simple replacement, we delve deeper.
        dold, dnew = lcs_old_and_new(left, right)
        old, new = dold.element, dnew.element
        begin
          return cmp(old, new, domid(old))
        rescue NoParentDOMID, TextualTranspose
          # fine fine if you hate fun we can just continue on our merry way below
        end
      end
    
      a, b, off = 0, 0, 0
      chgs = patch.map do |ch|
        if lcs_is_addition?(ch)
          while b < ch.position
            a += 1; b += 1; off += 1
          end
          b += 1
    
          mut = [added(ch.element, par, off)]
          off += 1
          mut
        elsif lcs_is_removal?(ch)
          while a < ch.position
            a += 1; b += 1; off += 1
          end
          a += 1
    
          [removed(ch.element, par, off)]
        else
          raise "Bad diff: #{ch.inspect}"
        end
      end
    
      chgs.flatten(1)
    end
    

    #cmp_node(old, new, par) ⇒ Object

    
    
    535
    536
    537
    538
    539
    540
    541
    542
    543
    544
    545
    546
    547
    548
    549
    # File 'lib/goat/dom.rb', line 535
    
    def cmp_node(old, new, par)
      oattrs = attrs(old)
      if tag(old) == tag(new) && \
          oattrs == attrs(new)
        begin
          cmp(body(old), body(new), oattrs ? domid(old) : nil)
        rescue NoParentDOMID => e
          transpose(old, new, par)
        rescue TextualTranspose => e
          transpose(old, new, par)
        end
      else
        transpose(old, new, par)
      end
    end
    

    #diffObject

    
    
    527
    528
    529
    # File 'lib/goat/dom.rb', line 527
    
    def diff
      cmp(@old, @new, nil)
    end
    

    #transpose(old, new, par) ⇒ Object

    
    
    531
    532
    533
    # File 'lib/goat/dom.rb', line 531
    
    def transpose(old, new, par)
      [removed(old, par, 0), added(new, par, 0)]
    end