goat 0.3.45 → 0.3.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/bin/state-srv +23 -11
- data/goat.gemspec +2 -2
- data/lib/goat.rb +96 -208
- data/lib/goat/common.rb +13 -4
- data/lib/goat/dom.rb +627 -0
- data/lib/goat/extn.rb +1 -1
- data/lib/goat/goat.js +61 -39
- data/lib/goat/state-srv.rb +47 -7
- metadata +5 -5
- data/lib/goat/html.rb +0 -301
data/lib/goat/common.rb
CHANGED
@@ -54,23 +54,32 @@ module Goat
|
|
54
54
|
end
|
55
55
|
|
56
56
|
class ComponentUpdate
|
57
|
-
attr_reader :skel, :mutations
|
57
|
+
attr_reader :skel, :mutations, :added, :removed
|
58
58
|
attr_accessor :version
|
59
59
|
|
60
|
-
def initialize(skel, mutations)
|
60
|
+
def initialize(skel, mutations, added, removed)
|
61
61
|
@skel = skel
|
62
62
|
@mutations = mutations
|
63
|
+
@added = added
|
64
|
+
@removed = removed
|
63
65
|
end
|
64
66
|
|
65
67
|
def to_hash
|
66
68
|
{
|
67
69
|
'skel' => @skel.to_hash,
|
68
|
-
'mutations' => @mutations
|
70
|
+
'mutations' => @mutations,
|
71
|
+
'added' => @added.map(&:to_hash),
|
72
|
+
'removed' => @removed
|
69
73
|
}
|
70
74
|
end
|
71
75
|
|
72
76
|
def self.from_hash(h)
|
73
|
-
self.new(
|
77
|
+
self.new(
|
78
|
+
ComponentSkeleton.from_hash(h['skel']),
|
79
|
+
h['mutations'],
|
80
|
+
h['added'].map{|a| ComponentSkeleton.from_hash(a)},
|
81
|
+
h['removed']
|
82
|
+
)
|
74
83
|
end
|
75
84
|
end
|
76
85
|
end
|
data/lib/goat/dom.rb
ADDED
@@ -0,0 +1,627 @@
|
|
1
|
+
require 'rack/utils'
|
2
|
+
|
3
|
+
module Goat
|
4
|
+
module DOMTools
|
5
|
+
def tag(node); node[0]; end
|
6
|
+
def is_attrs?(x); x.kind_of?(Hash) || x.kind_of?(DOMAttrs); end
|
7
|
+
def attrs(node); node[1] if is_attrs?(node[1]); end
|
8
|
+
def body(node); is_attrs?(node[1]) ? node[2..-1] : node[1..-1]; end
|
9
|
+
def domid(node); attrs(node) ? attrs(node)[:id] : nil; end
|
10
|
+
def dom_node?(node); node.is_a?(Array) && node.first.is_a?(Symbol); end
|
11
|
+
def nested_body?(node); node.is_a?(Array) && !node.first.is_a?(Symbol); end
|
12
|
+
def normalized_tags(body); nested_body?(body) ? body.map{|b| normalized_tags(b)}.flatten(1) : [body]; end
|
13
|
+
def car_tag(tree)
|
14
|
+
if (b = tree).is_a?(Array)
|
15
|
+
b = b.first while b.is_a?(Array)
|
16
|
+
b
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
module_function :tag, :attrs, :body, :domid, :dom_node?, :is_attrs?, :nested_body?,
|
21
|
+
:normalized_tags, :car_tag
|
22
|
+
|
23
|
+
class Traverser
|
24
|
+
include DOMTools
|
25
|
+
|
26
|
+
def initialize(tree, dlg, transpose)
|
27
|
+
@tree = tree
|
28
|
+
@dlg = dlg
|
29
|
+
@transpose = transpose
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_node(tag, attrs, body)
|
33
|
+
tag = [tag]
|
34
|
+
tag << attrs if attrs
|
35
|
+
tag += normalized_tags(body)
|
36
|
+
end
|
37
|
+
|
38
|
+
def replacement_block
|
39
|
+
@rep = nil
|
40
|
+
@replacement_block ||= lambda {|new| @rep = new}
|
41
|
+
end
|
42
|
+
|
43
|
+
def traverse(node)
|
44
|
+
if node.is_a?(String)
|
45
|
+
@dlg.string(node, &replacement_block)
|
46
|
+
@rep || node
|
47
|
+
elsif node == nil
|
48
|
+
@dlg.string('', &replacement_block)
|
49
|
+
@rep || nil
|
50
|
+
elsif dom_node?(node)
|
51
|
+
stay_traversing = @dlg.node(node, &replacement_block)
|
52
|
+
rep = @rep || node
|
53
|
+
|
54
|
+
if @transpose
|
55
|
+
if !rep.equal?(node)
|
56
|
+
if stay_traversing
|
57
|
+
to_node(tag(rep), attrs(rep), traverse(body(rep)))
|
58
|
+
else
|
59
|
+
rep
|
60
|
+
end
|
61
|
+
else
|
62
|
+
to_node(tag(node), attrs(node), traverse(body(node)))
|
63
|
+
end
|
64
|
+
else
|
65
|
+
traverse(body(node))
|
66
|
+
end
|
67
|
+
elsif node.is_a?(Array)
|
68
|
+
if node.size == 1
|
69
|
+
traverse(node.first)
|
70
|
+
else
|
71
|
+
if @transpose
|
72
|
+
node.map{|x| traverse(x)}
|
73
|
+
else
|
74
|
+
node.each{|x| traverse(x)}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
elsif node.kind_of?(Component)
|
78
|
+
@dlg.component(node, &replacement_block)
|
79
|
+
@rep || node
|
80
|
+
else
|
81
|
+
raise "Unknown object in the dom: #{node.inspect}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def traverse!
|
86
|
+
traverse(@tree)
|
87
|
+
end
|
88
|
+
|
89
|
+
class BlockTraverser
|
90
|
+
# would be infinitely nicer if you could just yield from a block
|
91
|
+
def initialize(blk, transpose)
|
92
|
+
@blk = blk
|
93
|
+
@transpose = transpose
|
94
|
+
end
|
95
|
+
|
96
|
+
def node(node, &blk)
|
97
|
+
if @transpose
|
98
|
+
@blk.call(node, blk)
|
99
|
+
else
|
100
|
+
@blk.call(node)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def string(str, &blk)
|
105
|
+
if @transpose
|
106
|
+
@blk.call(str, blk)
|
107
|
+
else
|
108
|
+
@blk.call(str)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def component(c, &blk)
|
113
|
+
if @transpose
|
114
|
+
@blk.call(c, blk)
|
115
|
+
else
|
116
|
+
@blk.call(c)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def self.traverse(tree, dlg=nil, transpose=false, &blk)
|
122
|
+
d = nil
|
123
|
+
|
124
|
+
if dlg
|
125
|
+
d = dlg
|
126
|
+
elsif blk
|
127
|
+
d = BlockTraverser.new(blk, transpose)
|
128
|
+
else
|
129
|
+
raise "Need a delegate"
|
130
|
+
end
|
131
|
+
|
132
|
+
self.new(tree, d, transpose).traverse!
|
133
|
+
end
|
134
|
+
|
135
|
+
def self.transpose(tree, dlg=nil, &blk)
|
136
|
+
traverse(tree, dlg, true, &blk)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def self.traverse(tree, dlg=nil, &blk); Traverser.traverse(tree, dlg, &blk); end
|
141
|
+
def self.transpose(tree, dlg=nil, &blk); Traverser.transpose(tree, dlg, &blk); end
|
142
|
+
|
143
|
+
def self.expanded_dom(dom)
|
144
|
+
DOMTools.transpose(dom) do |elt, update|
|
145
|
+
if elt.kind_of?(Component)
|
146
|
+
raise "Component #{elt} has no ID: was super's initialize called?" unless elt.id
|
147
|
+
Dynamic[:expander].component_used(elt)
|
148
|
+
update.call(elt.component(elt.expanded_dom))
|
149
|
+
elsif dom_node?(elt)
|
150
|
+
tag = tag(elt)
|
151
|
+
|
152
|
+
if tag == :tbody
|
153
|
+
b = body(elt)
|
154
|
+
b = b.first while b.is_a?(Array)
|
155
|
+
if b == :tbody
|
156
|
+
update.call(expanded_dom(body(elt)))
|
157
|
+
end
|
158
|
+
elsif tag == :table
|
159
|
+
a = attrs(elt)
|
160
|
+
tbody_id = (a && a[:id]) ? "#{a[:id]}_tbody" : 'tbody_' + String.random
|
161
|
+
update.call([:table, a ? a : {}, [:tbody, {:id => tbody_id}, expanded_dom(body(elt))]])
|
162
|
+
end
|
163
|
+
end
|
164
|
+
true
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def self.inject_prefixes(id, dom)
|
169
|
+
DOMTools.traverse(dom) do |elt|
|
170
|
+
if elt.kind_of?(Array) && elt.first.is_a?(Symbol) && elt[1].kind_of?(Hash)
|
171
|
+
attrs = elt[1]
|
172
|
+
elt[1] = attrs.map_to_hash do |k, v|
|
173
|
+
if v.kind_of?(String)
|
174
|
+
[k, v.prefix_ns(id)]
|
175
|
+
elsif v.kind_of?(Array) && HTMLBuilder::ARRAY_ATTRS.include?(k)
|
176
|
+
[k, v]
|
177
|
+
elsif v.kind_of?(Integer) && HTMLBuilder::INTEGER_ATTRS.include?(k)
|
178
|
+
[k, v]
|
179
|
+
else
|
180
|
+
raise "Invalid object #{v.inspect} to get a prefix in dom:\n#{dom.inspect}"
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
dom
|
186
|
+
end
|
187
|
+
|
188
|
+
def self.dom_components(tree)
|
189
|
+
c = []
|
190
|
+
DOMTools.traverse(tree) do |elt|
|
191
|
+
# dom_\w{10} is the form of a component ID; anything more liberal might catch things like
|
192
|
+
# dom_xxxxxxxxxx_table_border
|
193
|
+
if dom_node?(elt) && (attrs = attrs(elt)) && attrs.include?(:id) && attrs[:id] =~ /^dom_\w{10}$/
|
194
|
+
c << attrs[:id]
|
195
|
+
end
|
196
|
+
end
|
197
|
+
c
|
198
|
+
end
|
199
|
+
|
200
|
+
def self.find_component(tree, id)
|
201
|
+
catch(:find_component) do
|
202
|
+
DOMTools.traverse(tree) do |elt|
|
203
|
+
if dom_node?(elt) & (attrs = attrs(elt)) && attrs[:id] == id
|
204
|
+
throw :find_component, elt
|
205
|
+
end
|
206
|
+
nil
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
class ::String
|
212
|
+
def to_html(builder)
|
213
|
+
Rack::Utils.escape_html(self)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
class ::NilClass
|
218
|
+
def to_html(builder)
|
219
|
+
''
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
class ::Goat::HTMLString < String
|
224
|
+
def to_html(builder)
|
225
|
+
self
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
class ::Array
|
230
|
+
def to_html(builder)
|
231
|
+
builder.array_to_html(self)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
class InvalidBodyError < RuntimeError
|
236
|
+
attr_reader :body
|
237
|
+
|
238
|
+
def initialize(body)
|
239
|
+
super("Invalid body: #{body.inspect}")
|
240
|
+
@body = body
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
class HTMLBuilder
|
245
|
+
ARRAY_ATTRS = [:class]
|
246
|
+
INTEGER_ATTRS = [:colspan, :rowspan]
|
247
|
+
|
248
|
+
class TagBuilder
|
249
|
+
# TODO: gmail trick of only a single onclick() handler
|
250
|
+
|
251
|
+
def self.build(tag, attrs, body)
|
252
|
+
self.new(tag, attrs, body).dispatch
|
253
|
+
end
|
254
|
+
|
255
|
+
def initialize(tag, attrs, body)
|
256
|
+
@tag = tag
|
257
|
+
@attrs = attrs
|
258
|
+
@body = body
|
259
|
+
|
260
|
+
rewrite_attrs
|
261
|
+
end
|
262
|
+
|
263
|
+
def rewrite_attrs
|
264
|
+
new = {}
|
265
|
+
|
266
|
+
@attrs.map_to_hash do |k, v|
|
267
|
+
if k == :class && v.kind_of?(Array)
|
268
|
+
new[:class] = @attrs[:class].join(' ')
|
269
|
+
else
|
270
|
+
new[k] = v
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
@attrs = new
|
275
|
+
end
|
276
|
+
|
277
|
+
def build_node
|
278
|
+
[@tag, @attrs, @body]
|
279
|
+
end
|
280
|
+
|
281
|
+
def a_tag
|
282
|
+
unless @attrs.include?(:href)
|
283
|
+
@attrs[:href] = 'javascript:void(0)'
|
284
|
+
end
|
285
|
+
|
286
|
+
build_node
|
287
|
+
end
|
288
|
+
|
289
|
+
def input_tag
|
290
|
+
unless @attrs.include?(:name)
|
291
|
+
$stderr.puts "Warning: no name for <#{@tag} #{@attrs.inspect}>#{@body.inspect}</#{@tag}>"
|
292
|
+
|
293
|
+
# this is somewhat ungainly: we generate a name automatically by hashing the values of the
|
294
|
+
# other attrs. this may conflict - ideally would track per-page state for these.
|
295
|
+
# purpose is to have name-less inputs preserve their values when user goes back to page.
|
296
|
+
# webkit in safari 5 gets confused when inputs are nameless.
|
297
|
+
unless (vals = @attrs.values{|k, v| v.kind_of?(String)}).empty?
|
298
|
+
$stderr.puts "Generating a name automatically..."
|
299
|
+
@attrs[:name] = vals.join.md5[0..10]
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
build_node
|
304
|
+
end
|
305
|
+
|
306
|
+
def dispatch
|
307
|
+
meth = "#{@tag}_tag".to_sym
|
308
|
+
|
309
|
+
if self.respond_to?(meth)
|
310
|
+
self.send(meth)
|
311
|
+
else
|
312
|
+
build_node
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
def standalone_tags
|
318
|
+
%w{br img input}
|
319
|
+
end
|
320
|
+
|
321
|
+
def attrs_to_html(attrs)
|
322
|
+
attrs.map {|k, v| "#{k}=\"#{v}\""}.join(' ')
|
323
|
+
end
|
324
|
+
|
325
|
+
def array_to_html(ar)
|
326
|
+
if ar.first.kind_of?(Symbol)
|
327
|
+
tag = ar[0]
|
328
|
+
have_attrs = ar[1].kind_of?(Hash)
|
329
|
+
attrs = have_attrs ? ar[1] : {}
|
330
|
+
body = ar[(have_attrs ? 2 : 1)..-1]
|
331
|
+
|
332
|
+
tag, attrs, body = TagBuilder.build(tag, attrs, body)
|
333
|
+
lonely = standalone_tags.include?(tag.to_s)
|
334
|
+
|
335
|
+
open_tag = "<#{tag}#{attrs.empty? ? '' : (' ' + attrs_to_html(attrs))}>"
|
336
|
+
body_html = body.empty? ? '' : body.map{|x| x.to_html(self)}.join
|
337
|
+
close_tag = (lonely ? '' : "</#{tag}>")
|
338
|
+
|
339
|
+
"#{open_tag}#{body_html}#{close_tag}"
|
340
|
+
else
|
341
|
+
ar.map{|x| x.to_html(self)}.join
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
def initialize(input)
|
346
|
+
@input = input
|
347
|
+
end
|
348
|
+
|
349
|
+
def html
|
350
|
+
@input.to_html(self)
|
351
|
+
end
|
352
|
+
end
|
353
|
+
|
354
|
+
class DOMAttrs < ::Hash
|
355
|
+
def self.from_hash(hash, id=nil)
|
356
|
+
d = self[hash]
|
357
|
+
d.id = id
|
358
|
+
d.clean
|
359
|
+
d
|
360
|
+
end
|
361
|
+
|
362
|
+
def deleted
|
363
|
+
@deleted ||= {}
|
364
|
+
end
|
365
|
+
|
366
|
+
def id=(id); @id = id; end
|
367
|
+
|
368
|
+
def clean
|
369
|
+
self.each do |k, v|
|
370
|
+
if v.kind_of?(String) && v.start_with?('dom_') && (!@id || v !~ /#{@id}/)
|
371
|
+
deleted[k] = v
|
372
|
+
self.delete(k)
|
373
|
+
end
|
374
|
+
end
|
375
|
+
end
|
376
|
+
|
377
|
+
def reconstitute
|
378
|
+
(deleted.keys - self.keys).each{|k| self[k] = deleted[k]}
|
379
|
+
self
|
380
|
+
end
|
381
|
+
|
382
|
+
def inspect
|
383
|
+
start = super[0..-2]
|
384
|
+
o = []
|
385
|
+
deleted.each do |k, v|
|
386
|
+
o << "#{k.inspect}=>**#{v.inspect}**"
|
387
|
+
end
|
388
|
+
|
389
|
+
start + (o.empty? ? '' : ', ' + o.join(', ')) + '}'
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
class PartialUpdateFailed < StandardError; end
|
394
|
+
|
395
|
+
module DiffTools
|
396
|
+
class NoParentDOMID < PartialUpdateFailed; end
|
397
|
+
class TextualTranspose < PartialUpdateFailed; end
|
398
|
+
|
399
|
+
def type_of(x)
|
400
|
+
if x.is_a?(String)
|
401
|
+
:string
|
402
|
+
elsif x.is_a?(Array)
|
403
|
+
dom_node?(x) ? :node : :array
|
404
|
+
else
|
405
|
+
('cls_' + x.class.name.downcase).to_sym
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
def dom_node?(node)
|
410
|
+
# TODO take from dom.rb
|
411
|
+
node.is_a?(Array) && node.first.is_a?(Symbol)
|
412
|
+
end
|
413
|
+
|
414
|
+
def lcs_is_replacement?(d1, d2)
|
415
|
+
d1 && d2 && d1.position == d2.position && (
|
416
|
+
(d1.action == '+' && d2.action == '-') ||
|
417
|
+
(d1.action == '-' && d2.action == '+'))
|
418
|
+
end
|
419
|
+
|
420
|
+
def lcs_old_and_new(d1, d2)
|
421
|
+
d1.action == '+' ? [d2, d1] : [d1, d2]
|
422
|
+
end
|
423
|
+
|
424
|
+
def added(obj, par, pos)
|
425
|
+
raise NoParentDOMID if par.nil?
|
426
|
+
raise TextualTranspose if obj.kind_of?(String)
|
427
|
+
[:add, par, pos, *normalized_tags(obj)]
|
428
|
+
end
|
429
|
+
|
430
|
+
def removed(obj, par, pos)
|
431
|
+
raise NoParentDOMID if par.nil?
|
432
|
+
raise TextualTranspose if obj.kind_of?(String)
|
433
|
+
[:rem, par, pos, *normalized_tags(obj)]
|
434
|
+
end
|
435
|
+
|
436
|
+
def lcs_is_addition?(diff); diff.action == '+'; end
|
437
|
+
def lcs_is_removal?(diff); diff.action == '-'; end
|
438
|
+
end
|
439
|
+
|
440
|
+
|
441
|
+
class DOMDiff
|
442
|
+
include DiffTools
|
443
|
+
include DOMTools
|
444
|
+
class << self; include DOMTools; end
|
445
|
+
|
446
|
+
# the constraints:
|
447
|
+
# - arrays might be tags or they might be arrays
|
448
|
+
# - we can't directly address textual entities; they must be addressed by their enclosing tag
|
449
|
+
# (i.e. <li>foo</li> -> <li>bar</li> is a change to an li tag, not a change to the *contents*
|
450
|
+
# of an li tag. Why, you ask? Here:
|
451
|
+
# > $("#foo").innerHTML
|
452
|
+
# "<span>blub</span>foo<span>qux</span>bar"
|
453
|
+
# > $("#foo").children
|
454
|
+
# [<span>blub</span>, <span>qux</span>]
|
455
|
+
# Text isn't a thing in the DOM tree. So, in order to traverse, we have to restrict ourselves
|
456
|
+
# to DOM nodes, i.e. tags, not raw textual elements in the tree. I don't know why the DOM
|
457
|
+
# works this way.
|
458
|
+
# - we only try to deal with changes where the parent element of the changed element has a
|
459
|
+
# [unique] ID. We could address by route through the tree, but we don't attempt to store
|
460
|
+
# this.
|
461
|
+
# - we can't always tell in advance where we should make a change. For example,
|
462
|
+
# [:ul, [:li, [:a, {:id => 'hello'}, [:b, 'test']]]] changing to:
|
463
|
+
# [:ul, [:li, [:a, {:id => 'hello'}, [:b, 'blub']]]]
|
464
|
+
# When we're looking at the ul, and before we know what exactly is different below us,
|
465
|
+
# it *may* be the case that the change can be encapsulated more neatly somewhere
|
466
|
+
# in the subtree, but it may not -- and even if the change is nice and isolated further
|
467
|
+
# down, there might not be a parent ID for us to hook off. This is what NoParentDOMID
|
468
|
+
# exceptions are used for.
|
469
|
+
# - we want to ignore artefacts from randomization of IDs. (i.e. cases where nothing has
|
470
|
+
# changed except the dom_XXXXXXXX ID of some element. This is pretty easy intuitively, but a
|
471
|
+
# bit more subtle in practice. The obvious thing to do is to use an alternate comparison
|
472
|
+
# function, instead of ==, but this may be slow, and would (worse) need a lot of care to
|
473
|
+
# make sure that your code (and outside libraries, such as Diff::LCS, which we use) never
|
474
|
+
# call it. Next obvious thing is to create a subclass of Hash, make all hash attributes
|
475
|
+
# ({:id => 'dom_XXXXXXX', :class => '...'}) instances of it, and then redefine == on the
|
476
|
+
# subclass. This doesn't work, though, because ruby's crappy C implementation won't
|
477
|
+
# dispatch to the subclass's == -- for hashes, arrays, etc., the comparison code is
|
478
|
+
# wired in and can't be overridden. So instead, we have our subclass actually remove
|
479
|
+
# the unwanted elements, and store them in a separate @deleted ivar. The crappy part of
|
480
|
+
# this is that you need to manually go through the dom and stick them all back in the
|
481
|
+
# "actual" hash once you've finished diffing.
|
482
|
+
# - we *don't* want to remove IDs that don't change. For example, during a rerender, we
|
483
|
+
# preserve the ID of the rerendered-component (just not the IDs of any child components).
|
484
|
+
# If we deleted the IDs of these, constraint #3 would mean we'd miss a lot of potential
|
485
|
+
# diffs, since we'd think there were changes we couldn't make. (I.e. think a child's
|
486
|
+
# parent doesn't have an ID when in fact it does.) One todo here is to make the differ
|
487
|
+
# check the @deleted ivar of a DOMAttrs instance for IDs, since they're perfectly
|
488
|
+
# valid hooks to use when removing or adding components. If you do this, conflicts become
|
489
|
+
# much more likely though -- say you have multiple changes to apply; a node whose ID
|
490
|
+
# you depend on may be removed.
|
491
|
+
|
492
|
+
def self.diff(old, new)
|
493
|
+
d = self.new(old, new).diff
|
494
|
+
end
|
495
|
+
|
496
|
+
def self.preproc(tree, id)
|
497
|
+
DOMTools.transpose(tree) do |elt, update|
|
498
|
+
if dom_node?(elt) && attrs(elt)
|
499
|
+
update.call([tag(elt), DOMAttrs.from_hash(attrs(elt), id), *normalized_tags(body(elt))])
|
500
|
+
end
|
501
|
+
true
|
502
|
+
end
|
503
|
+
end
|
504
|
+
|
505
|
+
def self.unproc(tree)
|
506
|
+
tree.flatten.each{|elt| elt.reconstitute if elt.is_a?(DOMAttrs)}
|
507
|
+
tree
|
508
|
+
end
|
509
|
+
|
510
|
+
def self.dom_diff(old, new, id)
|
511
|
+
trees = []
|
512
|
+
[old, new].each {|tree| trees << preproc(tree, id)}
|
513
|
+
diff(*trees).map do |diff|
|
514
|
+
if diff.first == :add
|
515
|
+
diff[0..-2] + [unproc(diff[3])]
|
516
|
+
else
|
517
|
+
diff
|
518
|
+
end
|
519
|
+
end
|
520
|
+
end
|
521
|
+
|
522
|
+
def initialize(old, new)
|
523
|
+
@old = old
|
524
|
+
@new = new
|
525
|
+
end
|
526
|
+
|
527
|
+
def diff
|
528
|
+
cmp(@old, @new, nil)
|
529
|
+
end
|
530
|
+
|
531
|
+
def transpose(old, new, par)
|
532
|
+
[removed(old, par, 0), added(new, par, 0)]
|
533
|
+
end
|
534
|
+
|
535
|
+
def cmp_node(old, new, par)
|
536
|
+
oattrs = attrs(old)
|
537
|
+
if tag(old) == tag(new) && \
|
538
|
+
oattrs == attrs(new)
|
539
|
+
begin
|
540
|
+
cmp(body(old), body(new), oattrs ? domid(old) : nil)
|
541
|
+
rescue NoParentDOMID => e
|
542
|
+
transpose(old, new, par)
|
543
|
+
rescue TextualTranspose => e
|
544
|
+
transpose(old, new, par)
|
545
|
+
end
|
546
|
+
else
|
547
|
+
transpose(old, new, par)
|
548
|
+
end
|
549
|
+
end
|
550
|
+
|
551
|
+
def cmp_array(old, new, par)
|
552
|
+
if old.size == 1 && new.size == 1
|
553
|
+
return cmp(old.first, new.first, par)
|
554
|
+
end
|
555
|
+
|
556
|
+
patch = Diff::LCS.diff(old, new).flatten(1)
|
557
|
+
|
558
|
+
left, right = patch
|
559
|
+
if patch.size == 2 && lcs_is_replacement?(left, right)
|
560
|
+
# if we're just replacing an element, maybe we can do it more elegantly. e.g.,
|
561
|
+
# [:p, [:li, 'foo'], [:li, 'bar']] -> [:p, [:li, 'foo'], [:li, 'bar'], [:li, 'baz']]
|
562
|
+
# can be represented as a replacement of the p tag itself, or the insertion of
|
563
|
+
# [:li, 'baz']. If the patch looks like a simple replacement, we delve deeper.
|
564
|
+
dold, dnew = lcs_old_and_new(left, right)
|
565
|
+
old, new = dold.element, dnew.element
|
566
|
+
begin
|
567
|
+
return cmp(old, new, domid(old))
|
568
|
+
rescue NoParentDOMID, TextualTranspose
|
569
|
+
# fine fine if you hate fun we can just continue on our merry way below
|
570
|
+
end
|
571
|
+
end
|
572
|
+
|
573
|
+
a, b, off = 0, 0, 0
|
574
|
+
chgs = patch.map do |ch|
|
575
|
+
if lcs_is_addition?(ch)
|
576
|
+
while b < ch.position
|
577
|
+
a += 1; b += 1; off += 1
|
578
|
+
end
|
579
|
+
b += 1
|
580
|
+
|
581
|
+
mut = [added(ch.element, par, off)]
|
582
|
+
off += 1
|
583
|
+
mut
|
584
|
+
elsif lcs_is_removal?(ch)
|
585
|
+
while a < ch.position
|
586
|
+
a += 1; b += 1; off += 1
|
587
|
+
end
|
588
|
+
a += 1
|
589
|
+
|
590
|
+
[removed(ch.element, par, off)]
|
591
|
+
else
|
592
|
+
raise "Bad diff: #{ch.inspect}"
|
593
|
+
end
|
594
|
+
end
|
595
|
+
|
596
|
+
chgs.flatten(1)
|
597
|
+
end
|
598
|
+
|
599
|
+
def cmp(old, new, par)
|
600
|
+
return [] if old == new
|
601
|
+
|
602
|
+
told, tnew = type_of(old), type_of(new)
|
603
|
+
if told != tnew
|
604
|
+
if told == :string || tnew == :string
|
605
|
+
raise TextualTranspose
|
606
|
+
else
|
607
|
+
return transpose(old, new, par)
|
608
|
+
end
|
609
|
+
end
|
610
|
+
|
611
|
+
if told == :string || tnew == :string && old != new
|
612
|
+
raise TextualTranspose
|
613
|
+
end
|
614
|
+
|
615
|
+
type = told
|
616
|
+
|
617
|
+
if type == :node
|
618
|
+
cmp_node(old, new, par)
|
619
|
+
elsif type == :array
|
620
|
+
cmp_array(old, new, par)
|
621
|
+
else
|
622
|
+
raise "Unknown thing: #{old.inspect}"
|
623
|
+
end
|
624
|
+
end
|
625
|
+
end
|
626
|
+
end
|
627
|
+
end
|