dommy 0.10.0 → 0.12.0
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.
- checksums.yaml +4 -4
- data/lib/dommy/attr.rb +4 -5
- data/lib/dommy/backend/makiri_adapter.rb +24 -14
- data/lib/dommy/backend.rb +18 -5
- data/lib/dommy/browser.rb +8 -1
- data/lib/dommy/callable_invoker.rb +13 -5
- data/lib/dommy/css.rb +33 -10
- data/lib/dommy/custom_elements.rb +62 -16
- data/lib/dommy/document.rb +1096 -223
- data/lib/dommy/dom_parser.rb +12 -1
- data/lib/dommy/element.rb +513 -293
- data/lib/dommy/event.rb +462 -111
- data/lib/dommy/html_collection.rb +7 -4
- data/lib/dommy/html_elements.rb +763 -182
- data/lib/dommy/interaction/driver.rb +23 -1
- data/lib/dommy/interaction/event_synthesis.rb +4 -7
- data/lib/dommy/interaction/field_interactor.rb +13 -7
- data/lib/dommy/internal/accessible_name.rb +45 -8
- data/lib/dommy/internal/aria_role.rb +5 -3
- data/lib/dommy/internal/child_node.rb +151 -41
- data/lib/dommy/internal/css/cascade.rb +3 -1
- data/lib/dommy/internal/css/parser.rb +97 -14
- data/lib/dommy/internal/css/rule_index.rb +288 -18
- data/lib/dommy/internal/css_priority.rb +33 -0
- data/lib/dommy/internal/dom_matching.rb +1 -1
- data/lib/dommy/internal/leaf_node.rb +45 -0
- data/lib/dommy/internal/mutation_coordinator.rb +180 -46
- data/lib/dommy/internal/node_adopter.rb +190 -0
- data/lib/dommy/internal/node_traversal.rb +10 -0
- data/lib/dommy/internal/node_wrapper_cache.rb +76 -13
- data/lib/dommy/internal/observer_manager.rb +18 -0
- data/lib/dommy/internal/observer_matcher.rb +37 -4
- data/lib/dommy/internal/parent_node.rb +284 -45
- data/lib/dommy/internal/range_text_serializer.rb +7 -5
- data/lib/dommy/internal/selector_ast.rb +17 -0
- data/lib/dommy/internal/selector_index.rb +24 -8
- data/lib/dommy/internal/selector_matcher.rb +139 -24
- data/lib/dommy/internal/selector_parser.rb +104 -32
- data/lib/dommy/internal/shadow_root_registry.rb +12 -1
- data/lib/dommy/internal/template_content_registry.rb +23 -7
- data/lib/dommy/internal/utf16.rb +45 -0
- data/lib/dommy/internal/web_idl.rb +57 -0
- data/lib/dommy/internal/xml_serialization.rb +18 -5
- data/lib/dommy/js/custom_element_bridge.rb +10 -0
- data/lib/dommy/js/dom_interfaces.rb +82 -9
- data/lib/dommy/js/handle_table.rb +15 -0
- data/lib/dommy/js/host_bridge.rb +79 -15
- data/lib/dommy/js/host_runtime.js +607 -57
- data/lib/dommy/js/marshaller.rb +13 -7
- data/lib/dommy/js/script_boot.rb +30 -29
- data/lib/dommy/mutation_observer.rb +109 -19
- data/lib/dommy/node.rb +46 -3
- data/lib/dommy/parser.rb +14 -0
- data/lib/dommy/promise.rb +4 -4
- data/lib/dommy/range.rb +448 -219
- data/lib/dommy/selection.rb +422 -0
- data/lib/dommy/shadow_root.rb +40 -20
- data/lib/dommy/static_range.rb +65 -0
- data/lib/dommy/svg_elements.rb +45 -0
- data/lib/dommy/tree_walker.rb +16 -20
- data/lib/dommy/url_pattern/canonicalizer.rb +183 -0
- data/lib/dommy/url_pattern/component.rb +64 -0
- data/lib/dommy/url_pattern/constructor_string_parser.rb +281 -0
- data/lib/dommy/url_pattern/init_processor.rb +163 -0
- data/lib/dommy/url_pattern/pattern_parser.rb +343 -0
- data/lib/dommy/url_pattern/regexp_translator.rb +694 -0
- data/lib/dommy/url_pattern/tokenizer.rb +199 -0
- data/lib/dommy/url_pattern.rb +310 -103
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +76 -5
- data/lib/dommy.rb +7 -1
- metadata +18 -4
data/lib/dommy/document.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "internal/mutation_coordinator"
|
|
|
7
7
|
require_relative "internal/shadow_root_registry"
|
|
8
8
|
require_relative "internal/cookie_jar"
|
|
9
9
|
require_relative "internal/node_traversal"
|
|
10
|
+
require_relative "internal/node_adopter"
|
|
10
11
|
require_relative "internal/observer_manager"
|
|
11
12
|
require_relative "internal/template_content_registry"
|
|
12
13
|
|
|
@@ -25,6 +26,12 @@ module Dommy
|
|
|
25
26
|
# so it stays tree-DISCONNECTED per its detached nature.
|
|
26
27
|
class DocumentType
|
|
27
28
|
include Node
|
|
29
|
+
include Internal::LeafNode
|
|
30
|
+
# A node-backed doctype is an ordinary ChildNode of the document, so
|
|
31
|
+
# `before` / `after` / `replaceWith` go through the shared implementation —
|
|
32
|
+
# which runs the parent's ensure-pre-insertion-validity and the live-range
|
|
33
|
+
# insert steps, neither of which the old doctype-specific path did.
|
|
34
|
+
include Internal::ChildNode
|
|
28
35
|
|
|
29
36
|
# Mixed into a node-backed doctype only, so a synthetic one does NOT respond
|
|
30
37
|
# to `__dommy_backend_node__` — leaving the Node mixin's guards (which key off
|
|
@@ -59,6 +66,14 @@ module Dommy
|
|
|
59
66
|
@document || @owner_document
|
|
60
67
|
end
|
|
61
68
|
|
|
69
|
+
# A doctype answers `document` from either of two ivars (a synthetic one has
|
|
70
|
+
# only `@owner_document`), so an adopt has to move both.
|
|
71
|
+
def __internal_reseat__(backend_node, document)
|
|
72
|
+
super
|
|
73
|
+
@owner_document = document
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
62
77
|
def name
|
|
63
78
|
@__node__ ? @__node__.name : @name
|
|
64
79
|
end
|
|
@@ -93,26 +108,44 @@ module Dommy
|
|
|
93
108
|
end
|
|
94
109
|
|
|
95
110
|
def before(*nodes)
|
|
96
|
-
return
|
|
111
|
+
return synthetic_insert(nodes, after: false) unless @__node__
|
|
97
112
|
|
|
98
|
-
|
|
99
|
-
nil
|
|
113
|
+
child_node_before(nodes)
|
|
100
114
|
end
|
|
101
115
|
|
|
102
116
|
def after(*nodes)
|
|
103
|
-
return
|
|
117
|
+
return synthetic_insert(nodes, after: true) unless @__node__
|
|
104
118
|
|
|
105
|
-
|
|
106
|
-
nil
|
|
119
|
+
child_node_after(nodes)
|
|
107
120
|
end
|
|
108
121
|
|
|
109
122
|
def replace_with(*nodes)
|
|
123
|
+
unless @__node__
|
|
124
|
+
synthetic_insert(nodes, after: false)
|
|
125
|
+
remove
|
|
126
|
+
return nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
child_node_replace_with(nodes)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A synthetic doctype — the fallback for a backend that cannot create a
|
|
133
|
+
# doctype node — is not in the tree at all, so WHATWG's ChildNode methods
|
|
134
|
+
# would return at step 2. Dommy has always inserted around the document
|
|
135
|
+
# element instead; that stays until the fallback goes away.
|
|
136
|
+
def synthetic_insert(nodes, after:)
|
|
110
137
|
return nil unless @owner_document
|
|
111
138
|
|
|
112
|
-
@owner_document.__internal_insert_at_doctype__(nodes, after:
|
|
113
|
-
remove
|
|
139
|
+
@owner_document.__internal_insert_at_doctype__(nodes, after: after)
|
|
114
140
|
nil
|
|
115
141
|
end
|
|
142
|
+
private :synthetic_insert
|
|
143
|
+
|
|
144
|
+
# A doctype is connected when its tree is a document's; a synthetic one is
|
|
145
|
+
# never in a tree at all.
|
|
146
|
+
def is_connected?
|
|
147
|
+
get_root_node.is_a?(Dommy::Document)
|
|
148
|
+
end
|
|
116
149
|
|
|
117
150
|
def __js_get__(key)
|
|
118
151
|
case key
|
|
@@ -141,6 +174,8 @@ module Dommy
|
|
|
141
174
|
NodeList.new
|
|
142
175
|
when "firstChild", "lastChild"
|
|
143
176
|
nil
|
|
177
|
+
when "isConnected"
|
|
178
|
+
is_connected?
|
|
144
179
|
end
|
|
145
180
|
end
|
|
146
181
|
|
|
@@ -161,12 +196,25 @@ module Dommy
|
|
|
161
196
|
rescue StandardError
|
|
162
197
|
nil
|
|
163
198
|
end
|
|
164
|
-
|
|
199
|
+
if node
|
|
200
|
+
clone = DocumentType.new(backend_node: node, document: @document)
|
|
201
|
+
# Register the wrapper against its backend node, so inserting the clone
|
|
202
|
+
# into a tree and reading it back returns THIS object rather than a
|
|
203
|
+
# freshly built one (`doc.replaceChildren(dt); doc.firstChild === dt`).
|
|
204
|
+
@document.__internal_register_wrapper__(node, clone)
|
|
205
|
+
return clone
|
|
206
|
+
end
|
|
165
207
|
end
|
|
166
208
|
DocumentType.new(name, public_id, system_id, owner_document: @owner_document)
|
|
167
209
|
end
|
|
168
210
|
|
|
169
211
|
include Bridge::Methods
|
|
212
|
+
# WHATWG's own wording for a doctype parent.
|
|
213
|
+
def leaf_insertion_message
|
|
214
|
+
"a DocumentType may not have children"
|
|
215
|
+
end
|
|
216
|
+
private :leaf_insertion_message
|
|
217
|
+
|
|
170
218
|
js_methods %w[isEqualNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition contains
|
|
171
219
|
cloneNode appendChild insertBefore removeChild replaceChild before after replaceWith remove
|
|
172
220
|
lookupNamespaceURI lookupPrefix isDefaultNamespace
|
|
@@ -194,16 +242,14 @@ module Dommy
|
|
|
194
242
|
get_root_node(args[0])
|
|
195
243
|
when "compareDocumentPosition"
|
|
196
244
|
compare_document_position(args[0])
|
|
197
|
-
when "appendChild"
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
245
|
+
when "appendChild"
|
|
246
|
+
append_child(args[0])
|
|
247
|
+
when "insertBefore"
|
|
248
|
+
insert_before(args[0], args[1])
|
|
249
|
+
when "replaceChild"
|
|
250
|
+
replace_child(args[0], args[1])
|
|
203
251
|
when "removeChild"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
|
|
252
|
+
remove_child(args[0])
|
|
207
253
|
when "before"
|
|
208
254
|
before(*args)
|
|
209
255
|
when "after"
|
|
@@ -231,15 +277,23 @@ module Dommy
|
|
|
231
277
|
@document = document
|
|
232
278
|
end
|
|
233
279
|
|
|
280
|
+
# A name createDocumentType refuses. It is otherwise extremely permissive —
|
|
281
|
+
# "1foo", "@foo", "edi:%" and the empty string are all accepted — but a name
|
|
282
|
+
# carrying whitespace or ">" could not be serialized back as a doctype, and
|
|
283
|
+
# is an InvalidCharacterError.
|
|
284
|
+
UNSERIALIZABLE_DOCTYPE_NAME = /[\s>]/
|
|
285
|
+
|
|
234
286
|
# A created DocumentType's node document is the implementation's document. When
|
|
235
287
|
# the backend ships a doctype factory (the HTML backend) and accepts the name,
|
|
236
288
|
# the result is a real, node-backed (but detached) DocumentType that can join
|
|
237
|
-
# the tree; otherwise it falls back to a synthetic one
|
|
238
|
-
#
|
|
239
|
-
# stricter name check is bypassed via the synthetic fallback rather than
|
|
240
|
-
# raising; a couple of invalid-name WPT cases stay documented gaps.)
|
|
289
|
+
# the tree; otherwise it falls back to a synthetic one — the factory's own
|
|
290
|
+
# (stricter, XML-flavoured) name check is not the DOM rule.
|
|
241
291
|
def create_document_type(qualified_name, public_id, system_id)
|
|
242
292
|
qn = qualified_name.to_s
|
|
293
|
+
if qn.match?(UNSERIALIZABLE_DOCTYPE_NAME)
|
|
294
|
+
raise DOMException::InvalidCharacterError, "invalid doctype name: #{qn.inspect}"
|
|
295
|
+
end
|
|
296
|
+
|
|
243
297
|
pub = public_id.to_s
|
|
244
298
|
sys = system_id.to_s
|
|
245
299
|
node =
|
|
@@ -248,7 +302,16 @@ module Dommy
|
|
|
248
302
|
rescue ArgumentError
|
|
249
303
|
nil
|
|
250
304
|
end
|
|
251
|
-
|
|
305
|
+
return DocumentType.new(qn, pub, sys, owner_document: @document) unless node
|
|
306
|
+
|
|
307
|
+
# Seed the wrapper cache, or the doctype reached later through the tree
|
|
308
|
+
# (document.childNodes, document.doctype, a NodeIterator) would be a
|
|
309
|
+
# DIFFERENT DocumentType object than the one createDocumentType returned,
|
|
310
|
+
# and `===` / `isSameNode` on the two would be false. Every other
|
|
311
|
+
# create* goes through the cache; this one built its wrapper directly.
|
|
312
|
+
wrapper = DocumentType.new(backend_node: node, document: @document)
|
|
313
|
+
@document.__internal_register_wrapper__(node, wrapper)
|
|
314
|
+
wrapper
|
|
252
315
|
end
|
|
253
316
|
|
|
254
317
|
# `hasFeature()` is a no-op that always returns true (DOM Standard).
|
|
@@ -302,6 +365,7 @@ module Dommy
|
|
|
302
365
|
return unless node
|
|
303
366
|
|
|
304
367
|
root = doc.backend_doc.root
|
|
368
|
+
doc.__internal_ranges_will_insert__(doc.backend_doc, root, 1)
|
|
305
369
|
root ? root.add_previous_sibling(node) : doc.backend_doc.add_child(node)
|
|
306
370
|
end
|
|
307
371
|
|
|
@@ -349,24 +413,158 @@ module Dommy
|
|
|
349
413
|
attr_reader :backend_doc
|
|
350
414
|
attr_accessor :default_view
|
|
351
415
|
# --- CSS cascade support (Internal::CSS) ---
|
|
352
|
-
#
|
|
353
|
-
#
|
|
354
|
-
#
|
|
416
|
+
# Two invalidation epochs, split so a style-neutral mutation doesn't pay
|
|
417
|
+
# for a cascade rebuild (RuleIndex.build is a full rules x document query):
|
|
418
|
+
#
|
|
419
|
+
# `dom_generation` keys the selector-result caches (query caches, selector
|
|
420
|
+
# index). It moves on anything that can change what a selector matches:
|
|
421
|
+
# tree shape, attributes, focus/hover, checkedness, and text edits that
|
|
422
|
+
# flip a node between empty and non-empty (`:empty` is the matcher's only
|
|
423
|
+
# text-sensitive pseudo-class).
|
|
424
|
+
#
|
|
425
|
+
# `style_generation` keys the cascade caches (RuleIndex, computed styles,
|
|
426
|
+
# counters — the slot itself is owned by Internal::CSS::Cascade). It moves
|
|
427
|
+
# on the same events, EXCEPT that an attribute change only moves it when
|
|
428
|
+
# the built RuleIndex's selectors depend on that attribute, and a text
|
|
429
|
+
# edit only when it's inside a <style> or flips emptiness while a sheet
|
|
430
|
+
# uses `:empty`. The coordinator reports mutations through the
|
|
431
|
+
# `__internal_note_*` seams below, which decide what to bump.
|
|
355
432
|
attr_accessor :__css_style_cache__
|
|
356
433
|
|
|
357
434
|
def style_generation
|
|
358
435
|
@style_generation || 0
|
|
359
436
|
end
|
|
360
437
|
|
|
438
|
+
def dom_generation
|
|
439
|
+
@dom_generation || 0
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Moves only on childList mutations — the coarsest epoch. Keys memos
|
|
443
|
+
# whose value depends on the element population alone (which elements
|
|
444
|
+
# exist, in what order), like the document's <style>/<link> list: an
|
|
445
|
+
# attribute-triggered cascade rebuild can then skip re-walking for them.
|
|
446
|
+
def tree_generation
|
|
447
|
+
@tree_generation || 0
|
|
448
|
+
end
|
|
449
|
+
|
|
361
450
|
def __internal_bump_style_generation__
|
|
362
451
|
@style_generation = style_generation + 1
|
|
363
452
|
nil
|
|
364
453
|
end
|
|
365
454
|
|
|
455
|
+
def __internal_bump_dom_generation__
|
|
456
|
+
@dom_generation = dom_generation + 1
|
|
457
|
+
nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# A childList mutation: tree shape feeds both selector matching and the
|
|
461
|
+
# rule -> element index, so everything is suspect.
|
|
462
|
+
def __internal_note_tree_mutation__
|
|
463
|
+
@tree_generation = tree_generation + 1
|
|
464
|
+
__internal_bump_dom_generation__
|
|
465
|
+
__internal_bump_style_generation__
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# The document's <style> and <link> elements in document order (their
|
|
469
|
+
# relative order breaks cascade ties), memoized per tree_generation:
|
|
470
|
+
# only a childList mutation can change the list, so the cascade's
|
|
471
|
+
# attribute-triggered rebuilds reuse it without a document walk.
|
|
472
|
+
def __internal_style_sheet_elements__
|
|
473
|
+
if @__sheet_elements_gen != tree_generation
|
|
474
|
+
@__sheet_elements_gen = tree_generation
|
|
475
|
+
@__sheet_elements = query_selector_all("style, link").to_a
|
|
476
|
+
end
|
|
477
|
+
@__sheet_elements
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# An attribute mutation: selector results are always suspect (any cached
|
|
481
|
+
# query could carry an attribute selector), but the cascade only when the
|
|
482
|
+
# indexed rules read this attribute — or when the attribute belongs to a
|
|
483
|
+
# <style>/<link>, whose media/disabled/rel gate whole sheets.
|
|
484
|
+
def __internal_note_attribute_mutation__(name, target_node)
|
|
485
|
+
__internal_bump_dom_generation__
|
|
486
|
+
__internal_bump_style_generation__ if __internal_style_affected_by_attribute__(name, target_node)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# A characterData mutation. Text participates in matching only through
|
|
490
|
+
# `:empty` (a text node counts iff its data is non-empty), so nothing is
|
|
491
|
+
# suspect unless the edit flips that emptiness — except text inside a
|
|
492
|
+
# <style>, which IS the stylesheet source.
|
|
493
|
+
def __internal_note_character_data_mutation__(target_node, old_value)
|
|
494
|
+
# Only a Text node's data participates in :empty; a comment/PI edit
|
|
495
|
+
# can't change any match. `target_node` is a backend node, so its data
|
|
496
|
+
# reads through the Nokogiri-compatible #content.
|
|
497
|
+
text = target_node.respond_to?(:node_type) && target_node.node_type == 3
|
|
498
|
+
flipped = text && (old_value.to_s.empty? != target_node.content.to_s.empty?)
|
|
499
|
+
__internal_bump_dom_generation__ if flipped
|
|
500
|
+
if __internal_inside_style_element__(target_node) ||
|
|
501
|
+
(flipped && __internal_style_text_sensitive__)
|
|
502
|
+
__internal_bump_style_generation__
|
|
503
|
+
end
|
|
504
|
+
nil
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Selector-observable state that lives outside the attribute space
|
|
508
|
+
# (focus, hover, checkedness…): both cache families are suspect.
|
|
509
|
+
def __internal_note_selector_state_change__
|
|
510
|
+
__internal_bump_dom_generation__
|
|
511
|
+
__internal_bump_style_generation__
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# A form control's IDL value changed (typing, `input.value = …`, a form
|
|
515
|
+
# reset). No attribute mutates, yet the value is selector-observable
|
|
516
|
+
# through the validity / range / placeholder pseudo-classes, so the
|
|
517
|
+
# selector epoch always moves — a cached `querySelectorAll(":invalid")`
|
|
518
|
+
# would otherwise survive the very change that flipped it. The cascade
|
|
519
|
+
# follows only when a sheet actually uses one of those pseudo-classes.
|
|
520
|
+
def __internal_note_value_change__
|
|
521
|
+
__internal_bump_dom_generation__
|
|
522
|
+
__internal_bump_style_generation__ if __internal_style_value_sensitive__
|
|
523
|
+
nil
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def __internal_style_value_sensitive__
|
|
527
|
+
index = @__css_style_cache__ && @__css_style_cache__[:index]
|
|
528
|
+
index ? index.value_sensitive? : true
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def __internal_style_affected_by_attribute__(name, target_node)
|
|
532
|
+
owner = target_node.respond_to?(:name) ? target_node.name.to_s.downcase : nil
|
|
533
|
+
return true if owner == "style" || owner == "link"
|
|
534
|
+
|
|
535
|
+
index = @__css_style_cache__ && @__css_style_cache__[:index]
|
|
536
|
+
# No RuleIndex yet: the bump is nearly free (at most it drops the
|
|
537
|
+
# author_css?/counters memos), so stay conservative.
|
|
538
|
+
return true unless index
|
|
539
|
+
|
|
540
|
+
index.attribute_dependency?(name)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def __internal_style_text_sensitive__
|
|
544
|
+
index = @__css_style_cache__ && @__css_style_cache__[:index]
|
|
545
|
+
index ? index.text_sensitive? : true
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def __internal_inside_style_element__(node)
|
|
549
|
+
# No <style> in the document -> a text edit can't be sheet source, so
|
|
550
|
+
# skip the ancestor walk. The sheet-element list is memoized per
|
|
551
|
+
# tree_generation (only childList changes it), so a text-editing loop
|
|
552
|
+
# between childList mutations answers this without re-walking.
|
|
553
|
+
return false unless __internal_style_sheet_elements__.any? { |el| el.local_name.to_s.casecmp?("style") }
|
|
554
|
+
|
|
555
|
+
current = node.respond_to?(:parent) ? node.parent : nil
|
|
556
|
+
while current
|
|
557
|
+
return true if current.respond_to?(:name) && current.name.to_s.downcase == "style"
|
|
558
|
+
|
|
559
|
+
current = current.respond_to?(:parent) ? current.parent : nil
|
|
560
|
+
end
|
|
561
|
+
false
|
|
562
|
+
end
|
|
563
|
+
|
|
366
564
|
# A by-id/class/tag index of the backend element tree, memoized per DOM
|
|
367
565
|
# generation, for SelectorMatcher's document-scoped fast path (or nil to tell
|
|
368
566
|
# the caller to walk). Rebuilt lazily only after a mutation bumps
|
|
369
|
-
#
|
|
567
|
+
# dom_generation, so it costs one tree walk per generation and pays off when
|
|
370
568
|
# several queries run before the next mutation.
|
|
371
569
|
#
|
|
372
570
|
# Adaptive bypass: if the index keeps getting invalidated after serving only a
|
|
@@ -379,7 +577,7 @@ module Dommy
|
|
|
379
577
|
SELECTOR_INDEX_RETEST_GAP = 64 # generations to wait before re-testing a bypass
|
|
380
578
|
|
|
381
579
|
def __internal_selector_index__
|
|
382
|
-
gen =
|
|
580
|
+
gen = dom_generation
|
|
383
581
|
if @__sel_idx_gen != gen
|
|
384
582
|
if @__sel_idx
|
|
385
583
|
if @__sel_idx_served.to_i < SELECTOR_INDEX_MIN_REUSE
|
|
@@ -410,18 +608,18 @@ module Dommy
|
|
|
410
608
|
# (element, selector) constantly between mutations; this memoizes the match
|
|
411
609
|
# set, keyed by [scope object_id, kind, selector] and tagged with the DOM
|
|
412
610
|
# generation, so a hit skips the whole combinator match. Capped, and a
|
|
413
|
-
# mutation (
|
|
611
|
+
# mutation (dom_generation bump) makes every entry stale at once.
|
|
414
612
|
SCOPED_QUERY_CACHE_CAP = 4096
|
|
415
613
|
|
|
416
614
|
def __internal_scoped_query_get(key)
|
|
417
615
|
entry = (@__scoped_query_cache ||= {})[key]
|
|
418
|
-
entry && entry[0] ==
|
|
616
|
+
entry && entry[0] == dom_generation ? entry[1] : nil
|
|
419
617
|
end
|
|
420
618
|
|
|
421
619
|
def __internal_scoped_query_set(key, value)
|
|
422
620
|
cache = (@__scoped_query_cache ||= {})
|
|
423
621
|
cache.clear if cache.size >= SCOPED_QUERY_CACHE_CAP
|
|
424
|
-
cache[key] = [
|
|
622
|
+
cache[key] = [dom_generation, value]
|
|
425
623
|
value
|
|
426
624
|
end
|
|
427
625
|
|
|
@@ -447,6 +645,17 @@ module Dommy
|
|
|
447
645
|
# owns firing the element's load / error event. nil = such scripts are inert.
|
|
448
646
|
attr_accessor :external_script_runner
|
|
449
647
|
|
|
648
|
+
# Installed by the browser when a JS runtime is present: re-runs the
|
|
649
|
+
# inline-handler scan so an `on*` attribute that arrived after boot (a cloned
|
|
650
|
+
# template, an innerHTML fragment) is compiled. nil without a runtime, which
|
|
651
|
+
# is also the fast path that keeps a JS-free document out of this entirely.
|
|
652
|
+
attr_accessor :inline_handler_wirer
|
|
653
|
+
|
|
654
|
+
def __internal_wire_inline_handlers__
|
|
655
|
+
@inline_handler_wirer&.call
|
|
656
|
+
nil
|
|
657
|
+
end
|
|
658
|
+
|
|
450
659
|
def initialize(host = nil, backend_doc: nil, default_view: nil)
|
|
451
660
|
@host = host
|
|
452
661
|
@default_view = default_view
|
|
@@ -456,7 +665,12 @@ module Dommy
|
|
|
456
665
|
@cookie_jar = Internal::CookieJar.new
|
|
457
666
|
@template_content_registry = Internal::TemplateContentRegistry.new(self)
|
|
458
667
|
@mutation_coordinator = Internal::MutationCoordinator.new(self, @observer_manager)
|
|
459
|
-
@
|
|
668
|
+
# Weak, like @live_ranges: a NodeIterator is consulted by every removal for
|
|
669
|
+
# as long as its document holds it, and detach() is a no-op by definition,
|
|
670
|
+
# so a strong list would mean every iterator ever created keeps costing
|
|
671
|
+
# work — and keeps its referenceNode's whole detached subtree alive —
|
|
672
|
+
# forever. A browser collects an unreachable one and stops consulting it.
|
|
673
|
+
@node_iterators = ObjectSpace::WeakMap.new
|
|
460
674
|
@backend_doc = backend_doc || Backend.parse("<!doctype html><html><head></head><body></body></html>")
|
|
461
675
|
@content_type = "text/html"
|
|
462
676
|
# The document is fully parsed before scripts run (no incremental network
|
|
@@ -504,9 +718,24 @@ module Dommy
|
|
|
504
718
|
write_title(value.to_s)
|
|
505
719
|
end
|
|
506
720
|
|
|
721
|
+
# A document is its own shadow-including root, so it is always connected
|
|
722
|
+
# (Element#is_connected? walks up to a document; the document itself is the
|
|
723
|
+
# base case).
|
|
724
|
+
def is_connected?
|
|
725
|
+
true
|
|
726
|
+
end
|
|
727
|
+
|
|
507
728
|
def document_element
|
|
508
729
|
# The document's root element — `<html>` for HTML, the actual root for XML.
|
|
509
|
-
|
|
730
|
+
# It is the document's first ELEMENT child, so a document that has none
|
|
731
|
+
# answers null: the backend's `root` falls back to the doctype once the
|
|
732
|
+
# element is gone (`document.removeChild(documentElement)`, or a
|
|
733
|
+
# replaceChild that swaps it for a comment), and head / body / title
|
|
734
|
+
# resolve through this.
|
|
735
|
+
root = @backend_doc.root
|
|
736
|
+
return nil unless root&.element?
|
|
737
|
+
|
|
738
|
+
wrap_node(root)
|
|
510
739
|
end
|
|
511
740
|
|
|
512
741
|
def head
|
|
@@ -691,8 +920,8 @@ module Dommy
|
|
|
691
920
|
|
|
692
921
|
def __internal_set_active_element__(el)
|
|
693
922
|
# Focus is selector-observable state (:focus / :focus-within rules), so
|
|
694
|
-
# a change invalidates computed styles.
|
|
695
|
-
|
|
923
|
+
# a change invalidates cached query results and computed styles.
|
|
924
|
+
__internal_note_selector_state_change__ unless @active_element.equal?(el)
|
|
696
925
|
@active_element = el
|
|
697
926
|
end
|
|
698
927
|
|
|
@@ -713,7 +942,7 @@ module Dommy
|
|
|
713
942
|
return if @hovered_element.equal?(el)
|
|
714
943
|
|
|
715
944
|
@hovered_element = el
|
|
716
|
-
|
|
945
|
+
__internal_note_selector_state_change__
|
|
717
946
|
nil
|
|
718
947
|
end
|
|
719
948
|
|
|
@@ -733,15 +962,7 @@ module Dommy
|
|
|
733
962
|
# Ruby Proc, a JS-bridge callable, or an object with
|
|
734
963
|
# `accept_node` / `acceptNode`.
|
|
735
964
|
def create_tree_walker(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
|
|
736
|
-
TreeWalker.new(
|
|
737
|
-
end
|
|
738
|
-
|
|
739
|
-
# The `root` of a TreeWalker / NodeIterator is a non-nullable WebIDL `Node`:
|
|
740
|
-
# a null or non-Node argument is a TypeError before construction.
|
|
741
|
-
def require_node_root(root)
|
|
742
|
-
return root if root.is_a?(Dommy::Node)
|
|
743
|
-
|
|
744
|
-
raise Bridge::TypeError, "createTreeWalker/createNodeIterator root must be a Node"
|
|
965
|
+
TreeWalker.new(Internal::WebIDL.node!(root), what_to_show, filter)
|
|
745
966
|
end
|
|
746
967
|
|
|
747
968
|
# WebIDL `unsigned long whatToShow = 0xFFFFFFFF`: an omitted or `undefined`
|
|
@@ -767,97 +988,66 @@ module Dommy
|
|
|
767
988
|
# wrapper is owned by `this`. Per spec, the source node is left
|
|
768
989
|
# in place. `deep: true` copies the entire subtree.
|
|
769
990
|
def import_node(node, deep = false)
|
|
991
|
+
# Step 1: "If node is a document or shadow root, throw a
|
|
992
|
+
# NotSupportedError." Neither has anywhere to go in another document.
|
|
993
|
+
if node.is_a?(Document) || node.is_a?(ShadowRoot)
|
|
994
|
+
raise DOMException::NotSupportedError, "a #{node.is_a?(Document) ? "document" : "shadow root"} cannot be imported"
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
# An Attr is a Node but not a backend-tree node: it is copied by rebuilding
|
|
998
|
+
# it here with the same qualified name, namespace, prefix and value, owned
|
|
999
|
+
# by no element (importNode never attaches the copy to anything).
|
|
1000
|
+
return import_attribute(node) if node.is_a?(Attr)
|
|
770
1001
|
return nil unless node.respond_to?(:__dommy_backend_node__)
|
|
771
1002
|
|
|
772
1003
|
# WebIDL `optional boolean deep = false`: a missing / undefined argument
|
|
773
1004
|
# is the default (false / shallow), not a truthy sentinel.
|
|
774
1005
|
deep = false if deep.nil? || deep.equal?(Bridge::UNDEFINED)
|
|
775
|
-
|
|
1006
|
+
source_document = node.respond_to?(:document) ? node.document : self
|
|
1007
|
+
copy = clone_into_doc(node.__dommy_backend_node__, deep, source_document)
|
|
776
1008
|
wrap_node(copy)
|
|
777
1009
|
end
|
|
778
1010
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
src = node.__dommy_backend_node__
|
|
790
|
-
src.unlink if src.parent
|
|
791
|
-
|
|
792
|
-
# Same document: just return the wrapper after the detach above.
|
|
793
|
-
return wrap_node(src) if src.document == @backend_doc
|
|
794
|
-
|
|
795
|
-
# Cross-document DocumentType: Makiri can't import a doctype node between
|
|
796
|
-
# arenas, so re-create it in this document's backend from its public
|
|
797
|
-
# name / publicId / systemId (as createDocument does), then reseat the
|
|
798
|
-
# caller's wrapper onto the new node so JS identity survives the move.
|
|
799
|
-
if node.is_a?(DocumentType)
|
|
800
|
-
adopted = begin
|
|
801
|
-
Backend.create_document_type(node.name, node.public_id, node.system_id, @backend_doc)
|
|
802
|
-
rescue StandardError
|
|
803
|
-
nil
|
|
804
|
-
end
|
|
805
|
-
return node unless adopted
|
|
806
|
-
|
|
807
|
-
src_doc_wrapper = node.instance_variable_get(:@document)
|
|
808
|
-
src_doc_wrapper.__internal_reset_wrapper__(src) if src_doc_wrapper.respond_to?(:__internal_reset_wrapper__)
|
|
809
|
-
node.instance_variable_set(:@document, self)
|
|
810
|
-
node.instance_variable_set(:@owner_document, self)
|
|
811
|
-
node.instance_variable_set(:@__node__, adopted)
|
|
812
|
-
@node_wrapper_cache.register(adopted, node)
|
|
813
|
-
return node
|
|
814
|
-
end
|
|
815
|
-
|
|
816
|
-
# Cross-document: hand the detached source to the backend, which
|
|
817
|
-
# returns the node now owned by this document — an imported copy for
|
|
818
|
-
# Makiri (a node can't move between arenas). Drop the stale source
|
|
819
|
-
# wrapper, then reseat the caller's Dommy wrapper onto the adopted
|
|
820
|
-
# node so `adopt_node(x).equal?(x)` stays true across documents.
|
|
821
|
-
src_doc_wrapper = node.instance_variable_get(:@document)
|
|
822
|
-
adopted = Backend.adopt(src, @backend_doc)
|
|
823
|
-
|
|
824
|
-
if src_doc_wrapper.respond_to?(:__internal_reset_wrapper__)
|
|
825
|
-
src_doc_wrapper.__internal_reset_wrapper__(src)
|
|
826
|
-
end
|
|
827
|
-
node.instance_variable_set(:@document, self)
|
|
828
|
-
node.instance_variable_set(:@__node__, adopted)
|
|
829
|
-
@node_wrapper_cache.register(adopted, node)
|
|
830
|
-
|
|
831
|
-
# A deep adopt imports a fresh copy of the whole subtree, so any live
|
|
832
|
-
# descendant wrapper (held by page script, e.g. an aria element reference)
|
|
833
|
-
# must be reseated onto its corresponding copy — otherwise it stays bound to
|
|
834
|
-
# the old document. Import preserves document order, so walk both subtrees in
|
|
835
|
-
# lockstep.
|
|
836
|
-
reseat_descendant_wrappers(src, adopted, src_doc_wrapper)
|
|
837
|
-
node
|
|
1011
|
+
def import_attribute(attr)
|
|
1012
|
+
Attr.new(
|
|
1013
|
+
attr.name,
|
|
1014
|
+
value: attr.value,
|
|
1015
|
+
namespace_uri: attr.namespace_uri,
|
|
1016
|
+
prefix: attr.prefix,
|
|
1017
|
+
local_name: attr.local_name,
|
|
1018
|
+
document: self
|
|
1019
|
+
)
|
|
838
1020
|
end
|
|
839
1021
|
|
|
840
|
-
#
|
|
841
|
-
#
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
return unless src_nodes.length == dst_nodes.length
|
|
1022
|
+
# The registry holding this document's `<template>` content fragments. A
|
|
1023
|
+
# cross-document clone or adopt has to read the SOURCE document's registry:
|
|
1024
|
+
# template contents are not in the template's child list, so they are
|
|
1025
|
+
# reachable only through the registry that owns them.
|
|
1026
|
+
def __internal_template_registry__
|
|
1027
|
+
@template_content_registry
|
|
1028
|
+
end
|
|
848
1029
|
|
|
849
|
-
|
|
850
|
-
|
|
1030
|
+
# Move a node from another document into this one. The source node is
|
|
1031
|
+
# detached from its previous owner and its ownerDocument becomes this.
|
|
1032
|
+
# Returns the (possibly re-bound) node.
|
|
1033
|
+
#
|
|
1034
|
+
# What "moving" costs depends on the backend — an import plus a walk to
|
|
1035
|
+
# carry the live wrappers and template contents over, where a browser swaps
|
|
1036
|
+
# a pointer — so the whole of it lives in Internal::NodeAdopter.
|
|
1037
|
+
def adopt_node(node)
|
|
1038
|
+
node_adopter.adopt(node)
|
|
1039
|
+
end
|
|
851
1040
|
|
|
852
|
-
|
|
853
|
-
|
|
1041
|
+
# Adopt a raw backend node with no wrapper of its own: a DocumentFragment's
|
|
1042
|
+
# children, on a cross-document insert.
|
|
1043
|
+
def __internal_adopt_backend_node__(node, source_document)
|
|
1044
|
+
node_adopter.adopt_backend_node(node, source_document)
|
|
1045
|
+
end
|
|
854
1046
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
wrapper.instance_variable_set(:@__node__, copy)
|
|
858
|
-
@node_wrapper_cache.register(copy, wrapper)
|
|
859
|
-
end
|
|
1047
|
+
def node_adopter
|
|
1048
|
+
@node_adopter ||= Internal::NodeAdopter.new(self)
|
|
860
1049
|
end
|
|
1050
|
+
private :node_adopter
|
|
861
1051
|
|
|
862
1052
|
# HTML "cloning steps": a cloned node copies interface-specific live state
|
|
863
1053
|
# that the content attributes don't capture — an input's dirty value and
|
|
@@ -867,11 +1057,17 @@ module Dommy
|
|
|
867
1057
|
# lockstep and copy each live wrapper's cloning state onto its copy. `deep`
|
|
868
1058
|
# false processes only the root (a shallow clone has no children).
|
|
869
1059
|
def __internal_apply_cloning_steps__(src_root_bn, clone_root_bn, deep)
|
|
870
|
-
src_nodes = deep ?
|
|
871
|
-
clone_nodes = deep ?
|
|
1060
|
+
src_nodes = deep ? Internal::NodeTraversal.subtree_nodes(src_root_bn) : [src_root_bn]
|
|
1061
|
+
clone_nodes = deep ? Internal::NodeTraversal.subtree_nodes(clone_root_bn) : [clone_root_bn]
|
|
872
1062
|
return unless src_nodes.length == clone_nodes.length
|
|
873
1063
|
|
|
874
1064
|
src_nodes.zip(clone_nodes).each do |orig, copy|
|
|
1065
|
+
# HTML cloning steps for <template>: its content lives in an off-tree
|
|
1066
|
+
# fragment the backend's subtree clone never reaches, so a deep clone has
|
|
1067
|
+
# to copy it across explicitly (a shallow clone gets an empty template,
|
|
1068
|
+
# per spec).
|
|
1069
|
+
clone_template_content(orig, copy) if deep && @template_content_registry.has_content?(orig)
|
|
1070
|
+
|
|
875
1071
|
wrapper = @node_wrapper_cache.peek(orig)
|
|
876
1072
|
next unless wrapper.respond_to?(:__cloning_state__)
|
|
877
1073
|
|
|
@@ -882,32 +1078,27 @@ module Dommy
|
|
|
882
1078
|
end
|
|
883
1079
|
end
|
|
884
1080
|
|
|
885
|
-
# A subtree's nodes in document (depth-first) order — the order Backend.adopt
|
|
886
|
-
# preserves — so a source node and its imported copy line up by index.
|
|
887
|
-
def collect_subtree_nodes(root)
|
|
888
|
-
nodes = [root]
|
|
889
|
-
root.children.each { |child| nodes.concat(collect_subtree_nodes(child)) } if root.respond_to?(:children)
|
|
890
|
-
nodes
|
|
891
|
-
end
|
|
892
|
-
|
|
893
1081
|
# Legacy `document.createEvent("EventName")` factory. Returns an
|
|
894
1082
|
# Event subclass instance whose init still has to be called
|
|
895
1083
|
# (`event.initEvent(type, bubbles, cancelable)`). Matches the
|
|
896
1084
|
# mapping happy-dom and linkedom use.
|
|
897
1085
|
def create_event(type_name)
|
|
898
1086
|
name = type_name.to_s
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1087
|
+
event =
|
|
1088
|
+
case name
|
|
1089
|
+
when "CustomEvent"
|
|
1090
|
+
CustomEvent.new("")
|
|
1091
|
+
when "MouseEvent", "MouseEvents"
|
|
1092
|
+
MouseEvent.new("")
|
|
1093
|
+
when "KeyboardEvent", "KeyboardEvents"
|
|
1094
|
+
KeyboardEvent.new("")
|
|
1095
|
+
else
|
|
1096
|
+
Event.new("")
|
|
1097
|
+
end
|
|
1098
|
+
# createEvent hands back an *uninitialized* event: it has no type yet and
|
|
1099
|
+
# dispatching it before initEvent() is an InvalidStateError.
|
|
1100
|
+
event.__internal_mark_uninitialized__
|
|
1101
|
+
event
|
|
911
1102
|
end
|
|
912
1103
|
|
|
913
1104
|
# Stubs for layout / focus / selection / execCommand APIs that
|
|
@@ -920,7 +1111,11 @@ module Dommy
|
|
|
920
1111
|
|
|
921
1112
|
alias has_focus has_focus?
|
|
922
1113
|
|
|
1114
|
+
# A document without a browsing context (one built by DOMParser or
|
|
1115
|
+
# createHTMLDocument) has no selection.
|
|
923
1116
|
def get_selection
|
|
1117
|
+
return nil unless @default_view
|
|
1118
|
+
|
|
924
1119
|
@__selection ||= Selection.new(self)
|
|
925
1120
|
end
|
|
926
1121
|
|
|
@@ -962,7 +1157,7 @@ module Dommy
|
|
|
962
1157
|
# `document.createNodeIterator(root, whatToShow?, filter?)` —
|
|
963
1158
|
# flat depth-first iteration.
|
|
964
1159
|
def create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
|
|
965
|
-
root =
|
|
1160
|
+
root = Internal::WebIDL.node!(root)
|
|
966
1161
|
iterator = NodeIterator.new(root, what_to_show, filter)
|
|
967
1162
|
# The "NodeIterator pre-removing steps" run for iterators whose root's node
|
|
968
1163
|
# document is the removed node's document. Track the iterator on the root's
|
|
@@ -979,12 +1174,16 @@ module Dommy
|
|
|
979
1174
|
def node_iterator_document(root)
|
|
980
1175
|
return root if root.is_a?(Dommy::Document)
|
|
981
1176
|
|
|
982
|
-
doc = root.
|
|
1177
|
+
doc = root.document if root.respond_to?(:document)
|
|
983
1178
|
doc.is_a?(Dommy::Document) ? doc : self
|
|
984
1179
|
end
|
|
985
1180
|
|
|
986
1181
|
def __internal_track_node_iterator__(iterator)
|
|
987
|
-
@node_iterators
|
|
1182
|
+
@node_iterators[iterator] = true
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
def node_iterators?
|
|
1186
|
+
@node_iterators.size.positive?
|
|
988
1187
|
end
|
|
989
1188
|
|
|
990
1189
|
# `document.doctype` — the node-backed DocumentType wrapping the parsed
|
|
@@ -1012,6 +1211,7 @@ module Dommy
|
|
|
1012
1211
|
# from the counts, since replace-all removes them first. `exclude` (replace)
|
|
1013
1212
|
# is a current child to disregard. Raises HierarchyRequestError on violation.
|
|
1014
1213
|
def ensure_document_insertion_validity!(args, child_bn, ignore_existing: false, exclude: nil)
|
|
1214
|
+
ensure_not_self_insertion!(args)
|
|
1015
1215
|
elements = 0
|
|
1016
1216
|
doctypes = 0
|
|
1017
1217
|
has_text = false
|
|
@@ -1066,6 +1266,31 @@ module Dommy
|
|
|
1066
1266
|
end
|
|
1067
1267
|
end
|
|
1068
1268
|
|
|
1269
|
+
# Document's answer to the ParentNode hook a ChildNode mutation
|
|
1270
|
+
# (`before` / `after` / `replaceWith`) calls on its parent. A Document
|
|
1271
|
+
# parent skips the ancestor rule (it is never a descendant of anything) and
|
|
1272
|
+
# instead carries step 6: at most one element child, no Text child, and a
|
|
1273
|
+
# doctype only ahead of the document element. `replacing` is the child a
|
|
1274
|
+
# `replaceWith` stands in for, which WHATWG "replace" disregards when
|
|
1275
|
+
# counting the existing children.
|
|
1276
|
+
def __internal_ensure_insertion_validity__(args, ref_bn, replacing: nil)
|
|
1277
|
+
ensure_document_insertion_validity!(args, ref_bn, exclude: replacing)
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
# WHATWG "ensure pre-insertion validity" step 2 for a Document parent: node
|
|
1281
|
+
# must not be a host-including inclusive ancestor of the parent. A document
|
|
1282
|
+
# is an inclusive ancestor of itself and nothing else can be an ancestor of
|
|
1283
|
+
# one, so this reduces to "you cannot insert the document into itself".
|
|
1284
|
+
#
|
|
1285
|
+
# It is step 2, so it precedes step 3's NotFoundError on the reference
|
|
1286
|
+
# child: `document.replaceChild(document, x)` is a HierarchyRequestError
|
|
1287
|
+
# even when x is not a child of the document.
|
|
1288
|
+
def ensure_not_self_insertion!(args)
|
|
1289
|
+
return unless args.any? { |a| a.equal?(self) }
|
|
1290
|
+
|
|
1291
|
+
raise DOMException::HierarchyRequestError, "Cannot insert a node as a descendant of itself"
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1069
1294
|
# Whether any element child precedes `child_bn` in the document's child list.
|
|
1070
1295
|
def element_before_child?(existing, child_bn)
|
|
1071
1296
|
idx = child_bn && existing.index { |c| c == child_bn }
|
|
@@ -1088,26 +1313,147 @@ module Dommy
|
|
|
1088
1313
|
ensure_document_insertion_validity!([node], nil)
|
|
1089
1314
|
return node unless node.respond_to?(:__dommy_backend_node__)
|
|
1090
1315
|
|
|
1091
|
-
#
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1316
|
+
# An append has a null reference child, so insert step 5 shifts nothing.
|
|
1317
|
+
nodes = document_insertion_nodes([node])
|
|
1318
|
+
return node if nodes.empty?
|
|
1319
|
+
|
|
1320
|
+
nodes.each { |bn| @backend_doc.add_child(bn) }
|
|
1321
|
+
notify_document_child_list(added: nodes)
|
|
1097
1322
|
node
|
|
1098
1323
|
end
|
|
1099
1324
|
|
|
1325
|
+
# The Node / ParentNode mutation methods under their WHATWG names. The
|
|
1326
|
+
# document's own child list has its own rules (at most one element, no Text,
|
|
1327
|
+
# a doctype only ahead of the document element), so each forwards to the
|
|
1328
|
+
# `document_*` implementation that carries them.
|
|
1329
|
+
def insert_before(node, reference)
|
|
1330
|
+
document_insert_before(node, reference)
|
|
1331
|
+
end
|
|
1332
|
+
|
|
1333
|
+
def replace_child(new_child, old_child)
|
|
1334
|
+
document_replace_child(new_child, old_child)
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
def remove_child(node)
|
|
1338
|
+
document_remove_child(node)
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
def replace_children(*args)
|
|
1342
|
+
document_replace_children(args)
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
def append(*args)
|
|
1346
|
+
document_insert(args, prepend: false)
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
def prepend(*args)
|
|
1350
|
+
document_insert(args, prepend: true)
|
|
1351
|
+
end
|
|
1352
|
+
|
|
1353
|
+
# WHATWG ParentNode.moveBefore(node, child) with the DOCUMENT as the new
|
|
1354
|
+
# parent. The move primitive's steps 5 and 6 bind only here: a Text node may
|
|
1355
|
+
# not become a child of a document, and an Element may only be moved in when
|
|
1356
|
+
# the document has no element child, the reference is not the doctype, and
|
|
1357
|
+
# no doctype follows the reference.
|
|
1358
|
+
#
|
|
1359
|
+
# Spec: https://dom.spec.whatwg.org/#dom-parentnode-movebefore
|
|
1360
|
+
def move_before(node, child = nil)
|
|
1361
|
+
Internal::WebIDL.node!(node)
|
|
1362
|
+
Internal::WebIDL.nullable_node!(child)
|
|
1363
|
+
bn = move_backend_node(node)
|
|
1364
|
+
ref_bn = move_backend_node(child)
|
|
1365
|
+
ref_bn = ref_bn.next_sibling if ref_bn && bn && ref_bn == bn
|
|
1366
|
+
ensure_document_move_validity!(node, bn, ref_bn)
|
|
1367
|
+
|
|
1368
|
+
old_parent = bn.parent
|
|
1369
|
+
old_previous = bn.previous_sibling
|
|
1370
|
+
old_next = bn.next_sibling
|
|
1371
|
+
detach_node(bn, moving: true) # steps 10-11, 14
|
|
1372
|
+
ref_bn = nil if ref_bn && ref_bn.parent != @backend_doc
|
|
1373
|
+
__internal_ranges_will_insert__(@backend_doc, ref_bn, 1) # step 16
|
|
1374
|
+
new_previous = ref_bn ? ref_bn.previous_sibling : @backend_doc.children.to_a.last
|
|
1375
|
+
ref_bn ? ref_bn.add_previous_sibling(bn) : @backend_doc.add_child(bn) # step 18
|
|
1376
|
+
if old_parent
|
|
1377
|
+
notify_child_list_mutation(
|
|
1378
|
+
target_node: old_parent, added_nodes: [], removed_nodes: [bn],
|
|
1379
|
+
previous_sibling: old_previous && wrap_node(old_previous),
|
|
1380
|
+
next_sibling: old_next && wrap_node(old_next), moving: true
|
|
1381
|
+
)
|
|
1382
|
+
end
|
|
1383
|
+
notify_document_child_list(added: [bn], previous_sibling: new_previous && wrap_node(new_previous),
|
|
1384
|
+
next_sibling: ref_bn && wrap_node(ref_bn), moving: true)
|
|
1385
|
+
__internal_notify_moved_subtree__(bn) # step 19.3, into a document
|
|
1386
|
+
nil
|
|
1387
|
+
end
|
|
1388
|
+
|
|
1389
|
+
# The backend node an argument to `moveBefore` stands for. A Dommy::Document
|
|
1390
|
+
# has no `__dommy_backend_node__` of its own, but it is a node the algorithm
|
|
1391
|
+
# has to see (step 2 rejects it, step 3 measures its parentage).
|
|
1392
|
+
def move_backend_node(value)
|
|
1393
|
+
return value.backend_doc if value.is_a?(Dommy::Document)
|
|
1394
|
+
return nil unless value.respond_to?(:__dommy_backend_node__)
|
|
1395
|
+
|
|
1396
|
+
value.__dommy_backend_node__
|
|
1397
|
+
end
|
|
1398
|
+
|
|
1399
|
+
# "Move" steps 1-6 with a document new parent.
|
|
1400
|
+
def ensure_document_move_validity!(node, bn, ref_bn)
|
|
1401
|
+
# Step 1 — the same root. A document is its own root, so this says the
|
|
1402
|
+
# node must already be somewhere in this document.
|
|
1403
|
+
root = bn
|
|
1404
|
+
root = root.parent while root.respond_to?(:parent) && root.parent
|
|
1405
|
+
# `==` and not `equal?`: a backend may hand back a fresh Ruby object for
|
|
1406
|
+
# the same underlying node on every `parent` call.
|
|
1407
|
+
unless bn && root == @backend_doc
|
|
1408
|
+
raise DOMException::HierarchyRequestError,
|
|
1409
|
+
"moveBefore requires the node and the new parent to share a root"
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1412
|
+
# Step 2 — a document is its own inclusive ancestor; nothing else can be
|
|
1413
|
+
# an ancestor of one, so this reduces to "not the document itself".
|
|
1414
|
+
ensure_not_self_insertion!([node])
|
|
1415
|
+
|
|
1416
|
+
# Step 3.
|
|
1417
|
+
if ref_bn && ref_bn.parent != @backend_doc
|
|
1418
|
+
raise DOMException::NotFoundError, "The reference child is not a child of this document."
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
# Step 4.
|
|
1422
|
+
unless node.is_a?(Dommy::Element) || node.is_a?(Dommy::CharacterDataNode)
|
|
1423
|
+
raise DOMException::HierarchyRequestError, "this node type cannot be moved"
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
# Step 5.
|
|
1427
|
+
if node.is_a?(Dommy::CharacterDataNode) && node.node_type == 3
|
|
1428
|
+
raise DOMException::HierarchyRequestError, "A Text node cannot be a child of a document."
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
return unless node.is_a?(Dommy::Element)
|
|
1432
|
+
|
|
1433
|
+
# Step 6. The counts are of the CURRENT children, so a document that
|
|
1434
|
+
# already has a document element cannot take another element — not even
|
|
1435
|
+
# by moving that same element to a different slot.
|
|
1436
|
+
existing = @backend_doc.children.to_a
|
|
1437
|
+
return unless existing.any? { |c| c.node_type == 1 } ||
|
|
1438
|
+
(ref_bn && ref_bn.node_type == 10) ||
|
|
1439
|
+
doctype_after_child?(existing, ref_bn)
|
|
1440
|
+
|
|
1441
|
+
raise DOMException::HierarchyRequestError, "An element cannot be moved here."
|
|
1442
|
+
end
|
|
1443
|
+
|
|
1100
1444
|
# ParentNode / Node mutation on the document's direct children (the doctype
|
|
1101
1445
|
# and the document element).
|
|
1102
1446
|
def document_insert(args, prepend:)
|
|
1103
1447
|
ref_bn = prepend ? @backend_doc.children.first : nil
|
|
1104
1448
|
ensure_document_insertion_validity!(args, ref_bn)
|
|
1105
|
-
|
|
1449
|
+
__internal_ranges_will_insert__(@backend_doc, ref_bn, document_insertion_count(args))
|
|
1450
|
+
nodes = document_insertion_nodes(args)
|
|
1106
1451
|
if prepend && (first = @backend_doc.children.first)
|
|
1107
1452
|
nodes.reverse_each { |n| first.add_previous_sibling(n) }
|
|
1108
1453
|
else
|
|
1109
1454
|
nodes.each { |n| @backend_doc.add_child(n) }
|
|
1110
1455
|
end
|
|
1456
|
+
notify_document_child_list(added: nodes)
|
|
1111
1457
|
nil
|
|
1112
1458
|
end
|
|
1113
1459
|
|
|
@@ -1115,8 +1461,11 @@ module Dommy
|
|
|
1115
1461
|
# replaceChildren removes the current children first, so the validity
|
|
1116
1462
|
# checks ignore them (whatwg/dom#1045).
|
|
1117
1463
|
ensure_document_insertion_validity!(args, nil, ignore_existing: true)
|
|
1118
|
-
@backend_doc.children.
|
|
1119
|
-
|
|
1464
|
+
removed = @backend_doc.children.to_a
|
|
1465
|
+
removed.each { |child| detach_node(child) }
|
|
1466
|
+
added = document_insertion_nodes(args)
|
|
1467
|
+
added.each { |n| @backend_doc.add_child(n) }
|
|
1468
|
+
notify_document_child_list(added: added, removed: removed)
|
|
1120
1469
|
nil
|
|
1121
1470
|
end
|
|
1122
1471
|
|
|
@@ -1126,15 +1475,15 @@ module Dommy
|
|
|
1126
1475
|
bn = backend_node(node)
|
|
1127
1476
|
raise DOMException::NotFoundError, "node is not a child of this document" unless bn && bn.parent == @backend_doc
|
|
1128
1477
|
|
|
1129
|
-
|
|
1130
|
-
bn.unlink
|
|
1478
|
+
remove_node_with_notify(bn)
|
|
1131
1479
|
node
|
|
1132
1480
|
end
|
|
1133
1481
|
|
|
1134
1482
|
def document_insert_before(node, ref)
|
|
1135
|
-
# WHATWG pre-insert order:
|
|
1136
|
-
#
|
|
1137
|
-
# checks (steps 4-6).
|
|
1483
|
+
# WHATWG pre-insert order: step 2 (a cycle) first, THEN step 3 (the
|
|
1484
|
+
# reference child must be a child of the parent, NotFoundError), and only
|
|
1485
|
+
# then the node-type / document-hierarchy checks (steps 4-6).
|
|
1486
|
+
ensure_not_self_insertion!([node])
|
|
1138
1487
|
ref_present = !(ref.nil? || (defined?(Bridge::UNDEFINED) && ref.equal?(Bridge::UNDEFINED)))
|
|
1139
1488
|
ref_bn = ref_present ? backend_node(ref) : nil
|
|
1140
1489
|
if ref_present && !(ref_bn && ref_bn.parent == @backend_doc)
|
|
@@ -1142,19 +1491,31 @@ module Dommy
|
|
|
1142
1491
|
end
|
|
1143
1492
|
|
|
1144
1493
|
ensure_document_insertion_validity!([node], ref_bn)
|
|
1145
|
-
|
|
1146
|
-
|
|
1494
|
+
# Insert step 6's insertion point, read BEFORE step 7's adopt moves
|
|
1495
|
+
# anything: the reference child's previous sibling, or the document's last
|
|
1496
|
+
# child when appending.
|
|
1497
|
+
record_previous = document_insertion_previous_sibling(ref_bn)
|
|
1498
|
+
record_next = ref_bn && wrap_node(ref_bn)
|
|
1499
|
+
# Insert step 5 runs before step 7's adopt, so the count is taken while the
|
|
1500
|
+
# node (or fragment) still sits wherever it is now.
|
|
1501
|
+
__internal_ranges_will_insert__(@backend_doc, ref_bn, document_insertion_count([node]))
|
|
1502
|
+
nodes = document_insertion_nodes([node])
|
|
1503
|
+
return node if nodes.empty?
|
|
1147
1504
|
|
|
1148
1505
|
ref_node = ref && backend_node(ref)
|
|
1149
1506
|
if ref_node && ref_node.parent == @backend_doc
|
|
1150
|
-
ref_node.add_previous_sibling(bn)
|
|
1507
|
+
nodes.each { |bn| ref_node.add_previous_sibling(bn) }
|
|
1151
1508
|
else
|
|
1152
|
-
@backend_doc.add_child(bn)
|
|
1509
|
+
nodes.each { |bn| @backend_doc.add_child(bn) }
|
|
1153
1510
|
end
|
|
1511
|
+
notify_document_child_list(added: nodes, previous_sibling: record_previous,
|
|
1512
|
+
next_sibling: record_next)
|
|
1154
1513
|
node
|
|
1155
1514
|
end
|
|
1156
1515
|
|
|
1157
1516
|
def document_replace_child(new_child, old_child)
|
|
1517
|
+
# Step 2 (a cycle) precedes step 3 (the reference child's parentage).
|
|
1518
|
+
ensure_not_self_insertion!([new_child])
|
|
1158
1519
|
old_bn = backend_node(old_child)
|
|
1159
1520
|
raise DOMException::NotFoundError, "node is not a child of this document" unless old_bn && old_bn.parent == @backend_doc
|
|
1160
1521
|
|
|
@@ -1162,18 +1523,51 @@ module Dommy
|
|
|
1162
1523
|
# the document's existing element / doctype children.
|
|
1163
1524
|
ensure_document_insertion_validity!([new_child], old_bn, exclude: old_bn)
|
|
1164
1525
|
|
|
1165
|
-
# Remove old FIRST, then adopt the incoming node. A cross-document doctype
|
|
1166
|
-
# is re-created in this backend, and Makiri's fail-closed guard refuses a
|
|
1167
|
-
# second doctype — so the old one must be gone before the new is made.
|
|
1168
1526
|
ref = old_bn.next
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1527
|
+
# Replace step 4's previousSibling: the old child's previous sibling,
|
|
1528
|
+
# read before the adopt below removes anything.
|
|
1529
|
+
record_previous = old_bn.previous && wrap_node(old_bn.previous)
|
|
1530
|
+
record_next = ref && wrap_node(ref)
|
|
1531
|
+
cross_document = !Backend.moves_nodes_across_documents? &&
|
|
1532
|
+
new_child.respond_to?(:document) && !new_child.document.equal?(self)
|
|
1533
|
+
fragment = new_child.is_a?(Dommy::Fragment)
|
|
1534
|
+
|
|
1535
|
+
# Same document: WHATWG replace adopts the incoming node — which removes
|
|
1536
|
+
# it from whatever parent it has — BEFORE removing the child it replaces,
|
|
1537
|
+
# so that old parent gets its removing steps and its childList record.
|
|
1538
|
+
#
|
|
1539
|
+
# Cross-document is deferred instead: a doctype has to be re-created in
|
|
1540
|
+
# this backend, and Makiri's fail-closed guard refuses a second doctype,
|
|
1541
|
+
# so the old one must be gone before the new one is made.
|
|
1542
|
+
#
|
|
1543
|
+
# A DocumentFragment has nothing to adopt at this point: what gets
|
|
1544
|
+
# inserted is its children, and insert step 4 takes them out later, after
|
|
1545
|
+
# step 7 has removed the child being replaced.
|
|
1546
|
+
new_bn = adopted_backend_node(new_child) if !cross_document && !fragment
|
|
1547
|
+
# Insert step 2's count, taken while the fragment still holds its children.
|
|
1548
|
+
count = document_insertion_count([new_child])
|
|
1549
|
+
detach_node(old_bn)
|
|
1550
|
+
# Insert step 4: a fragment's children are REMOVED from it, which runs the
|
|
1551
|
+
# live range and NodeIterator pre-remove steps and queues a childList
|
|
1552
|
+
# record on the fragment. Then step 5 shifts the ranges on this document
|
|
1553
|
+
# by the count. WHATWG "replace" removes the old child (step 7) before the
|
|
1554
|
+
# insert (step 9), so step 5 measures `ref` in the tree the removal leaves.
|
|
1555
|
+
nodes =
|
|
1556
|
+
if fragment
|
|
1557
|
+
document_insertion_nodes([new_child])
|
|
1558
|
+
elsif cross_document
|
|
1559
|
+
new_child = adopt_node(new_child)
|
|
1560
|
+
bn = backend_node(new_child)
|
|
1561
|
+
bn ? [bn] : []
|
|
1562
|
+
else
|
|
1563
|
+
new_bn ? [new_bn] : []
|
|
1564
|
+
end
|
|
1565
|
+
__internal_ranges_will_insert__(@backend_doc, ref && ref.parent == @backend_doc ? ref : nil, count)
|
|
1566
|
+
nodes.each do |bn|
|
|
1567
|
+
ref && ref.parent == @backend_doc ? ref.add_previous_sibling(bn) : @backend_doc.add_child(bn)
|
|
1176
1568
|
end
|
|
1569
|
+
notify_document_child_list(added: nodes, removed: [old_bn],
|
|
1570
|
+
previous_sibling: record_previous, next_sibling: record_next)
|
|
1177
1571
|
old_child
|
|
1178
1572
|
end
|
|
1179
1573
|
|
|
@@ -1183,8 +1577,7 @@ module Dommy
|
|
|
1183
1577
|
node = backend_node(doctype) || Backend.internal_subset(@backend_doc)
|
|
1184
1578
|
return nil unless node
|
|
1185
1579
|
|
|
1186
|
-
|
|
1187
|
-
node.unlink
|
|
1580
|
+
remove_node_with_notify(node)
|
|
1188
1581
|
nil
|
|
1189
1582
|
end
|
|
1190
1583
|
|
|
@@ -1192,21 +1585,28 @@ module Dommy
|
|
|
1192
1585
|
# (at the document start) or after it (just before the document element).
|
|
1193
1586
|
def __internal_insert_at_doctype__(nodes, after:)
|
|
1194
1587
|
bns = nodes.filter_map { |n| backend_node(n) }
|
|
1588
|
+
anchor = after ? @backend_doc.root : @backend_doc.children.first
|
|
1589
|
+
__internal_ranges_will_insert__(@backend_doc, anchor, bns.size)
|
|
1195
1590
|
if after
|
|
1196
|
-
|
|
1197
|
-
root ? bns.each { |n| root.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
|
|
1591
|
+
anchor ? bns.each { |n| anchor.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
|
|
1198
1592
|
else
|
|
1199
|
-
|
|
1200
|
-
first ? bns.reverse_each { |n| first.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
|
|
1593
|
+
anchor ? bns.reverse_each { |n| anchor.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
|
|
1201
1594
|
end
|
|
1202
1595
|
nil
|
|
1203
1596
|
end
|
|
1204
1597
|
|
|
1205
|
-
# `document.cloneNode(deep)` → a fresh Document
|
|
1206
|
-
#
|
|
1598
|
+
# `document.cloneNode(deep)` → a fresh Document of the same kind, keeping
|
|
1599
|
+
# the content type. "Clone a single node" creates a document EMPTY, and a
|
|
1600
|
+
# deep clone then copies the original's children into it — so nothing
|
|
1601
|
+
# sprouts an html/head/body of its own, and a document with no children
|
|
1602
|
+
# clones to one with no children.
|
|
1207
1603
|
def clone_node(deep)
|
|
1208
|
-
copy =
|
|
1209
|
-
|
|
1604
|
+
copy = Document.new(nil, backend_doc: Backend.empty_document_like(@backend_doc))
|
|
1605
|
+
copy.content_type = @content_type
|
|
1606
|
+
return copy unless deep
|
|
1607
|
+
|
|
1608
|
+
child_nodes.each { |child| copy.append_child(copy.import_node(child, true)) }
|
|
1609
|
+
copy
|
|
1210
1610
|
end
|
|
1211
1611
|
|
|
1212
1612
|
def backend_node(node)
|
|
@@ -1219,13 +1619,55 @@ module Dommy
|
|
|
1219
1619
|
# `append_child` does, otherwise a cross-document node's backend node comes
|
|
1220
1620
|
# from a foreign arena and the insertion silently drops it on a backend that
|
|
1221
1621
|
# can't move nodes across documents (Makiri).
|
|
1622
|
+
# WHATWG pre-insert: adopt the node into this document, which removes it
|
|
1623
|
+
# from whatever parent it has now. The backend would detach it implicitly on
|
|
1624
|
+
# the next add_child, but silently — that is a storage operation, not a DOM
|
|
1625
|
+
# removal, so route it through the shared remove primitive instead and let
|
|
1626
|
+
# the old parent see its removing steps and its childList record.
|
|
1627
|
+
# WHATWG "insert" steps 1 and 4 for the DOCUMENT's own child list. A
|
|
1628
|
+
# DocumentFragment argument contributes its children, and they are removed
|
|
1629
|
+
# from it first — with their removing steps, so a live range or NodeIterator
|
|
1630
|
+
# inside them follows — before anything is linked here. Every other node is
|
|
1631
|
+
# adopted, which removes it from its old parent.
|
|
1632
|
+
#
|
|
1633
|
+
# Document's insertion paths hand the backend the node they were given, and
|
|
1634
|
+
# the backend splices a fragment's children in silently; without this the
|
|
1635
|
+
# fragment's children would move with no removing steps at all.
|
|
1636
|
+
def document_insertion_nodes(args)
|
|
1637
|
+
args.flat_map do |arg|
|
|
1638
|
+
if arg.is_a?(Dommy::Fragment)
|
|
1639
|
+
source = arg.document
|
|
1640
|
+
arg.extract_children.map do |n|
|
|
1641
|
+
n.document == @backend_doc ? n : __internal_adopt_backend_node__(n, source)
|
|
1642
|
+
end
|
|
1643
|
+
else
|
|
1644
|
+
bn = adopted_backend_node(arg)
|
|
1645
|
+
bn ? [bn] : []
|
|
1646
|
+
end
|
|
1647
|
+
end
|
|
1648
|
+
end
|
|
1649
|
+
|
|
1650
|
+
# How many nodes `args` will contribute, counted BEFORE any of them moves —
|
|
1651
|
+
# insert step 5 needs the count while the fragment still holds its children.
|
|
1652
|
+
def document_insertion_count(args)
|
|
1653
|
+
args.sum do |arg|
|
|
1654
|
+
if arg.is_a?(Dommy::Fragment) then arg.child_nodes.to_a.size
|
|
1655
|
+
elsif arg.respond_to?(:__dommy_backend_node__) then 1
|
|
1656
|
+
else 0
|
|
1657
|
+
end
|
|
1658
|
+
end
|
|
1659
|
+
end
|
|
1660
|
+
|
|
1222
1661
|
def adopted_backend_node(node)
|
|
1223
1662
|
return nil unless node.respond_to?(:__dommy_backend_node__)
|
|
1224
1663
|
|
|
1225
1664
|
if !Backend.moves_nodes_across_documents? && node.respond_to?(:document) && !node.document.equal?(self)
|
|
1226
|
-
|
|
1665
|
+
return adopt_node(node)&.__dommy_backend_node__
|
|
1227
1666
|
end
|
|
1228
|
-
|
|
1667
|
+
|
|
1668
|
+
bn = node.__dommy_backend_node__
|
|
1669
|
+
remove_node_with_notify(bn) if bn.parent
|
|
1670
|
+
bn
|
|
1229
1671
|
end
|
|
1230
1672
|
|
|
1231
1673
|
# Delegate to CookieJar
|
|
@@ -1333,14 +1775,16 @@ module Dommy
|
|
|
1333
1775
|
when "scrollingElement"
|
|
1334
1776
|
wrap_node(@backend_doc.at_css("html"))
|
|
1335
1777
|
when "documentElement"
|
|
1336
|
-
|
|
1337
|
-
wrap_node(@backend_doc.root)
|
|
1778
|
+
document_element
|
|
1338
1779
|
when "title"
|
|
1339
1780
|
read_title
|
|
1340
1781
|
when "cookie"
|
|
1341
1782
|
cookie
|
|
1342
1783
|
when "nodeType"
|
|
1343
1784
|
9
|
|
1785
|
+
when "isConnected"
|
|
1786
|
+
# A document is its own shadow-including root, so it is always connected.
|
|
1787
|
+
true
|
|
1344
1788
|
when "nodeValue", "textContent"
|
|
1345
1789
|
# A Document's nodeValue and textContent are null (not the concatenated
|
|
1346
1790
|
# descendant text) per the DOM.
|
|
@@ -1524,7 +1968,7 @@ module Dommy
|
|
|
1524
1968
|
adoptNode hasFocus getSelection elementFromPoint queryCommandSupported addEventListener
|
|
1525
1969
|
removeEventListener dispatchEvent write writeln open close isEqualNode isSameNode appendChild
|
|
1526
1970
|
hasChildNodes contains append prepend replaceChildren removeChild insertBefore replaceChild
|
|
1527
|
-
cloneNode normalize compareDocumentPosition getRootNode
|
|
1971
|
+
cloneNode normalize compareDocumentPosition getRootNode moveBefore
|
|
1528
1972
|
lookupNamespaceURI lookupPrefix isDefaultNamespace
|
|
1529
1973
|
]
|
|
1530
1974
|
def __js_call__(method, args)
|
|
@@ -1563,17 +2007,20 @@ module Dommy
|
|
|
1563
2007
|
document_remove_child(args[0])
|
|
1564
2008
|
when "insertBefore"
|
|
1565
2009
|
raise Bridge::TypeError, "insertBefore requires 2 arguments." if args.length < 2
|
|
1566
|
-
|
|
1567
|
-
raise Bridge::TypeError, "The reference child is not a Node."
|
|
1568
|
-
end
|
|
2010
|
+
Internal::WebIDL.nullable_node!(args[1])
|
|
1569
2011
|
|
|
1570
2012
|
document_insert_before(args[0], args[1])
|
|
1571
2013
|
when "replaceChild"
|
|
1572
2014
|
document_replace_child(args[0], args[1])
|
|
2015
|
+
when "moveBefore"
|
|
2016
|
+
raise Bridge::TypeError, "moveBefore requires 2 arguments." if args.length < 2
|
|
2017
|
+
|
|
2018
|
+
move_before(args[0], args[1])
|
|
2019
|
+
Bridge::UNDEFINED
|
|
1573
2020
|
when "cloneNode"
|
|
1574
2021
|
clone_node(args[0])
|
|
1575
2022
|
when "normalize"
|
|
1576
|
-
|
|
2023
|
+
normalize
|
|
1577
2024
|
when "writeln"
|
|
1578
2025
|
write(*(args + ["\n"]))
|
|
1579
2026
|
when "exitFullscreen"
|
|
@@ -1702,6 +2149,58 @@ module Dommy
|
|
|
1702
2149
|
@node_wrapper_cache.wrap_cloned_element_ns(node, namespace, prefix, local, qualified_name)
|
|
1703
2150
|
end
|
|
1704
2151
|
|
|
2152
|
+
# The task scheduler this document's own tasks run on: its browsing context's
|
|
2153
|
+
# when it has one, otherwise the one handed to it by whatever built it (a
|
|
2154
|
+
# DOMParser document has no defaultView but still queues tasks on the window
|
|
2155
|
+
# whose script created it).
|
|
2156
|
+
attr_writer :task_scheduler
|
|
2157
|
+
|
|
2158
|
+
def __internal_scheduler__
|
|
2159
|
+
(@default_view&.scheduler if @default_view.respond_to?(:scheduler)) || @task_scheduler
|
|
2160
|
+
end
|
|
2161
|
+
|
|
2162
|
+
# The parser built the tree without any insertion or attribute steps
|
|
2163
|
+
# running: give the elements that depend on them their due, once the
|
|
2164
|
+
# document exists. Every details gets its insertion steps (the toggle event
|
|
2165
|
+
# it owes, its exclusive accordion group settled), and every select has its
|
|
2166
|
+
# list of options settled (a single-select the parser left with no, or
|
|
2167
|
+
# several, selected options).
|
|
2168
|
+
def __internal_run_parsed_insertion_steps__
|
|
2169
|
+
return nil unless @backend_doc.respond_to?(:css)
|
|
2170
|
+
|
|
2171
|
+
elements = @backend_doc.css("details").filter_map { |node| wrap_node(node) }
|
|
2172
|
+
HTMLDetailsElement.run_insertion_steps(elements) unless elements.empty?
|
|
2173
|
+
@backend_doc.css("select").each { |node| wrap_node(node)&.__internal_settle_selectedness_once__ }
|
|
2174
|
+
nil
|
|
2175
|
+
end
|
|
2176
|
+
|
|
2177
|
+
# Bind an externally built wrapper to its backend node, so later traversals
|
|
2178
|
+
# return the same Ruby object (JS identity) instead of building a new one.
|
|
2179
|
+
def __internal_register_wrapper__(node, wrapper)
|
|
2180
|
+
@node_wrapper_cache.register(node, wrapper)
|
|
2181
|
+
wrapper
|
|
2182
|
+
end
|
|
2183
|
+
|
|
2184
|
+
# The wrapper already cached for a backend node, or nil — never builds one.
|
|
2185
|
+
def __internal_cached_wrapper__(node)
|
|
2186
|
+
@node_wrapper_cache.cached_wrapper(node)
|
|
2187
|
+
end
|
|
2188
|
+
|
|
2189
|
+
# Recorded when an element is created outside the HTML namespace or with a
|
|
2190
|
+
# prefix. Deep cloning only has to carry that metadata across for a document
|
|
2191
|
+
# that has some — which the overwhelming majority never do, so the ordinary
|
|
2192
|
+
# `body.cloneNode(true)` keeps walking nothing.
|
|
2193
|
+
def __internal_note_namespaced_element__(namespace, prefix)
|
|
2194
|
+
return if namespace == Element::HTML_NAMESPACE && prefix.nil?
|
|
2195
|
+
|
|
2196
|
+
@namespaced_elements = true
|
|
2197
|
+
nil
|
|
2198
|
+
end
|
|
2199
|
+
|
|
2200
|
+
def __internal_namespaced_elements__?
|
|
2201
|
+
@namespaced_elements == true
|
|
2202
|
+
end
|
|
2203
|
+
|
|
1705
2204
|
# Clear the cached wrapper so the next `wrap_node` creates a new
|
|
1706
2205
|
# one. Used by `customElements.define` to upgrade nodes that were
|
|
1707
2206
|
# constructed before the registration landed.
|
|
@@ -1727,6 +2226,33 @@ module Dommy
|
|
|
1727
2226
|
@shadow_registry.find_for_fragment(fragment_node)
|
|
1728
2227
|
end
|
|
1729
2228
|
|
|
2229
|
+
def __internal_shadow_root_for_host__(host_node)
|
|
2230
|
+
@shadow_registry.find_for_host(host_node)
|
|
2231
|
+
end
|
|
2232
|
+
|
|
2233
|
+
# Every element among `root`'s shadow-including inclusive descendants, as
|
|
2234
|
+
# backend nodes, in shadow-including tree order: an element, then the shadow
|
|
2235
|
+
# tree it hosts, then its children. The whole list, shadow trees included, is
|
|
2236
|
+
# taken before the first element is yielded. Callbacks run synchronously here,
|
|
2237
|
+
# and one that attaches a shadow root or inserts elements triggers those
|
|
2238
|
+
# changes' own reactions; walking into them as well would run them twice.
|
|
2239
|
+
def __internal_each_shadow_including_element__(root, &block)
|
|
2240
|
+
shadow_including_elements(root).each(&block)
|
|
2241
|
+
nil
|
|
2242
|
+
end
|
|
2243
|
+
|
|
2244
|
+
def shadow_including_elements(root, list = [])
|
|
2245
|
+
elements = root.respond_to?(:element?) && root.element? ? [root] : []
|
|
2246
|
+
elements.concat(root.css("*").to_a) if root.respond_to?(:css)
|
|
2247
|
+
elements.each do |element|
|
|
2248
|
+
list << element
|
|
2249
|
+
shadow = @shadow_registry.find_for_host(element)
|
|
2250
|
+
shadow_including_elements(shadow.__dommy_backend_node__, list) if shadow
|
|
2251
|
+
end
|
|
2252
|
+
list
|
|
2253
|
+
end
|
|
2254
|
+
private :shadow_including_elements
|
|
2255
|
+
|
|
1730
2256
|
def __internal_shadow_root_containing__(node)
|
|
1731
2257
|
@shadow_registry.find_enclosing(node)
|
|
1732
2258
|
end
|
|
@@ -1754,6 +2280,12 @@ module Dommy
|
|
|
1754
2280
|
@mutation_coordinator.notify_connected_subtree(nk)
|
|
1755
2281
|
end
|
|
1756
2282
|
|
|
2283
|
+
# The custom element reactions of a move (moveBefore), for a node that ended
|
|
2284
|
+
# up connected.
|
|
2285
|
+
def __internal_notify_moved_subtree__(nk)
|
|
2286
|
+
@mutation_coordinator.notify_moved_subtree(nk)
|
|
2287
|
+
end
|
|
2288
|
+
|
|
1757
2289
|
def __internal_notify_disconnected_subtree__(nk)
|
|
1758
2290
|
@mutation_coordinator.notify_disconnected_subtree(nk)
|
|
1759
2291
|
end
|
|
@@ -1770,22 +2302,114 @@ module Dommy
|
|
|
1770
2302
|
@mutation_coordinator.unregister_observer(observer)
|
|
1771
2303
|
end
|
|
1772
2304
|
|
|
2305
|
+
# Queue the childList record for a mutation. The live-range insertion steps
|
|
2306
|
+
# are NOT run here: WHATWG puts them at insert step 5, before the nodes are
|
|
2307
|
+
# converted and linked, so every insertion site calls
|
|
2308
|
+
# __internal_ranges_will_insert__ itself, at that point.
|
|
1773
2309
|
def notify_child_list_mutation(
|
|
1774
2310
|
target_node:,
|
|
1775
2311
|
added_nodes:,
|
|
1776
2312
|
removed_nodes:,
|
|
1777
2313
|
previous_sibling: nil,
|
|
1778
|
-
next_sibling: nil
|
|
2314
|
+
next_sibling: nil,
|
|
2315
|
+
moving: false
|
|
1779
2316
|
)
|
|
1780
2317
|
@mutation_coordinator.notify_child_list_mutation(
|
|
1781
2318
|
target_node: target_node,
|
|
1782
2319
|
added_nodes: added_nodes,
|
|
1783
2320
|
removed_nodes: removed_nodes,
|
|
1784
2321
|
previous_sibling: previous_sibling,
|
|
1785
|
-
next_sibling: next_sibling
|
|
2322
|
+
next_sibling: next_sibling,
|
|
2323
|
+
moving: moving
|
|
2324
|
+
)
|
|
2325
|
+
end
|
|
2326
|
+
|
|
2327
|
+
# WHATWG "removing steps", run while `node` is STILL attached (they are all
|
|
2328
|
+
# expressed in terms of the position it is about to vacate). Every path that
|
|
2329
|
+
# takes a node out of its parent — an explicit removeChild, the implicit
|
|
2330
|
+
# removal a move performs, replaceChildren, textContent=, fragment
|
|
2331
|
+
# extraction — must go through here, or a live Range / NodeIterator anchored
|
|
2332
|
+
# in the vacated position is left pointing at a detached node.
|
|
2333
|
+
#
|
|
2334
|
+
# A document with no live range and no NodeIterator has nothing to observe
|
|
2335
|
+
# the vacated position, so the whole thing collapses to two predicate calls
|
|
2336
|
+
# — this runs once per removed node, and a bulk replaceChildren /
|
|
2337
|
+
# textContent= must not pay for machinery nobody is watching.
|
|
2338
|
+
def pre_remove_node(node)
|
|
2339
|
+
return nil if !node_iterators? && !live_ranges?
|
|
2340
|
+
return nil unless node.parent
|
|
2341
|
+
|
|
2342
|
+
run_node_iterator_pre_remove(node)
|
|
2343
|
+
__internal_ranges_will_remove__(node)
|
|
2344
|
+
nil
|
|
2345
|
+
end
|
|
2346
|
+
|
|
2347
|
+
# A childList mutation on the DOCUMENT's own child list (its doctype, the
|
|
2348
|
+
# document element, a stray comment). Document-level mutation is observable
|
|
2349
|
+
# like any other — `observe(document, {childList: true})` is legal — so it
|
|
2350
|
+
# goes through the same pipeline rather than only nudging live ranges.
|
|
2351
|
+
# Insert step 6 for a document parent: the reference child's previous
|
|
2352
|
+
# sibling, or the document's last child when appending.
|
|
2353
|
+
def document_insertion_previous_sibling(ref_bn)
|
|
2354
|
+
node = ref_bn ? ref_bn.previous : @backend_doc.children.to_a.last
|
|
2355
|
+
node && wrap_node(node)
|
|
2356
|
+
end
|
|
2357
|
+
|
|
2358
|
+
def notify_document_child_list(added: [], removed: [], previous_sibling: nil, next_sibling: nil, moving: false)
|
|
2359
|
+
notify_child_list_mutation(
|
|
2360
|
+
target_node: @backend_doc,
|
|
2361
|
+
added_nodes: added,
|
|
2362
|
+
removed_nodes: removed,
|
|
2363
|
+
previous_sibling: previous_sibling,
|
|
2364
|
+
next_sibling: next_sibling,
|
|
2365
|
+
moving: moving
|
|
1786
2366
|
)
|
|
1787
2367
|
end
|
|
1788
2368
|
|
|
2369
|
+
# The single detach primitive: pre-removing steps, then unlink. Callers that
|
|
2370
|
+
# batch several removals into one childList record (replaceChildren,
|
|
2371
|
+
# textContent=, replaceChild) use this and queue the record themselves;
|
|
2372
|
+
# `remove_node_with_notify` is this plus a per-node record.
|
|
2373
|
+
#
|
|
2374
|
+
# `moving:` marks the first half of the "move" primitive (moveBefore). A
|
|
2375
|
+
# move takes the node out of its old parent's children only to put it
|
|
2376
|
+
# straight back into the same shadow-including tree: it never leaves the
|
|
2377
|
+
# document, so nothing that reacts to a removal from the document may run.
|
|
2378
|
+
def detach_node(node, moving: false)
|
|
2379
|
+
pre_remove_node(node)
|
|
2380
|
+
add_transient_observers_for(node)
|
|
2381
|
+
node.unlink
|
|
2382
|
+
# A removal can take a shadow tree, and a selection range in it, out of
|
|
2383
|
+
# the document without moving the range; the selection lets go of it.
|
|
2384
|
+
@__selection&.__internal_node_removed__ unless moving
|
|
2385
|
+
node
|
|
2386
|
+
end
|
|
2387
|
+
|
|
2388
|
+
# WHATWG remove step 20: every subtree registration reachable from the old
|
|
2389
|
+
# parent's inclusive ancestors gains a transient registered observer on the
|
|
2390
|
+
# node being removed, so a subtree observer keeps seeing mutations inside
|
|
2391
|
+
# the just-removed subtree until the next microtask checkpoint.
|
|
2392
|
+
#
|
|
2393
|
+
# The step is NOT guarded by suppressObservers (only step 21's record is),
|
|
2394
|
+
# so it has to run here, in the removal primitive, rather than alongside the
|
|
2395
|
+
# record. Replace all step 3, insert step 4 and replace step 7 all remove
|
|
2396
|
+
# with observers suppressed.
|
|
2397
|
+
def add_transient_observers_for(node)
|
|
2398
|
+
return unless @observer_manager.any?
|
|
2399
|
+
|
|
2400
|
+
parent = node.parent
|
|
2401
|
+
return unless parent
|
|
2402
|
+
|
|
2403
|
+
target = wrap_node(parent)
|
|
2404
|
+
removed = wrap_node(node)
|
|
2405
|
+
return unless target && removed
|
|
2406
|
+
|
|
2407
|
+
@observer_manager.observers_matching(target).each do |observer|
|
|
2408
|
+
entry = observer.find_matching_entry(target)
|
|
2409
|
+
observer.add_transient(removed, entry) if entry && entry[:subtree]
|
|
2410
|
+
end
|
|
2411
|
+
end
|
|
2412
|
+
|
|
1789
2413
|
# Unlink a backend node from its parent and queue a childList removal record
|
|
1790
2414
|
# capturing the node's position (previous/next sibling) BEFORE the unlink, so
|
|
1791
2415
|
# the record's previousSibling/nextSibling are correct (the coordinator can't
|
|
@@ -1796,8 +2420,7 @@ module Dommy
|
|
|
1796
2420
|
|
|
1797
2421
|
prev_w = node.previous_sibling && wrap_node(node.previous_sibling)
|
|
1798
2422
|
next_w = node.next_sibling && wrap_node(node.next_sibling)
|
|
1799
|
-
|
|
1800
|
-
node.unlink
|
|
2423
|
+
detach_node(node)
|
|
1801
2424
|
notify_child_list_mutation(
|
|
1802
2425
|
target_node: parent,
|
|
1803
2426
|
added_nodes: [],
|
|
@@ -1807,14 +2430,198 @@ module Dommy
|
|
|
1807
2430
|
)
|
|
1808
2431
|
end
|
|
1809
2432
|
|
|
2433
|
+
# --- Live ranges -------------------------------------------------
|
|
2434
|
+
# Ranges are live: a DOM mutation moves their boundary points so they keep
|
|
2435
|
+
# designating the same content. They are held weakly, so a range the caller
|
|
2436
|
+
# drops is collected rather than pinned for the document's lifetime.
|
|
2437
|
+
|
|
2438
|
+
def __internal_register_range__(range)
|
|
2439
|
+
@live_ranges ||= ObjectSpace::WeakMap.new
|
|
2440
|
+
@live_ranges[range] = true
|
|
2441
|
+
nil
|
|
2442
|
+
end
|
|
2443
|
+
|
|
2444
|
+
def __internal_each_live_range__
|
|
2445
|
+
return if @live_ranges.nil? || @live_ranges.size.zero?
|
|
2446
|
+
|
|
2447
|
+
@live_ranges.each_key { |range| yield range }
|
|
2448
|
+
end
|
|
2449
|
+
|
|
2450
|
+
def live_ranges?
|
|
2451
|
+
!@live_ranges.nil? && @live_ranges.size.positive?
|
|
2452
|
+
end
|
|
2453
|
+
|
|
2454
|
+
# WHATWG "removing steps" for live ranges. Must run while `backend_node` is
|
|
2455
|
+
# still attached, since the rules are expressed in terms of the position it
|
|
2456
|
+
# is about to vacate.
|
|
2457
|
+
def __internal_ranges_will_remove__(backend_node)
|
|
2458
|
+
return unless live_ranges?
|
|
2459
|
+
|
|
2460
|
+
parent = backend_node.parent
|
|
2461
|
+
return unless parent
|
|
2462
|
+
|
|
2463
|
+
removed = wrap_node(backend_node)
|
|
2464
|
+
parent_wrapper = wrap_node(parent)
|
|
2465
|
+
affected = live_ranges_where { |r| r.__internal_affected_by_removal__(removed, parent_wrapper) }
|
|
2466
|
+
return if affected.empty?
|
|
2467
|
+
|
|
2468
|
+
index = child_index_of_wrapper(parent_wrapper, removed)
|
|
2469
|
+
return unless index
|
|
2470
|
+
|
|
2471
|
+
affected.each { |r| r.__internal_apply_remove__(removed, parent_wrapper, index) }
|
|
2472
|
+
end
|
|
2473
|
+
|
|
2474
|
+
# WHATWG "insert a node into a parent before a child", step 5 — the
|
|
2475
|
+
# live-range offset shift.
|
|
2476
|
+
#
|
|
2477
|
+
# It runs BEFORE step 7 adopts each node (which removes it from wherever it
|
|
2478
|
+
# is now), so `child`'s index, and every boundary it shifts, are measured
|
|
2479
|
+
# against the tree as it stands before the insertion begins. Running it
|
|
2480
|
+
# afterwards double-counts a boundary that one of those removals has just
|
|
2481
|
+
# moved onto `parent`: `parent.insertBefore(second, first)` with a range
|
|
2482
|
+
# inside `second` leaves that boundary at `(parent, 1)` per spec, but at
|
|
2483
|
+
# `(parent, 2)` if the shift is applied after the move.
|
|
2484
|
+
#
|
|
2485
|
+
# Appending (a null `child`) shifts nothing: a boundary at the parent's end
|
|
2486
|
+
# stays before the new nodes.
|
|
2487
|
+
def __internal_ranges_will_insert__(parent_backend_node, ref_backend_node, count)
|
|
2488
|
+
return if ref_backend_node.nil? || count.zero?
|
|
2489
|
+
return unless live_ranges?
|
|
2490
|
+
|
|
2491
|
+
parent_wrapper = wrap_node(parent_backend_node)
|
|
2492
|
+
return unless parent_wrapper.respond_to?(:child_nodes)
|
|
2493
|
+
|
|
2494
|
+
affected = live_ranges_where { |r| r.__internal_anchored_at__(parent_wrapper) }
|
|
2495
|
+
return if affected.empty?
|
|
2496
|
+
|
|
2497
|
+
index = child_index_of_wrapper(parent_wrapper, wrap_node(ref_backend_node))
|
|
2498
|
+
return unless index
|
|
2499
|
+
|
|
2500
|
+
affected.each { |r| r.__internal_apply_insert__(parent_wrapper, index, count) }
|
|
2501
|
+
end
|
|
2502
|
+
|
|
2503
|
+
# WHATWG normalize() steps 6.1-6.4. `current` is a contiguous exclusive Text
|
|
2504
|
+
# sibling whose data has just been appended to `node` at `length`; its own
|
|
2505
|
+
# boundaries — and a parent-anchored boundary pointing AT it — follow the
|
|
2506
|
+
# data into the merged node. Run for every merged sibling before any of them
|
|
2507
|
+
# is removed, so the indices still describe the pre-removal tree.
|
|
2508
|
+
def __internal_ranges_normalize_merge__(node, current, length)
|
|
2509
|
+
return unless live_ranges?
|
|
2510
|
+
|
|
2511
|
+
merged_into = wrap_node(node)
|
|
2512
|
+
current_wrapper = wrap_node(current)
|
|
2513
|
+
parent = current.parent && wrap_node(current.parent)
|
|
2514
|
+
index = parent && child_index_of_wrapper(parent, current_wrapper)
|
|
2515
|
+
__internal_each_live_range__ do |range|
|
|
2516
|
+
range.__internal_apply_normalize_merge__(merged_into, current_wrapper, length, parent, index)
|
|
2517
|
+
end
|
|
2518
|
+
end
|
|
2519
|
+
|
|
2520
|
+
# Node.normalize() on the document: every Text run in the tree.
|
|
2521
|
+
def normalize
|
|
2522
|
+
__internal_normalize__(@backend_doc)
|
|
2523
|
+
end
|
|
2524
|
+
|
|
2525
|
+
# Node#normalize — merge each run of adjacent exclusive Text descendants
|
|
2526
|
+
# into its first node (preserving that node's identity, so a JS reference
|
|
2527
|
+
# to it survives) and drop empty Text nodes. Recurses the whole subtree,
|
|
2528
|
+
# so it serves Element, DocumentFragment, ShadowRoot and the Document alike.
|
|
2529
|
+
#
|
|
2530
|
+
# A run is merged one sibling at a time: append the sibling's data to the
|
|
2531
|
+
# survivor (a characterData record), hand its live range boundaries over,
|
|
2532
|
+
# remove it (a childList record), then the next. Read literally, the spec
|
|
2533
|
+
# concatenates every sibling's data first (steps 3-4, one "replace data")
|
|
2534
|
+
# and removes them afterwards (step 7), which would queue ONE
|
|
2535
|
+
# characterData record per run. Every shipping engine merges pairwise
|
|
2536
|
+
# instead — Blink, WebCore and Gecko all answer [characterData, childList,
|
|
2537
|
+
# characterData, childList, …] for a run of four text nodes, confirmed by
|
|
2538
|
+
# running the same script in Chromium 141, WebKitGTK 2.52.6 and Firefox —
|
|
2539
|
+
# and the WPT suite fixes only the childList side, so the records follow
|
|
2540
|
+
# the engines. The tree and every live range boundary end up exactly where
|
|
2541
|
+
# the spec's steps put them: a boundary in a later sibling (or on the
|
|
2542
|
+
# parent, pointing at one) is shifted down by each earlier removal and
|
|
2543
|
+
# then handed over at the survivor's length of that moment, which is the
|
|
2544
|
+
# same offset the batch steps compute up front.
|
|
2545
|
+
# https://github.com/takahashim/dommy/issues/24
|
|
2546
|
+
def __internal_normalize__(root)
|
|
2547
|
+
text_nodes = []
|
|
2548
|
+
root.traverse { |node| text_nodes << node if node.respond_to?(:text?) && node.text? }
|
|
2549
|
+
|
|
2550
|
+
text_nodes.each do |node|
|
|
2551
|
+
next unless node.parent # already removed as part of an earlier run
|
|
2552
|
+
|
|
2553
|
+
if node.content.to_s.empty?
|
|
2554
|
+
remove_node_with_notify(node)
|
|
2555
|
+
next
|
|
2556
|
+
end
|
|
2557
|
+
|
|
2558
|
+
sib = node.next
|
|
2559
|
+
while sib.respond_to?(:text?) && sib.text?
|
|
2560
|
+
following = sib.next
|
|
2561
|
+
data = sib.content.to_s
|
|
2562
|
+
# The offset the sibling's data lands at inside the survivor — the
|
|
2563
|
+
# length of what it already holds, measured before the append.
|
|
2564
|
+
length = wrap_node(node).length
|
|
2565
|
+
# An empty sibling has nothing to append: the engines skip the data
|
|
2566
|
+
# step for it (Gecko checks the length, Blink and WebCore behave the
|
|
2567
|
+
# same), so it is removed without a characterData record. Its range
|
|
2568
|
+
# boundaries still move to the survivor's join.
|
|
2569
|
+
unless data.empty?
|
|
2570
|
+
old = node.content.to_s
|
|
2571
|
+
node.content = old + data
|
|
2572
|
+
notify_character_data_mutation(target_node: node, old_value: old)
|
|
2573
|
+
end
|
|
2574
|
+
# WHATWG normalize() step 6: the merged-away sibling hands its live
|
|
2575
|
+
# range boundaries to the survivor at that offset BEFORE it is
|
|
2576
|
+
# removed, or the plain removing steps would strand them on the
|
|
2577
|
+
# parent.
|
|
2578
|
+
__internal_ranges_normalize_merge__(node, sib, length)
|
|
2579
|
+
remove_node_with_notify(sib)
|
|
2580
|
+
sib = following
|
|
2581
|
+
end
|
|
2582
|
+
end
|
|
2583
|
+
|
|
2584
|
+
nil
|
|
2585
|
+
end
|
|
2586
|
+
|
|
2587
|
+
def __internal_ranges_replaced_data__(node, offset, count, new_length)
|
|
2588
|
+
return unless live_ranges?
|
|
2589
|
+
|
|
2590
|
+
__internal_each_live_range__ { |r| r.__internal_apply_replace_data__(node, offset, count, new_length) }
|
|
2591
|
+
end
|
|
2592
|
+
|
|
2593
|
+
def __internal_ranges_split_text__(node, offset, new_node)
|
|
2594
|
+
return unless live_ranges?
|
|
2595
|
+
|
|
2596
|
+
parent = node.parent_node
|
|
2597
|
+
# Only the parent-anchored rule needs an index, so resolve one lazily.
|
|
2598
|
+
index =
|
|
2599
|
+
if parent && live_ranges_where { |r| r.__internal_anchored_at__(parent) }.any?
|
|
2600
|
+
child_index_of_wrapper(parent, node)
|
|
2601
|
+
end
|
|
2602
|
+
__internal_each_live_range__ { |r| r.__internal_apply_split__(node, offset, new_node, parent, index) }
|
|
2603
|
+
end
|
|
2604
|
+
|
|
2605
|
+
def live_ranges_where
|
|
2606
|
+
out = []
|
|
2607
|
+
__internal_each_live_range__ { |r| out << r if yield(r) }
|
|
2608
|
+
out
|
|
2609
|
+
end
|
|
2610
|
+
|
|
2611
|
+
def child_index_of_wrapper(parent_wrapper, child_wrapper)
|
|
2612
|
+
return nil unless parent_wrapper.respond_to?(:child_nodes)
|
|
2613
|
+
|
|
2614
|
+
parent_wrapper.child_nodes.to_a.index { |c| c.equal?(child_wrapper) }
|
|
2615
|
+
end
|
|
2616
|
+
|
|
1810
2617
|
# Run the "NodeIterator pre-removing steps" for every live iterator before
|
|
1811
2618
|
# `backend_node` is detached, so referenceNode/pointerBeforeReferenceNode
|
|
1812
2619
|
# stay valid. `backend_node` must still be attached (tree intact) here.
|
|
1813
2620
|
def run_node_iterator_pre_remove(backend_node)
|
|
1814
|
-
return
|
|
2621
|
+
return unless node_iterators?
|
|
1815
2622
|
|
|
1816
2623
|
removed = wrap_node(backend_node)
|
|
1817
|
-
@node_iterators.
|
|
2624
|
+
@node_iterators.each_key { |iter| iter.pre_remove(removed) }
|
|
1818
2625
|
end
|
|
1819
2626
|
|
|
1820
2627
|
def notify_attribute_mutation(target_node:, attribute_name:, old_value:, namespace: nil)
|
|
@@ -1895,41 +2702,106 @@ module Dommy
|
|
|
1895
2702
|
# Build a Nokogiri copy of the given node inside our @backend_doc.
|
|
1896
2703
|
# `deep: true` recurses into children. Used by importNode and
|
|
1897
2704
|
# adoptNode for cross-document transfer.
|
|
1898
|
-
def clone_into_doc(source, deep)
|
|
1899
|
-
copy =
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
2705
|
+
def clone_into_doc(source, deep, source_document = self)
|
|
2706
|
+
copy = clone_single_node_into_doc(source, source_document)
|
|
2707
|
+
|
|
2708
|
+
if source.element? && source.name == "template"
|
|
2709
|
+
# A <template>'s contents live in a separate content fragment, not its
|
|
2710
|
+
# child list, so the generic deep pass over `children` misses them.
|
|
2711
|
+
clone_template_content(source, copy, source_document) if deep
|
|
2712
|
+
elsif deep && source.respond_to?(:children)
|
|
2713
|
+
source.children.each do |child|
|
|
2714
|
+
copy.add_child(clone_into_doc(child, true, source_document))
|
|
2715
|
+
end
|
|
2716
|
+
end
|
|
2717
|
+
|
|
2718
|
+
copy
|
|
2719
|
+
end
|
|
2720
|
+
|
|
2721
|
+
# "Clone a single node" (§4.4): the copy implements the SAME interface as
|
|
2722
|
+
# the original — a ProcessingInstruction clones to one, not to the comment
|
|
2723
|
+
# its serialization looks like — and carries the same data. Elements are
|
|
2724
|
+
# cloned by #clone_element_into_doc; children are the caller's business.
|
|
2725
|
+
def clone_single_node_into_doc(source, source_document)
|
|
2726
|
+
if source.element?
|
|
2727
|
+
clone_element_into_doc(source, source_document)
|
|
2728
|
+
elsif (cdata = Backend.cdata_class) && source.is_a?(cdata)
|
|
2729
|
+
# CDATA is a Text subtype in the backend, so ask about it first.
|
|
2730
|
+
Backend.create_cdata(source.content, @backend_doc)
|
|
1903
2731
|
elsif source.text?
|
|
1904
2732
|
Backend.create_text(source.content, @backend_doc)
|
|
1905
2733
|
elsif source.is_a?(Backend.comment_class)
|
|
1906
2734
|
Backend.create_comment(source.content, @backend_doc)
|
|
2735
|
+
elsif source.is_a?(Backend.processing_instruction_class)
|
|
2736
|
+
Backend.create_processing_instruction(source.target, source.content, @backend_doc)
|
|
1907
2737
|
elsif source.is_a?(Backend.document_fragment_class)
|
|
1908
2738
|
# A DocumentFragment clones to a fragment (its children are appended by
|
|
1909
|
-
# the deep pass
|
|
2739
|
+
# the deep pass), NOT to its first child — `importNode(<template>
|
|
1910
2740
|
# .content, true)` must return a fragment so `.firstElementChild` works
|
|
1911
2741
|
# (Vue/Alpine x-for clone template content this way). Built via the
|
|
1912
2742
|
# document's own `fragment` (as TemplateContentRegistry does) rather than
|
|
1913
2743
|
# `document_fragment_class.new`, so it works on backends whose fragment
|
|
1914
2744
|
# class isn't directly instantiable (Makiri).
|
|
1915
|
-
|
|
2745
|
+
Parser.fragment("", owner_doc: @backend_doc)
|
|
2746
|
+
elsif (doctype = Backend.document_type_class) && source.is_a?(doctype)
|
|
2747
|
+
wrapper = source_document.wrap_node(source)
|
|
2748
|
+
Backend.create_document_type(wrapper.name, wrapper.public_id, wrapper.system_id, @backend_doc)
|
|
1916
2749
|
else
|
|
1917
|
-
|
|
1918
|
-
fragment = Parser.fragment(source.to_html, owner_doc: @backend_doc)
|
|
1919
|
-
fragment.children.first || Backend.create_text("", @backend_doc)
|
|
2750
|
+
Backend.create_text("", @backend_doc)
|
|
1920
2751
|
end
|
|
2752
|
+
end
|
|
1921
2753
|
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2754
|
+
# An element's copy keeps its namespace, its prefix and the exact spelling
|
|
2755
|
+
# of its local name. None of that is on the backend node — the HTML backend
|
|
2756
|
+
# tracks no namespace for an element script created, and createElementNS's
|
|
2757
|
+
# case lives on the wrapper — so the original is read through its wrapper
|
|
2758
|
+
# and the copy is given the same metadata.
|
|
2759
|
+
def clone_element_into_doc(source, source_document)
|
|
2760
|
+
wrapper = source_document.wrap_node(source)
|
|
2761
|
+
namespace, prefix, local, qualified = clone_name_parts(wrapper, source)
|
|
2762
|
+
copy = Backend.create_element_loose(qualified, prefix, local, namespace, @backend_doc) ||
|
|
2763
|
+
Backend.create_element(source.name, @backend_doc)
|
|
2764
|
+
copy_attributes_into(source, copy)
|
|
2765
|
+
note_cloned_element_namespace(copy, namespace, prefix, local, qualified)
|
|
2766
|
+
copy
|
|
2767
|
+
end
|
|
2768
|
+
|
|
2769
|
+
# The four parts of the original's name — namespace, prefix, local name and
|
|
2770
|
+
# qualified name. The wrapper's own metadata has them whenever there is
|
|
2771
|
+
# anything to carry (createElementNS's case and prefix, an XML document's,
|
|
2772
|
+
# the HTML parser's SVG names); otherwise the element is an unprefixed one
|
|
2773
|
+
# whose namespace the backend knows.
|
|
2774
|
+
def clone_name_parts(wrapper, source)
|
|
2775
|
+
meta = wrapper.__internal_namespace_metadata__ if wrapper.respond_to?(:__internal_namespace_metadata__)
|
|
2776
|
+
return meta if meta
|
|
2777
|
+
|
|
2778
|
+
local = wrapper ? wrapper.local_name.to_s : source.name
|
|
2779
|
+
[wrapper&.namespace_uri, nil, local, local]
|
|
2780
|
+
end
|
|
2781
|
+
|
|
2782
|
+
# "Clone a single node" step 2.1 clones the attributes one by one, so each
|
|
2783
|
+
# keeps its own namespace and prefix — an `xml:b` must not flatten into an
|
|
2784
|
+
# attribute whose local name is the qualified string "xml:b".
|
|
2785
|
+
def copy_attributes_into(source, copy)
|
|
2786
|
+
Backend.attribute_nodes(source).each do |attr|
|
|
2787
|
+
info = Backend.attribute_ns_info(attr)
|
|
2788
|
+
if info[:namespace_uri]
|
|
2789
|
+
Backend.set_attribute_ns(copy, info[:namespace_uri], info[:prefix], info[:local_name],
|
|
2790
|
+
info[:qualified_name], info[:value])
|
|
2791
|
+
else
|
|
2792
|
+
copy[info[:qualified_name]] = info[:value]
|
|
1929
2793
|
end
|
|
1930
2794
|
end
|
|
2795
|
+
nil
|
|
2796
|
+
end
|
|
1931
2797
|
|
|
1932
|
-
|
|
2798
|
+
# Give the copy's wrapper the namespace metadata whenever the backend node
|
|
2799
|
+
# alone would report something else (see #clone_element_into_doc).
|
|
2800
|
+
def note_cloned_element_namespace(copy, namespace, prefix, local, qualified)
|
|
2801
|
+
derived = Backend.namespace_of(copy)&.href || (html_document? ? Element::HTML_NAMESPACE : nil)
|
|
2802
|
+
return if derived == namespace && prefix.nil? && local == copy.name
|
|
2803
|
+
|
|
2804
|
+
wrap_cloned_element_ns(copy, namespace, prefix, local, qualified)
|
|
1933
2805
|
end
|
|
1934
2806
|
|
|
1935
2807
|
# Clone a <template>'s content into a fragment registered as `copy`'s
|
|
@@ -1937,13 +2809,14 @@ module Dommy
|
|
|
1937
2809
|
# keeps it in a native content fragment, Nokogiri keeps it as direct children
|
|
1938
2810
|
# before migration and in the registry after — so source it from the registry
|
|
1939
2811
|
# fragment when migrated, else from Backend.template_content_nodes.
|
|
1940
|
-
def clone_template_content(source, copy)
|
|
1941
|
-
|
|
2812
|
+
def clone_template_content(source, copy, source_document = self)
|
|
2813
|
+
registry = source_document.__internal_template_registry__
|
|
2814
|
+
src_frag = registry.raw_fragment_for(source)
|
|
1942
2815
|
content_nodes = src_frag ? src_frag.children.to_a : Backend.template_content_nodes(source)
|
|
1943
2816
|
return if content_nodes.empty?
|
|
1944
2817
|
|
|
1945
|
-
frag =
|
|
1946
|
-
content_nodes.each { |n| frag.add_child(clone_into_doc(n, true)) }
|
|
2818
|
+
frag = Parser.fragment("", owner_doc: @backend_doc)
|
|
2819
|
+
content_nodes.each { |n| frag.add_child(clone_into_doc(n, true, source_document)) }
|
|
1947
2820
|
@template_content_registry.store(copy, frag)
|
|
1948
2821
|
end
|
|
1949
2822
|
|
|
@@ -1968,7 +2841,7 @@ module Dommy
|
|
|
1968
2841
|
head.add_child(title)
|
|
1969
2842
|
end
|
|
1970
2843
|
|
|
1971
|
-
title.children.each(
|
|
2844
|
+
title.children.to_a.each { |child| detach_node(child) }
|
|
1972
2845
|
title.add_child(Backend.create_text(value, @backend_doc))
|
|
1973
2846
|
end
|
|
1974
2847
|
|