diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 28c66938..bd0828c4 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -8,6 +8,11 @@ - Added `URL.toDart` and `Uri.toJS` extension methods. - Added missing `Document` and `Window` pointer event getters: `onDrag*`, `onTouch*`, `onMouse*`. +- Added `HTMLCollectionListWrapper` and `NodeListListWrapper` to support + mutable operations on node lists. +- Added `childNodesAsList` to `Node` and `childrenAsList` to `Element` via + extensions. +- Added `asList` to `NodeList` via extension. ## 1.1.1 diff --git a/web/lib/src/helpers/extensions.dart b/web/lib/src/helpers/extensions.dart index 555b0e23..af1af2c6 100644 --- a/web/lib/src/helpers/extensions.dart +++ b/web/lib/src/helpers/extensions.dart @@ -25,6 +25,7 @@ import 'dart:convert'; import 'dart:js_interop'; import '../dom.dart'; +import 'lists.dart'; export 'cross_origin.dart' show CrossOriginContentWindowExtension, CrossOriginWindowExtension; @@ -103,3 +104,18 @@ extension UriToURL on Uri { } } } + +extension NodeExtension on Node { + /// Returns [childNodes] as a modifiable [List]. + List get childNodesAsList => NodeListListWrapper(this, childNodes); +} + +extension ElementExtension on Element { + /// Returns [children] as a modifiable [List]. + List get childrenAsList => HTMLCollectionListWrapper(this, children); +} + +extension NodeListExtension on NodeList { + /// Returns node list as a modifiable [List]. + List get asList => JSImmutableListWrapper(this); +} diff --git a/web/lib/src/helpers/lists.dart b/web/lib/src/helpers/lists.dart index 18e0f936..a5dc734a 100644 --- a/web/lib/src/helpers/lists.dart +++ b/web/lib/src/helpers/lists.dart @@ -4,6 +4,7 @@ import 'dart:collection'; import 'dart:js_interop'; +import '../dom/dom.dart'; /// `_JSList` acts as a wrapper around a JS list object providing an interface to /// access the list items and list length while also allowing us to specify the @@ -65,7 +66,313 @@ class JSImmutableListWrapper if (length > 1) throw StateError('More than one element'); return first; } +} + +/// This mixin exists to avoid repetition in `NodeListListWrapper` and `HTMLCollectionListWrapper` +/// It can be also used for `HTMLCollection` and `NodeList` that is +/// [live](https://developer.mozilla.org/en-US/docs/Web/API/NodeList#live_vs._static_nodelists) +/// and can be safely modified at runtime. +/// This requires an instance of `P`, a container that elements would be added to or removed from. +abstract mixin class _LiveNodeListMixin

{ + P get _parent; + _JSList get _list; + + bool contains(Object? element) { + // TODO(srujzs): migrate this ifs to isJSAny once we have it + // ignore: invalid_runtime_check_with_js_interop_types + if ((element is JSAny?) && (element?.isA() ?? false)) { + if ((element as Node).parentNode.strictEquals(_parent).toDart) { + return true; + } + } + return false; + } + + bool remove(Object? element) { + if (contains(element)) { + _parent.removeChild(element as Node); + return true; + } else { + return false; + } + } + + int get length => _list.length; + + set length(int value) { + if (value > length) { + throw UnsupportedError('Cannot add empty nodes.'); + } + for (var i = length - 1; i >= value; i--) { + _parent.removeChild(_list.item(i)); + } + } + + U operator [](int index) { + if (index > length || index < 0) { + throw IndexError.withLength(index, length, indexable: this); + } + return _list.item(index); + } + + void operator []=(int index, U value) { + RangeError.checkValidRange(index, null, length); + _parent.replaceChild(value, _list.item(index)); + } + + void add(U value) { + _parent.appendChild(value); + } + + void removeRange(int start, int end) { + RangeError.checkValidRange(start, end, length); + for (var i = 0; i < end - start; i++) { + _parent.removeChild(this[start]); + } + } + + U removeAt(int index) { + final result = this[index]; + _parent.removeChild(result); + return result; + } + + void fillRange(int start, int end, [U? fill]) { + // without cloning the element we would end up with one `fill` instance + // this method does not make much sense in nodes lists + throw UnsupportedError('Cannot fillRange on Node list'); + } + + U get last; + + U removeLast() { + final result = last; + _parent.removeChild(result); + return result; + } + + void removeWhere(bool Function(U element) test) { + _filter(test, false); + } + + void retainWhere(bool Function(U element) test) { + _filter(test, true); + } + + Iterator get iterator; + + void _filter(bool Function(U element) test, bool requiredTestValue) { + // This implementation of removeWhere/retainWhere is more efficient + // than the default in ListBase. Child nodes can be removed in constant + // time. + final i = iterator; + U? removeMe; + while (i.moveNext()) { + if (removeMe != null) { + _parent.removeChild(removeMe); + removeMe = null; + } + if (test(i.current) != requiredTestValue) { + removeMe = i.current; + } + } + if (removeMe != null) { + _parent.removeChild(removeMe); + removeMe = null; + } + } + + void insert(int index, U element) { + if (index < 0 || index > length) { + throw RangeError.range(index, 0, length); + } + if (index == length) { + _parent.appendChild(element); + } else { + _parent.insertBefore(element, this[index]); + } + } + + void addAll(Iterable iterable) { + if (iterable is _LiveNodeListMixin) { + final otherList = iterable as _LiveNodeListMixin; + if (otherList._parent.strictEquals(_parent).toDart) { + throw ArgumentError('Cannot add nodes from same parent'); + } + // Optimized route for copying between nodes. + for (var len = otherList.length; len > 0; --len) { + _parent.appendChild(otherList._parent.firstChild!); + } + } + + for (var element in iterable) { + _parent.appendChild(element); + } + } + + void insertAll(int index, Iterable iterable) { + if (index == length) { + addAll(iterable); + } else { + final child = this[index]; + if (iterable is _LiveNodeListMixin) { + final otherList = iterable as _LiveNodeListMixin; + if (otherList._parent.strictEquals(_parent).toDart) { + throw ArgumentError('Cannot add nodes from same parent'); + } + // Optimized route for copying between nodes. + for (var len = otherList.length; len > 0; --len) { + _parent.insertBefore(otherList._parent.firstChild!, child); + } + } else { + for (var node in iterable) { + _parent.insertBefore(node, child); + } + } + } + } +} + +/// Allows iterating `HTMLCollection` with `nextElementSibling` for optimisation and easier encapsulation +class _HTMLCollectionIterator implements Iterator { + @override + Element get current => _current!; + + Element? _current; + bool start = true; + + _HTMLCollectionIterator(this._current); @override - U elementAt(int index) => this[index]; + bool moveNext() { + if (start) { + start = false; + } else { + _current = _current?.nextElementSibling; + } + return _current != null; + } +} + +/// Wrapper for `HTMLCollection` returned from `children` that implements modifiable list interface and allows easier DOM manipulation. +/// This is loosely based on `_ChildrenElementList` from `dart:html` to preserve compatibility +class HTMLCollectionListWrapper + with ListMixin, _LiveNodeListMixin { + @override + final Element _parent; + @override + _JSList get _list => _JSList(_htmlCollection); + + final HTMLCollection _htmlCollection; + + HTMLCollectionListWrapper(this._parent, this._htmlCollection); + + @override + Iterator get iterator => + _HTMLCollectionIterator(_parent.firstElementChild); + + @override + bool get isEmpty { + return _parent.firstElementChild == null; + } + + @override + Element get first { + final result = _parent.firstElementChild; + if (result == null) throw StateError('No elements'); + return result; + } + + @override + Element get last { + final result = _parent.lastElementChild; + if (result == null) throw StateError('No elements'); + return result; + } + + @override + Element get single { + final l = length; + if (l == 0) throw StateError('No elements'); + if (l > 1) throw StateError('More than one element'); + return _parent.firstElementChild!; + } + + @override + void clear() { + while (_parent.firstElementChild != null) { + _parent.removeChild(_parent.firstElementChild!); + } + } +} + +/// Allows iterating `NodeList` with `nextSibling` for optimisation and easier encapsulation +class _NodeListIterator implements Iterator { + @override + Node get current => _current!; + + Node? _current; + bool start = true; + + _NodeListIterator(this._current); + + @override + bool moveNext() { + if (start) { + start = false; + } else { + _current = _current?.nextSibling; + } + return _current != null; + } +} + +/// Wrapper for `NodeList` returned from `childNodes` that implements modifiable list interface and allows easier DOM manipulation. +/// This is loosely based on `_ChildNodeListLazy` from `dart:html` to preserve compatibility +class NodeListListWrapper with ListMixin, _LiveNodeListMixin { + @override + final Node _parent; + @override + _JSList get _list => _JSList(_nodeList); + + final NodeList _nodeList; + + NodeListListWrapper(this._parent, this._nodeList); + + @override + Iterator get iterator => _NodeListIterator(_parent.firstChild); + + @override + bool get isEmpty { + return _parent.firstChild == null; + } + + @override + Node get first { + final result = _parent.firstChild; + if (result == null) throw StateError('No elements'); + return result; + } + + @override + Node get last { + final result = _parent.lastChild; + if (result == null) throw StateError('No elements'); + return result; + } + + @override + Node get single { + final l = length; + if (l == 0) throw StateError('No elements'); + if (l > 1) throw StateError('More than one element'); + return _parent.firstChild!; + } + + @override + void clear() { + while (_parent.firstChild != null) { + _parent.removeChild(_parent.firstChild!); + } + } } diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index ce5d425c..4954e2e0 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -40,6 +40,132 @@ void main() { expect(() => dartList[0], returnsNormally); }); + test('modify child nodes using JSLiveNodeListWrapper', () { + void expectNodeListEquals(List list, List contents) { + expect(list.length, contents.length); + for (var i = 0; i < contents.length; i++) { + expect(list[i].textContent, contents[i]); + } + } + + final div = (document.createElement('div')) + ..append(document.createElement('div')..textContent = 'e1') + ..append(document.createElement('div')..textContent = 'e2') + ..append(document.createElement('div')..textContent = 'e3'); + + final childNodesList = div.childNodesAsList; + final childrenList = div.childrenAsList; + + // Ensure initial lists are correct. + expectNodeListEquals(childNodesList, ['e1', 'e2', 'e3']); + expectNodeListEquals(childrenList, ['e1', 'e2', 'e3']); + + childrenList.removeWhere((node) => node.textContent == 'e2'); + + // Ensure both list were updated. + expectNodeListEquals(childNodesList, ['e1', 'e3']); + expectNodeListEquals(childrenList, ['e1', 'e3']); + + // add only text nodes + childNodesList + .addAll([document.createTextNode('t1'), document.createTextNode('t2')]); + + // Ensure only childNodes list changed + expectNodeListEquals(childNodesList, ['e1', 'e3', 't1', 't2']); + expectNodeListEquals(childrenList, ['e1', 'e3']); + + // add node via children + childrenList.add(document.createElement('div')..textContent = 'e4'); + // add node via childNodes + childNodesList.add(document.createElement('div')..textContent = 'e5'); + // add node directly to parent + div.appendChild(document.createElement('div')..textContent = 'e6'); + + // Ensure 3 elements were added to both lists + expectNodeListEquals( + childNodesList, ['e1', 'e3', 't1', 't2', 'e4', 'e5', 'e6']); + expectNodeListEquals(childrenList, ['e1', 'e3', 'e4', 'e5', 'e6']); + + // replace element with text node + childNodesList[4] = document.createTextNode('t3'); + + // test retainWhere, keep Elements only + childNodesList.retainWhere((e) => e.isA()); + + // Ensure only text nodes were removed + expectNodeListEquals(childNodesList, ['e1', 'e3', 'e5', 'e6']); + expectNodeListEquals(childrenList, ['e1', 'e3', 'e5', 'e6']); + + // test removeRange + childrenList.removeRange(1, 3); + + // Ensure 2 elements were removed + expectNodeListEquals(childNodesList, ['e1', 'e6']); + expectNodeListEquals(childrenList, ['e1', 'e6']); + + // test []= range exception + expect(() => childNodesList[10] = document.createTextNode('nope'), + throwsRangeError); + + // test remove + final removeMe = childNodesList[0]; + expect(childNodesList.remove(removeMe), true); + expectNodeListEquals(childNodesList, ['e6']); + + // test remove with objects that are not in list + expect(childNodesList.remove(removeMe), false); + expect(childNodesList.remove(null), false); + // ignore: collection_methods_unrelated_type + expect(childNodesList.remove('test'), false); + + final differentParentDiv = document.createElement('div'); + document.createElement('div').append(differentParentDiv); + expect(childNodesList.remove(differentParentDiv), false); + + // test if nothing was removed + expectNodeListEquals(childNodesList, ['e6']); + + final newTextNodes = [ + document.createTextNode('t3'), + document.createTextNode('t4') + ]; + final newDiv = (document.createElement('div')) + ..append(document.createElement('div')..textContent = 'e7') + ..append(document.createElement('div')..textContent = 'e8'); + + // adding text nodes via addAll + childNodesList.addAll(newTextNodes); + expectNodeListEquals(childNodesList, ['e6', 't3', 't4']); + expectNodeListEquals(childrenList, ['e6']); + + // adding div nodes from other element + childrenList.addAll(newDiv.childrenAsList); + expectNodeListEquals(childNodesList, ['e6', 't3', 't4', 'e7', 'e8']); + expectNodeListEquals(childrenList, ['e6', 'e7', 'e8']); + + // adding from self should throw exception + expect(() => childrenList.addAll(div.childrenAsList), throwsArgumentError); + expect( + () => childNodesList.addAll(div.childNodesAsList), throwsArgumentError); + + // insertAll test + childNodesList.insertAll( + 1, [document.createTextNode('t5'), document.createTextNode('t6')]); + expectNodeListEquals( + childNodesList, ['e6', 't5', 't6', 't3', 't4', 'e7', 'e8']); + expectNodeListEquals(childrenList, ['e6', 'e7', 'e8']); + + // empty elements list + childrenList.clear(); + expectNodeListEquals(childNodesList, ['t5', 't6', 't3', 't4']); + expectNodeListEquals(childrenList, []); + + // empty both lists + childNodesList.clear(); + expectNodeListEquals(childNodesList, []); + expectNodeListEquals(childrenList, []); + }); + test('responseHeaders transforms headers into a map', () async { final request = XMLHttpRequest() ..open('GET', 'www.google.com')