Class: Dommy::CharacterDataNode

Inherits:
Object
  • Object
show all
Includes:
Bridge::Methods, EventTarget, Internal::ChildNode, Node
Defined in:
lib/dommy/element.rb

Overview

CharacterData base — TextNode and CommentNode share the data / nodeValue / textContent API and remove / cloneNode semantics.

Direct Known Subclasses

CommentNode, ProcessingInstructionNode, TextNode

Constant Summary

Constants included from Node

Node::ATTRIBUTE_NODE, Node::CDATA_SECTION_NODE, Node::COMMENT_NODE, Node::DOCUMENT_FRAGMENT_NODE, Node::DOCUMENT_NODE, Node::DOCUMENT_POSITION_CONTAINED_BY, Node::DOCUMENT_POSITION_CONTAINS, Node::DOCUMENT_POSITION_DISCONNECTED, Node::DOCUMENT_POSITION_FOLLOWING, Node::DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC, Node::DOCUMENT_POSITION_PRECEDING, Node::DOCUMENT_TYPE_NODE, Node::ELEMENT_NODE, Node::HTML_NAMESPACE, Node::PROCESSING_INSTRUCTION_NODE, Node::TEXT_NODE, Node::XMLNS_NAMESPACE, Node::XML_NAMESPACE

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Bridge::Methods

included

Methods included from Internal::ChildNode

#child_node_after, #child_node_before, #child_node_replace_with, #nullable_dom_string, #validate_insert_before_ref!

Methods included from EventTarget

#__dommy_dump_event_failure__, #__internal_deliver_event__, #__internal_process_event_handler_return__, #add_event_listener, capture_flag, #deliver_at, #dispatch_event, #event_name_from_on, #invoke_listener_isolated, js_truthy?, #on_handler, #remove_event_listener, #set_on_handler

Methods included from Node

#compare_document_position, #get_root_node, #is_default_namespace, #is_equal_node, #is_same_node, #lookup_namespace_uri, #lookup_prefix

Constructor Details

#initialize(document, nokogiri_node) ⇒ CharacterDataNode

Returns a new instance of CharacterDataNode.



313
314
315
316
# File 'lib/dommy/element.rb', line 313

def initialize(document, nokogiri_node)
  @document = document
  @__node__ = nokogiri_node
end

Instance Attribute Details

#documentObject (readonly)

The owning Dommy document (as Element exposes), so cross-document adoption checks work for text/comment nodes too.



284
285
286
# File 'lib/dommy/element.rb', line 284

def document
  @document
end

Instance Method Details

#[](key) ⇒ Object



367
368
369
# File 'lib/dommy/element.rb', line 367

def [](key)
  __js_get__(key.to_s)
end

#[]=(key, value) ⇒ Object



371
372
373
# File 'lib/dommy/element.rb', line 371

def []=(key, value)
  __js_set__(key.to_s, value)
end

#__dommy_backend_node__Object



286
# File 'lib/dommy/element.rb', line 286

def __dommy_backend_node__ = @__node__

#__internal_event_parent__Object

EventTarget needs a parent for event propagation; a character-data node bubbles to its parent element.



290
291
292
# File 'lib/dommy/element.rb', line 290

def __internal_event_parent__
  @__node__.parent && @document.wrap_node(@__node__.parent)
end

#__js_call__(method, args) ⇒ Object



546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
# File 'lib/dommy/element.rb', line 546

def __js_call__(method, args)
  case method
  when "hasChildNodes"
    false
  when "contains"
    # A leaf node contains only itself (no descendants).
    args[0].respond_to?(:__dommy_backend_node__) &&
      args[0].__dommy_backend_node__ == @__node__
  when "appendChild", "insertBefore", "replaceChild"
    # WebIDL coerces the Node argument first (null/non-Node → TypeError);
    # only then does the leaf reject the insertion. WHATWG pre-insert /
    # replace step 1 checks the PARENT type before the reference child, so a
    # leaf parent is a HierarchyRequestError even when `child` isn't a child.
    raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)

    raise DOMException::HierarchyRequestError, "this node type does not support children"
  when "removeChild"
    raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)

    raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
  when "compareDocumentPosition"
    compare_document_position(args[0])
  when "isSameNode"
    is_same_node(args[0])
  when "getRootNode"
    get_root_node(args[0])
  when "lookupNamespaceURI"
    lookup_namespace_uri(args[0])
  when "lookupPrefix"
    lookup_prefix(args[0])
  when "isDefaultNamespace"
    is_default_namespace(args[0])
  when "normalize"
    nil # a leaf has no child text runs to merge
  when "splitText"
    split_text(args[0])
  when "addEventListener"
    add_event_listener(args[0], args[1], args[2])
  when "removeEventListener"
    remove_event_listener(args[0], args[1], args[2])
  when "dispatchEvent"
    dispatch_event(args[0])
  when "appendData"
    raise Bridge::TypeError, "appendData requires 1 argument." if args.empty?

    append_data(args[0])
  when "insertData"
    insert_data(args[0], args[1])
  when "deleteData"
    delete_data(args[0], args[1])
  when "replaceData"
    replace_data(args[0], args[1], args[2])
  when "substringData"
    raise Bridge::TypeError, "substringData requires 2 arguments." if args.length < 2

    substring_data(args[0], args[1])
  when "remove"
    remove
    Bridge::UNDEFINED # ChildNode#remove is void -> JS undefined, not null
  when "before"
    before(*args)
  when "after"
    after(*args)
  when "replaceWith"
    replace_with(*args)
  when "isEqualNode"
    is_equal_node(args[0])
  end
end

#__js_get__(key) ⇒ Object



472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'lib/dommy/element.rb', line 472

def __js_get__(key)
  case key
  when "nodeType"
    node_type
  when "nodeName"
    node_name
  when "textContent"
    @__node__.content
  when "data"
    @__node__.content
  when "nodeValue"
    @__node__.content
  when "length"
    length
  when "parentNode"
    parent_node
  when "parentElement"
    parent_element
  when "ownerDocument"
    @document
  when "nextSibling"
    next_sibling
  when "previousSibling"
    previous_sibling
  when "childNodes"
    # CharacterData is a leaf node: childNodes is always an empty (but
    # present and iterable) NodeList, and firstChild/lastChild are null.
    # DOM-walking code (e.g. idiomorph's morphChildren) iterates
    # `node.childNodes` on every node, so a missing one crashes it.
    NodeList.new
  when "firstChild", "lastChild"
    nil
  when "assignedSlot"
    assigned_slot
  else
    Bridge::ABSENT # unknown property: JS undefined, `in` absent
  end
end

#__js_set__(key, value) ⇒ Object



530
531
532
533
534
535
536
537
# File 'lib/dommy/element.rb', line 530

def __js_set__(key, value)
  case key
  when "textContent", "data", "nodeValue"
    write_data(value)
  end

  nil
end

#after(*args) ⇒ Object



626
627
628
# File 'lib/dommy/element.rb', line 626

def after(*args)
  child_node_after(args)
end

#append_data(value) ⇒ Object



444
445
446
# File 'lib/dommy/element.rb', line 444

def append_data(value)
  write_data(@__node__.content + dom_string(value))
end

#assigned_slotObject

Slottable mixin: the this text node is assigned to. A text node has no slot attribute, so it targets the default (unnamed) slot of its parent element's shadow tree; a closed shadow tree hides the assignment (null).



514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
# File 'lib/dommy/element.rb', line 514

def assigned_slot
  parent = @__node__.parent
  return nil unless parent.respond_to?(:element?) && parent.element?

  host = @document.wrap_node(parent)
  return nil unless host.respond_to?(:shadow_root)

  sr = host.shadow_root
  return nil unless sr
  return nil if sr.__js_get__("mode") == "closed"

  sr.query_selector_all("slot").find do |slot|
    (slot.respond_to?(:name) ? slot.name.to_s : "") == ""
  end
end

#before(*args) ⇒ Object

ChildNode mixin: WHATWG DOM defines before, after, replaceWith on all child nodes, including Text and Comment. The spec-correct algorithm (viable previous/next sibling, forward insertion, string coercion, Fragment/cross-document adoption) lives in Internal::ParentNode and is shared with Element — these just forward to it.



622
623
624
# File 'lib/dommy/element.rb', line 622

def before(*args)
  child_node_before(args)
end

#dataObject

Snake_case facade (CRuby idiomatic)



320
321
322
# File 'lib/dommy/element.rb', line 320

def data
  @__node__.content
end

#data=(value) ⇒ Object



324
325
326
# File 'lib/dommy/element.rb', line 324

def data=(value)
  write_data(value)
end

#delete_data(offset, count) ⇒ Object



458
459
460
# File 'lib/dommy/element.rb', line 458

def delete_data(offset, count)
  replace_data(offset, count, "")
end

#dom_string(value) ⇒ Object

WebIDL DOMString coercion for a CharacterData mutation argument: JS null becomes the string "null" (undefined already stringifies to "undefined").



450
451
452
# File 'lib/dommy/element.rb', line 450

def dom_string(value)
  value.nil? ? "null" : value.to_s
end

#insert_data(offset, value) ⇒ Object



454
455
456
# File 'lib/dommy/element.rb', line 454

def insert_data(offset, value)
  replace_data(offset, 0, value)
end

#lengthObject

CharacterData length / mutation methods. Offsets and counts are UTF-16 code units per spec; for BMP text (the common case) Ruby char indices match. Each mutating op routes through write_data, which fires the characterData MutationObserver record.



390
391
392
# File 'lib/dommy/element.rb', line 390

def length
  utf16_length(@__node__.content)
end

#next_siblingObject



359
360
361
# File 'lib/dommy/element.rb', line 359

def next_sibling
  @__node__.next && @document.wrap_node(@__node__.next)
end

#node_nameObject

WHATWG nodeName for character-data nodes is a per-type constant ("#text" / "#comment" / "#cdata-section"), not the element name.



377
378
379
380
381
382
383
# File 'lib/dommy/element.rb', line 377

def node_name
  case node_type
  when 3 then "#text"
  when 4 then "#cdata-section"
  when 8 then "#comment"
  end
end

#node_valueObject



328
329
330
# File 'lib/dommy/element.rb', line 328

def node_value
  @__node__.content
end

#node_value=(value) ⇒ Object



332
333
334
# File 'lib/dommy/element.rb', line 332

def node_value=(value)
  write_data(value)
end

#parent_elementObject

parentElement is the parent only when it is an element (a document or fragment parent is a parentNode but not a parentElement).



355
356
357
# File 'lib/dommy/element.rb', line 355

def parent_element
  @document.wrap_node(@__node__.parent) if @__node__.parent&.element?
end

#parent_nodeObject



349
350
351
# File 'lib/dommy/element.rb', line 349

def parent_node
  @__node__.parent && @document.wrap_node(@__node__.parent)
end

#previous_siblingObject



363
364
365
# File 'lib/dommy/element.rb', line 363

def previous_sibling
  @__node__.previous && @document.wrap_node(@__node__.previous)
end

#removeObject



344
345
346
347
# File 'lib/dommy/element.rb', line 344

def remove
  @document.remove_node_with_notify(@__node__)
  nil
end

#replace_data(offset, count, value) ⇒ Object



462
463
464
465
466
467
468
469
470
# File 'lib/dommy/element.rb', line 462

def replace_data(offset, count, value)
  s = @__node__.content
  len = utf16_length(s)
  o = to_uint32(offset)
  raise DOMException::IndexSizeError, "offset out of bounds" if o > len

  c = [to_uint32(count), len - o].min
  write_data(utf16_slice(s, 0, o) + dom_string(value) + utf16_slice(s, o + c, len - (o + c)))
end

#replace_with(*args) ⇒ Object



630
631
632
# File 'lib/dommy/element.rb', line 630

def replace_with(*args)
  child_node_replace_with(args)
end

#split_text(offset) ⇒ Object

Text.splitText / CharacterData split: break the node at offset, keeping [0, offset) here and returning a new sibling node with the remainder.



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/dommy/element.rb', line 296

def split_text(offset)
  off = offset.to_i
  full = @__node__.content
  raise DOMException::IndexSizeError, "offset #{off} is out of bounds" if off.negative? || off > full.length

  rest = full[off..] || ""
  write_data(full[0, off])
  new_node = @document.create_text_node(rest)
  if @__node__.parent
    new_bn = new_node.__dommy_backend_node__
    @__node__.add_next_sibling(new_bn)
    # The new node is inserted right after self — a childList addition record.
    @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: [new_bn], removed_nodes: [])
  end
  new_node
end

#substring_data(offset, count) ⇒ Object



418
419
420
421
422
423
424
425
426
# File 'lib/dommy/element.rb', line 418

def substring_data(offset, count)
  s = @__node__.content
  len = utf16_length(s)
  o = to_uint32(offset)
  raise DOMException::IndexSizeError, "offset out of bounds" if o > len

  c = [to_uint32(count), len - o].min
  utf16_slice(s, o, c)
end

#text_contentObject



336
337
338
# File 'lib/dommy/element.rb', line 336

def text_content
  @__node__.content
end

#text_content=(value) ⇒ Object



340
341
342
# File 'lib/dommy/element.rb', line 340

def text_content=(value)
  write_data(value)
end

#to_uint32(value) ⇒ Object

ECMAScript ToUint32 — WebIDL unsigned long conversion for a data offset or count: ToNumber (a non-numeric string is NaN), truncate toward zero, then take modulo 2**32 (so -1 wraps to 4294967295, 0x100000000+2 to 2).



431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/dommy/element.rb', line 431

def to_uint32(value)
  num =
    case value
    when Integer then value
    when Numeric then value
    when nil then 0 # JS null -> 0
    else Float(value.to_s) rescue Float::NAN
    end
  return 0 unless num.respond_to?(:finite?) ? num.finite? : true

  num.to_i % (2**32)
end

#utf16_length(str) ⇒ Object

CharacterData offsets and counts are measured in UTF-16 code units, not Unicode code points, so an astral character (e.g. an emoji) counts as 2.



396
397
398
# File 'lib/dommy/element.rb', line 396

def utf16_length(str)
  str.encode(Encoding::UTF_16LE).bytesize / 2
end

#utf16_slice(str, offset, count) ⇒ Object

Extract count UTF-16 code units from str starting at code unit offset. Slicing on the UTF-16LE byte buffer keeps astral characters intact for the offsets these APIs actually produce.

If the range starts or ends inside a surrogate pair the result would be a lone (unpaired) surrogate. JS strings can hold those; a Ruby UTF-8 String cannot, so re-raise the raw encoding error as a clear, intentional message rather than leaking "\xDF on UTF-16LE" to the caller. This is a Dommy limitation and, since splitting a surrogate pair signals a UTF-16 offset bug in the caller, failing loud is deliberate.



410
411
412
413
414
415
416
# File 'lib/dommy/element.rb', line 410

def utf16_slice(str, offset, count)
  buf = str.encode(Encoding::UTF_16LE)
  buf.byteslice(offset * 2, count * 2).encode(Encoding::UTF_8, Encoding::UTF_16LE)
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
  raise "cannot split a UTF-16 surrogate pair: the requested range would " \
        "produce a lone surrogate, which Dommy cannot represent"
end