From 3566947e18daa571e1e942b4c8705f406d64768e Mon Sep 17 00:00:00 2001 From: 7HR4IZ3 <90985774+7HR4IZ3@users.noreply.github.com> Date: Mon, 5 Feb 2024 20:34:05 +0100 Subject: [PATCH 1/6] Update dom.py Added MutationRecord and MutationObserver classes --- domonic/dom.py | 233 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 220 insertions(+), 13 deletions(-) diff --git a/domonic/dom.py b/domonic/dom.py index b6c6884..8c5a9f8 100644 --- a/domonic/dom.py +++ b/domonic/dom.py @@ -23,6 +23,19 @@ # from xml.dom.pulldom import END_ELEMENT +from functools import wraps +from threading import Thread + +def task(func, handler=Thread, *ta, **tkw): + @wraps(func) + def wrapper(*a, **kw): + thread = handler(*ta, target=func, args=a, kwargs=kw, **tkw) + thread.start() + return thread + return wrapper + +def daemon_task(func, *args, **kwargs): + return task(func, *args, handler=Thread, daemon=True, **kwargs) # TODO - unit tests class DOMConfig: @@ -133,6 +146,7 @@ def __init__(self, *args, **kwargs) -> None: self.outerText: str = None self.parentNode = None self.prefix = None # 🗑️ + self.observerList = [] # self.baseURIObject = None # ? # self.nodePrincipal = None self._update_parents() @@ -743,6 +757,66 @@ def _iterate(self, element, callback) -> None: def __len__(self): return len(self.args) + def _add_mutation( + self, type=None, name=None, target=None, + addedNodes=None, removedNodes=None, previousSibling=None, + nextSibling=None, namespace=None, oldValue=None + ): + nodes = [] + interestedObservers = {} + + node = target + + while node is not None and hasattr(node, "parentNode"): + nodes.append(node) + node = node.parentNode + + for node in nodes: + for observer, options in node.observerList: + if (( + node != target + and options.get('subtree', False) is False + ) or ( + type == "attributes" + and options.get('attributes', False) is False + ) or ( + type == "attributes" and ( + options.get("attributeFilter") + and name not in options.get("attributeFilter") + or namespace is not None + ) + ) or ( + type == "characterData" + and options.get('characterData', False) is False + ) or ( + type == "childList" + and options.get("childList", False) is False + )) is False: + if not observer in interestedObservers: + interestedObservers[observer] = None + + if ( + type == "attributes" + and options.get("attributeOldValue") is True + ) or ( + type == "characterData" + and options.get("characterDataOldValue") is True + ): + interestedObservers[observer] = oldValue + + for observer, mappedOldValue in interestedObservers.items(): + observer: MutationObserver + + record = MutationRecord( + type=type, target=target, addedNodes=addedNodes, + removedNodes=removedNodes, previousSibling=previousSibling, + nextSibling=nextSibling, attributeName=name, + attributeNamespace=namespace, oldValue=mappedOldValue + ) + + observer.append(record) + + def appendChild(self, aChild: "Node") -> "Node": """ Adds a child to the current element. @@ -754,11 +828,28 @@ def appendChild(self, aChild: "Node") -> "Node": if isinstance(aChild, DocumentFragment): items = aChild.args self.args = self.args + items - return DocumentFragment() + ret = DocumentFragment() else: - self.args = self.args + (aChild,) + if isinstance(aChild, Node): + self.args = self.args + (aChild,) + else: + self.args = self.args + (aChild,) # return aChild # causes max recursion when called chained? then don't chain? - return aChild + ret = aChild + + self._add_mutation(**{ + "name": None, + "type": "childList", + "addedNodes": NodeList([aChild]), + "namespace": None, + "nextSibling": aChild.nextSibling, + "oldValue": None, + "previousSibling": aChild.previousSibling, + "removedNodes": NodeList(), + "target": self, + }) + + return ret @property def childElementCount(self) -> int: @@ -4086,17 +4177,133 @@ def __getitem__(self, index): else: return super().__getitem__(index) +class MutationRecord: + """ MutationObserver Record Interface. """ + + class Type: + ATTRIBUTES = "attributes" + CHILD_LIST = "childList" + CHARACTER_DATA = "characterData" + + target: Element + nextSibling: Element + previousSibling: Element + + addedNodes: NodeList[Element] + removedNodes: NodeList[Element] + + oldValue: str + attributeName: str + attributeNamespace: str + + def __init__( + self, type=None, target=None, addedNodes=None, + removedNodes=None, previousSibling=None, nextSibling=None, + attributeName=None, attributeNamespace=None, oldValue=None + ): + self.type = type + self.target = target + self.addedNodes = addedNodes or NodeList() + self.removedNodes = removedNodes or NodeList() + self.previousSibling = previousSibling + self.nextSibling = nextSibling + self.attributeName = attributeName + self.attributeNamespace = attributeNamespace + self.oldValue = oldValue + + def __setattr__(self, name, value): + if hasattr(self, name): + raise ValueError(f"Attribute '{name}' of {self.__class__.__name__} is read-only.") + super().__setattr__(name, value) + + def __repr__(self): + return f"""{self.__class__.__name__}( + type={self.type !r}, target={self.target !r}, oldValue={self.oldValue !r}, + addedNodes={self.addedNodes !r}, removedNodes={self.removedNodes !r}, + previousSibling={self.previousSibling !r}, nextSibling={self.nextSibling !r}, + attributeName={self.attributeName !r}, attributeNamespace={self.attributeNamespace !r} +)""" + +class MutationObserver: # TODO - test + """ The MutationObserver interface provides the ability to watch for changes being made to the DOM tree. """ + + def __init__( + self, callback: t.Optional[t.Callable[[list[MutationRecord]], None]] = None, + interval=.5, append_callback: t.Optional[t.Callable[[MutationRecord], None]] = None + ): + self.is_connected = threading.Event() + self.callback = callback + self.append_callback = append_callback + self.interval = interval + self.mutations = [] + self.nodeList = [] + + def disconnect(self): + """ Stops the MutationObserver instance from receiving further notifications until + and unless observe() is called again. """ + + for target in self.nodeList: + for item in target.observerList: + if item[0] == self: + target.observerList.remove(item) + self.is_connected.clear() + return self + + def observe( + self, target: Node, subtree=False, + childList=False, attributes=False, + attributeFilter=None, attributeOldValue=False, + characterData=False, characterDataOldValue=False + ): + """ Configures the MutationObserver to begin receiving notifications through + its callback function when DOM changes matching the given options occur. """ + + options = { + "subtree": subtree, + "childList": childList, + "attributes": attributes, + "characterData": characterData, + "attributeFilter": attributeFilter, + "attributeOldValue": attributeOldValue, + "characterDataOldValue": characterDataOldValue + } + + for item in target.observerList: + if item[0] == self: + item[1] = options + return self + + target.observerList.append([self, options]) + self.nodeList.append(target) + + self.watch_mutations() + + def takeRecords(self): + """ Removes all pending notifications from the MutationObserver's notification queue + and returns them in a new Array of MutationRecord objects. """ + records = self.mutations.copy() + self.mutations.clear() + return records + + def append(self, record: MutationRecord): + """ Append MutationRecord """ + self.mutations.insert(0, record) + if self.append_callback: + self.append_callback(record) + # self.callback(self.takeRecords()) + + @daemon_task + def watch_mutations(self): + if not self.callback: return + + self.is_connected.set() + + while self.is_connected.is_set(): + time.sleep(self.interval) + + if len(self.mutations): + self.callback(self.takeRecords()) -# TODO - is there a webapi module for this now? -# from domonic.javascript import Object -# MutationObserverInit = Object() -# MutationObserverInit.subtree = False -# MutationObserverInit.childList = False -# MutationObserverInit.attributes = False -# MutationObserverInit.attributeFilter = False -# MutationObserverInit.attributeOldValue = False -# MutationObserverInit.characterData = False -# MutationObserverInit.characterDataOldValue = False # class MutationObserver(): # TODO - test # """ The MutationObserver interface provides the ability to watch for changes being made to the DOM tree. """ From 5d736bbaebbfba59316d942240f935d1faf26fd0 Mon Sep 17 00:00:00 2001 From: 7HR4IZ3 <90985774+7HR4IZ3@users.noreply.github.com> Date: Mon, 5 Feb 2024 21:03:08 +0100 Subject: [PATCH 2/6] Implemented MutationObserver --- domonic/dom.py | 406 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 323 insertions(+), 83 deletions(-) diff --git a/domonic/dom.py b/domonic/dom.py index 8c5a9f8..c4b3952 100644 --- a/domonic/dom.py +++ b/domonic/dom.py @@ -134,19 +134,20 @@ def __init__(self, *args, **kwargs) -> None: ] ) except IndexError as e: - from domonic.html import TemplateError raise TemplateError(e) # except Exception as e: # print(e) - self.baseURI: str = "" # TODO - if ownerdocument has a basetag, use that - self.isConnected: bool = True - self.namespaceURI: str = "http://www.w3.org/1999/xhtml" - self.outerText: str = None + self.prefix = None self.parentNode = None - self.prefix = None # 🗑️ + self.outerText: str = None + self.isConnected: bool = True self.observerList = [] + + # TODO - if ownerdocument has a basetag, use that + self.baseURI: str = "eventual.technology" + self.namespaceURI: str = "http://www.w3.org/1999/xhtml" # self.baseURIObject = None # ? # self.nodePrincipal = None self._update_parents() @@ -289,9 +290,8 @@ def format_attr(key, value): try: return "".join([format_attr(key, value) for key, value in self.kwargs.items()]) except IndexError as e: - from domonic.html import TemplateError - raise TemplateError(e) + raise TemplateError(e) from None # except Exception as e: # print(e) @@ -310,13 +310,15 @@ def __attributes__(self, ignore): ] ) except IndexError as e: - from domonic.html import TemplateError - raise TemplateError(e) + raise TemplateError(e) from None # except Exception as e: # print(e) def __str__(self): + if isinstance(self, Document) or not self.name: + return self.content + if not DOMConfig.RENDER_OPTIONAL_CLOSING_TAGS: if self.name in [ "html", @@ -345,7 +347,7 @@ def __mul__(self, other): print(''.join([str(c) for c in cells])) """ reproducer = [] - for i in range(other): + for _ in range(other): reproducer.append(copy.deepcopy(self)) return reproducer @@ -355,42 +357,27 @@ def __rmul__(self, other): cells = cell()*10 print(''.join([str(c) for c in cells])) """ - reproducer = [] - for i in range(other): - reproducer.append(copy.deepcopy(self)) - return reproducer + return self.__mul__(other) def __truediv__(self, other): """use to render clones without having to parse commas yourself""" - reproducer = [] - for i in range(other): - reproducer.append(str(self)) - return "".join(reproducer) + return "".join(self.__mul__(other)) def __rtruediv__(self, other): """use to render clones without having to parse commas yourself""" - reproducer = [] - for i in range(other): - reproducer.append(str(self)) - return "".join(reproducer) + return "".join(self.__mul__(other)) def __div__(self, other): """ useful for prototyping as renders. to retain objects use multiply """ - reproducer = [] - for i in range(other): - reproducer.append(str(self)) - return "".join(reproducer) + return "".join(self.__mul__(other)) def __rdiv__(self, other): """ useful for prototyping as renders. to retain objects use multiply """ - reproducer = [] - for i in range(other): - reproducer.append(str(self)) - return "".join(reproducer) + return "".join(self.__mul__(other)) def __or__(self, other): """return self unless other is something""" @@ -468,7 +455,7 @@ def __getattr__(self, attr): allows dot notation for reading attributes *credit to the peeps on discord/python for this one* """ - kwargs = super().__getattribute__("kwargs") + kwargs = self.kwargs if attr in kwargs: return kwargs[attr] @@ -481,13 +468,6 @@ def __getattr__(self, attr): if retry in kwargs: return kwargs[retry] - # TODO - think of solution for other MIA attributes as when it would fail silently - # it was a nightmare. But having to catch the raised errors may also be sluggish - # maybe specific tags can override this method and provide default values when not present? - if self.__class__.__name__ == "a" and attr == "href": - print(" Warning: No 'href' attribute was defined for this 'a' tag.") - return "" - try: # return getattr(super(), attr) # return getattr(self, attr) @@ -609,7 +589,6 @@ def __format__(self, format_spec): dtype = self.doctype # if self is a closed_tag, return the content - from domonic.html import closed_tag if isinstance(self, closed_tag): return f"\n{dent}<{self.name}{self.__attributes__} />" @@ -660,7 +639,7 @@ def __format__(self, format_spec): # print(kwargs) # print(self.name) - def __setattr__(self, name: str, value: Any) -> None: + def __setattr__(self, name: str, value: t.Any) -> None: try: if name == "args": super().__setattr__(name, value) @@ -721,10 +700,9 @@ def _update_parents(self): so will have to call manually whenever self.args are ammended. """ try: - # print(self.args) for el in self.args: # if(type(el) not in [str, list, dict, int, float, tuple, object, set]): - if isinstance(el, (Element, Node)): + if isinstance(el, Node): el.parentNode = self el._update_parents() except Exception as e: @@ -738,18 +716,20 @@ def _iterate(self, element, callback) -> None: """ callback(element) # TODO - this can block on failed attributes elements = [] + if isinstance(element, Node): elements = element.args elif isinstance(element, list): elements = element + try: for el in elements: - if type(el) not in [str, list, dict, int, float, tuple, object, set]: + if isinstance(el, Node): # callback(el) el._iterate(el, callback) elif isinstance(el, list): # if someone is incorrectly using a list as a child for e in el: - if type(e) not in (str, list, dict, int, float, tuple, object, set): + if isinstance(el, Node): e._iterate(e, callback) except Exception as e: print("_iterate error", e) @@ -816,7 +796,6 @@ def _add_mutation( observer.append(record) - def appendChild(self, aChild: "Node") -> "Node": """ Adds a child to the current element. @@ -863,9 +842,9 @@ def childNodes(self) -> "NodeList": return NodeList(self.args) @property - def children(self): + def children(self) -> list["Element"]: """Returns a collection of an element's child element (excluding text and comment nodes)""" - newlist: list = [] + newlist = [] for each in self.args: if type(each) != str: newlist.append(each) @@ -940,7 +919,7 @@ def identifyWhichIsFirst(node): else: return Node.DOCUMENT_POSITION_FOLLOWING - def contains(self, node): + def contains(self, node: "Node") -> bool: """Check whether a node is a descendant of a given node""" # this will go crunch on big stuff... need to consider best way for each in self.args: @@ -955,7 +934,7 @@ def contains(self, node): return False @property - def firstChild(self): + def firstChild(self) -> t.Union["Element", None]: """Returns the first child node of an element""" try: return self.args[0] # TODO - check if this means includes content @@ -967,7 +946,7 @@ def hasChildNodes(self) -> bool: return len(self.args) > 0 @property - def lastChild(self): + def lastChild(self) -> t.Union["Element", None]: """Returns the last child node of an element""" try: return self.args[len(self.args) - 1] @@ -975,14 +954,14 @@ def lastChild(self): return None @property - def localName(self): + def localName(self) -> t.Union[str, None]: try: return self.tagName except Exception: return None @property - def nodeName(self): + def nodeName(self) -> t.Union[str, None]: """Returns the name of a node""" # TODO - not sure what's better this or overriding on every element # if isinstance(self, Text): @@ -1035,13 +1014,31 @@ def nodeValue(self): @nodeValue.setter def nodeValue(self, content): """Sets or returns the value of a node""" + oldValue = str(self.args) self.args = (content,) + self._add_mutation(**{ + "name": "nodeValue", + "type": "characterData", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": oldValue, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) return content @property - def ownerDocument(self): + def ownerDocument(self) -> "Document": """Returns the root element (document object) for an element""" - return self.rootNode + + node = self + while node: + if isinstance(node, Document): + return node + node = node.parentNode + return None @ownerDocument.setter def ownerDocument(self, newOwner): #: Element): @@ -1060,10 +1057,8 @@ def rootNode(self): return self node = self - nxt = self.parentNode - while nxt is not None: - node = nxt - nxt = nxt.parentNode + while node.parentNode is not None: + node = node.parentNode return node def insertBefore(self, new_node, reference_node=None): @@ -1078,11 +1073,53 @@ def insertBefore(self, new_node, reference_node=None): # remove new_node from its previous parent node if new_node.parentNode is not None: new_node.parentNode.removeChild(new_node) + self.args = ( self.args[: self.args.index(reference_node)] + (new_node,) - + self.args[self.args.index(reference_node) :] + + self.args[self.args.index(reference_node):] + ) + self._add_mutation(**{ + "name": None, + "type": "childList", + "addedNodes": NodeList(new_node), + "namespace": None, + "nextSibling": None, + "oldValue": None, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) + return new_node + + def insertAfter(self, new_node, reference_node=None): + """inserts a node after a reference node as a child of a specified parent node. + this will remove the node from its previous parent node, if any. + + # TODO - can throw value error if wrong ordered params. may be helpful to catch to say so. + """ + if reference_node is None: + self.appendChild(new_node) + else: + # remove new_node from its previous parent node + if new_node.parentNode is not None: + new_node.parentNode.removeChild(new_node) + self.args = ( + self.args[: self.args.index(reference_node) + 1] + + (new_node,) + + self.args[self.args.index(reference_node) + 1:] ) + self._add_mutation(**{ + "name": None, + "type": "childList", + "addedNodes": NodeList([new_node]), + "namespace": None, + "nextSibling": None, + "oldValue": None, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) return new_node def removeChild(self, node): @@ -1093,11 +1130,27 @@ def removeChild(self, node): if each == node: n = node + + # NOTE: Remove in main implementation + xpath = generate_xpath(node) + n.parentNode = None replace_args = list(self.args) replace_args.remove(node) self.args = tuple(replace_args) + self._add_mutation(**{ + "name": None, + "type": "childList", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": None, + "previousSibling": None, + "removedNodes": NodeList([(node, xpath)]), + "target": self, + }) + return n r = each.removeChild(node) if r: @@ -1117,11 +1170,26 @@ def replaceChild(self, newChild, oldChild): """ for count, each in enumerate(self.args): if each == oldChild: + # NOTE: Remove in main implementation + xpath = generate_xpath(oldChild) + replace_args = list(self.args) replace_args[count] = newChild self.args = tuple(replace_args) + self._add_mutation(**{ + "name": None, + "type": "childList", + "addedNodes": NodeList([newChild]), + "namespace": None, + "nextSibling": None, + "oldValue": None, + "previousSibling": None, + "removedNodes": NodeList([(oldChild, xpath)]), + "target": self, + }) return oldChild return oldChild + # for count, each in enumerate(self.args): # if each == oldChild: # n = oldChild @@ -1223,7 +1291,7 @@ def textContent(self): # nodevalue is lvl 1 spec. textcontent is lvl 3 spec. outp = "" for each in self.args: - if type(each) is str: + if isinstance(each, str): outp = outp + each else: val = each.textContent @@ -1238,12 +1306,26 @@ def textContent(self): @textContent.setter def textContent(self, content): """Sets the text content of a node and its descendants""" + oldValue = self.textContent + if oldValue == content: return + self.args = (content,) + self._add_mutation(**{ + "name": "textContent", + "type": "characterData", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": oldValue, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) return content - # def isSupported(self): return False # 🗑 - # getUserData() 🗑️ - # setUserData() 🗑️ + # def isSupported(self): return False # 🗑 + # getUserData() 🗑️ + # setUserData() 🗑️ # non standard methods to be etree compatible # seems to make it work with https://github.com/sissaschool/elementpath @@ -1295,7 +1377,7 @@ def length(self) -> int: return len(self) -class ParentNode: +class ParentNode(Node): """not tested yet""" def __init__(self, *args, **kwargs): @@ -1332,11 +1414,33 @@ def append(self, *args): def prepend(self, *args): self.args = (args).extend(self.args) + self._add_mutation(**{ + "name": None, + "type": "childList", + "addedNodes": NodeList(args), + "namespace": None, + "nextSibling": None, + "oldValue": None, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) return self def replaceChildren(self, children): + removed = [x for x in self.args] self.args = children - + self._add_mutation(**{ + "name": None, + "type": "childList", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": None, + "previousSibling": None, + "removedNodes": NodeList(removed), + "target": self, + }) class ChildNode(Node): """not tested yet""" @@ -2113,6 +2217,7 @@ class Element(Node): def __init__(self, *args, **kwargs): # self.content = None # self.attributes = None + self.kwargs = {} if self.hasAttribute("id"): self.id = self.id # ''#None @@ -2247,7 +2352,7 @@ def getElements(context, tag): found.extend(elements) return found - context = [document] + context = [self.ownerDocument] inheriters = all_selectors.split(" ") # Space @@ -2260,7 +2365,7 @@ def getElements(context, tag): parts = str.split(element, "#") tag = parts[0] id = parts[1] - ele = document.getElementById(id) + ele = self.ownerDocument.getElementById(id) context = [ele] # [](ele) continue @@ -2270,7 +2375,7 @@ def getElements(context, tag): tag = parts[0] class_name = parts[1] found = getElements(context, tag) - # found = document.getElementsByClassName(class_name) + # found = self.ownerDocument.getElementsByClassName(class_name) context = [] for fnd in found: if fnd.getAttribute("class") and re.search( @@ -2325,6 +2430,17 @@ def getElements(context, tag): def append(self, *args): """Inserts a set of Node objects or DOMString objects after the last child of the Element.""" self.args += args + self._add_mutation(**{ + "name": None, + "type": "childList", + "addedNodes": NodeList(args), + "namespace": None, + "nextSibling": None, + "oldValue": None, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) return self # elem.attachShadow({mode: open|closed}) @@ -2356,7 +2472,20 @@ def innerHTML(self): def innerHTML(self, value): if value is not None: # TODO - will need the parser to work for this to work properly. for now shove all on first content node - self.args = (value,) + oldValue = [*self.args] + + self.args = (eval(HtmlToPy(value), globals()),) + self._add_mutation(**{ + "name": "innerHTML", + "type": "characterData", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": oldValue, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) return self.content @property @@ -2368,14 +2497,25 @@ def outerHTML(self, value): if isinstance(value, Element): self = value if isinstance(value, str): - # self = value - # TODO - parse - # TODO - will need the parser to work for this to work properly - pass + node = eval(HtmlToPy(value), globals()) + if self.parentNode: + self.parentNode.replaceChild(node, self) return self def html(self, *args): + oldValue = [*self.args] self.args = args + self._add_mutation(**{ + "name": "html", + "type": "characterData", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": oldValue, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) return self def blur(self): @@ -2450,13 +2590,12 @@ def dataset(self): # return self.getAttribute('data-*') # TODO - copilot suggested a star. is that supposed to work? # loop all attributes and return the ones that start with data- # return {key: value for key, value in self.kwargs.items() if key.startswith('data-')} - from domonic.utils import Utils dsmap = DOMStringMap() for key, value in self.kwargs.items(): if key.startswith("data-"): # remove data from the key and change case to lower - key = Utils.camel_case(key.replace("data-", "")) + key = case_camel(key.replace("data-", "")) dsmap[key] = value return dsmap @@ -2599,9 +2738,25 @@ def id(self, newid: str): self.setAttribute("id", newid) # Sets or returns the text content of a node and its descendants + @property + def innerText(self): + return "".join([str(each) for each in self.args if not isinstance(each, Node)]) + + @innerText.setter def innerText(self, *args): + oldValue = self.innerText self.args = args - return "".join([each.__str__() for each in self.args]) + self._add_mutation(**{ + "name": "innerText", + "type": "characterData", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": oldValue, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) # Inserts an element adjacent to the current element def insertAdjacentElement(self, position: str, element): # TODO - test. these look wrong. @@ -2712,6 +2867,7 @@ def normalize(self): """Joins adjacent text nodes and removes empty text nodes in an element""" content = [] nodestr = "" + oldValue = self.innerText for s in self.args: if type(s) == Text: # content.append(s.textContent) @@ -2728,6 +2884,18 @@ def normalize(self): if nodestr != "": content.append(nodestr) self.args = content + + self._add_mutation(**{ + "name": "normalize", + "type": "characterData", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": oldValue, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) return self.args def offsetHeight(self): @@ -2768,6 +2936,27 @@ def prepend(self, *args): """Prepends a node to the current element""" newargs = list(args) + list(self.args) self.args = tuple(newargs) + self._add_mutation(**{ + "name": None, + "type": "childList", + "addedNodes": NodeList(args), + "namespace": None, + "nextSibling": None, + "oldValue": None, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) + + # def before(self, *args): + # """Prepends a node to the current element""" + # self.parentElement + # self.args = tuple(newargs) + + # def after(self, *args): + # """Prepends a node to the current element""" + # newargs = list(args) + list(self.args) + # self.args = tuple(newargs) def querySelector(self, query: str): """[Returns the first child element that matches a specified CSS selector(s) of an element] @@ -2783,7 +2972,7 @@ def querySelector(self, query: str): except Exception as e: return None - def querySelectorAll(self, query: str): + def querySelectorAll(self, query: str) -> NodeList: """[Returns all child elements that matches a specified CSS selector(s) of an element] Args: @@ -2792,6 +2981,8 @@ def querySelectorAll(self, query: str): Returns: [type]: [a list of Element objects] """ + query = query.strip() + naked_query = query[1:] if "." in naked_query or "[" in naked_query or " " in naked_query: # return self.getElementsBySelector(query, self) @@ -2800,7 +2991,6 @@ def querySelectorAll(self, query: str): try: expression = HTMLTranslator().css_to_xpath(query) - from domonic.webapi.xpath import XPathEvaluator, XPathResult evaluator = XPathEvaluator() expression = evaluator.createExpression(expression) @@ -2813,11 +3003,11 @@ def querySelectorAll(self, query: str): elements = [] def anon(el): - if self._matchElement(el, query): + if query == "*" or self._matchElement(el, query): elements.append(el) self._iterate(self, anon) - return elements + return NodeList(elements) def remove(self): """Removes the element from the DOM""" @@ -2837,7 +3027,19 @@ def removeAttribute(self, attribute: str): try: if attribute[0:1] != "_": attribute = "_" + attribute + oldValue = self.kwargs.get(attribute) del self.kwargs[attribute] + self._add_mutation(**{ + "name": attribute, + "type": "attributes", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": oldValue, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) except Exception as e: print("failed to remove!", e) pass @@ -2848,6 +3050,18 @@ def removeAttributeNode(self, attribute): # untested if attribute == each: val = self.kwargs[each] del self.kwargs[each] + + self._add_mutation(**{ + "name": attribute, + "type": "attributes", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": None, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) return Attr(attribute, val) def requestFullscreen(self): @@ -2887,9 +3101,24 @@ def setAttribute(self, attribute, value): try: if attribute[0:1] != "_": attribute = "_" + attribute + oldValue = self.kwargs.get(attribute) + + if oldValue == value: return + self.kwargs[attribute] = value + self._add_mutation(**{ + "name": attribute.lstrip("_"), + "type": "attributes", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": oldValue, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) except Exception as e: - # print('failed to set attribute', e) + print('failed to set attribute:', repr(e)) return None def setAttributeNode(self, attr): @@ -2946,7 +3175,6 @@ def toString(self) -> str: """Converts an element to a string""" return str(self) - class DOMImplementation: def __init__(self): # self.__domImplementation = None @@ -4091,7 +4319,19 @@ def data(self): @data.setter def data(self, data): + oldValue = self.data self.args = (data,) + self._add_mutation(**{ + "name": "data", + "type": "characterData", + "addedNodes": NodeList(), + "namespace": None, + "nextSibling": None, + "oldValue": oldValue, + "previousSibling": None, + "removedNodes": NodeList(), + "target": self, + }) return self.args[0] nodeType: int = Node.TEXT_NODE From 6d832baf8fffb780a6c388f9b1b5179f8ff1d879 Mon Sep 17 00:00:00 2001 From: 7HR4IZ3 <90985774+7HR4IZ3@users.noreply.github.com> Date: Mon, 5 Feb 2024 21:27:49 +0100 Subject: [PATCH 3/6] Create mutationobserver.py --- examples/mutationobserver.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 examples/mutationobserver.py diff --git a/examples/mutationobserver.py b/examples/mutationobserver.py new file mode 100644 index 0000000..572ec0a --- /dev/null +++ b/examples/mutationobserver.py @@ -0,0 +1,22 @@ +from domonic import * +from domonic.dom import MutationObserver, MutationRecord + +node = div( + p("Hello WOrld") +) + +def on_mutation(records: list[MutationRecord]): + print(records) + +observer = MutationObserver(on_mutation, interval=0) +observer.observe( + node, subtree=True, childList=True, attributes=True, + attributeFilter=None, attributeOldValue=True, + characterData=True, characterDataOldValue=True +) + +p.appendChild(a("CLick Me", _href="/home")) + +p.innerHTML = "Hello Wordl!!" + +p.remove() From b843cf5be5b3d5d62a36403ea0a20b1131389f8d Mon Sep 17 00:00:00 2001 From: 7HR4IZ3 <90985774+7HR4IZ3@users.noreply.github.com> Date: Mon, 5 Feb 2024 21:45:55 +0100 Subject: [PATCH 4/6] Update dom.py --- domonic/dom.py | 96 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 85 insertions(+), 11 deletions(-) diff --git a/domonic/dom.py b/domonic/dom.py index c4b3952..a178a7a 100644 --- a/domonic/dom.py +++ b/domonic/dom.py @@ -23,8 +23,10 @@ # from xml.dom.pulldom import END_ELEMENT +import time +import typing as t from functools import wraps -from threading import Thread +from threading import Thread, Event def task(func, handler=Thread, *ta, **tkw): @wraps(func) @@ -37,6 +39,79 @@ def wrapper(*a, **kw): def daemon_task(func, *args, **kwargs): return task(func, *args, handler=Thread, daemon=True, **kwargs) + +def generate_xpath(node): + if not node: return None + + temp_one = get_element_index( + node, + node.parentNode.children if node.parentNode else [] + ) + + try: + last_node_index = temp_one.index(node) + except ValueError: + last_node_index = 1 + + if len(temp_one) == 1: + path = "/" + node.name + elif len(temp_one) > 1: + last_node_index = last_node_index + 1 + path = "/" + node.name + "[" + last_node_index + "]" + else: + path = "" + + while node and node.name != "html" and node.parentNode != None: + node = node.parentNode + + # When loop reaches the last element of the dom (body)*/ + if (node.name == "body"): + current = "/body" + path = current + path + break + + # if the node has id attribute and is not the last element */ + if node.id and node.id != "" and node.name != "body": + current = "/" + node.name + "[@id='" + node.id + "']" + path = current + path + break + + # if the node has class attribute and has no id attribute or is not the last element */ + if ((not node.id or node.id == "") and node.name != "body"): + if (node.parentNode != None): + temp = get_element_index( + node, node.parentNode.children + ) + try: + node_index = temp_one.index(node) + except ValueError: + node_index = 1 + + if len(temp) == 1: + current = "/" + node.name + elif len(temp) > 1: + node_index = node_index + 1 + current = "/" + node.name + "[" + node_index + "]" + + path = current + path + + return "/" + path + + +def get_element_index(node, children): + if not node: + return + + temp = [] + + for child in children: + if child and child.name == node.name: + temp.append(child) + + return temp + + + # TODO - unit tests class DOMConfig: """DOMConfig: Not to be confused with the obsolete DOMConfiguration. @@ -134,7 +209,7 @@ def __init__(self, *args, **kwargs) -> None: ] ) except IndexError as e: - + from domonic.html import TemplateError raise TemplateError(e) # except Exception as e: # print(e) @@ -290,7 +365,7 @@ def format_attr(key, value): try: return "".join([format_attr(key, value) for key, value in self.kwargs.items()]) except IndexError as e: - + from domonic.html import TemplateError raise TemplateError(e) from None # except Exception as e: # print(e) @@ -310,7 +385,7 @@ def __attributes__(self, ignore): ] ) except IndexError as e: - + from domonic.html import TemplateError raise TemplateError(e) from None # except Exception as e: # print(e) @@ -589,7 +664,7 @@ def __format__(self, format_spec): dtype = self.doctype # if self is a closed_tag, return the content - + from domonic.html import closed_tag if isinstance(self, closed_tag): return f"\n{dent}<{self.name}{self.__attributes__} />" @@ -2474,7 +2549,7 @@ def innerHTML(self, value): # TODO - will need the parser to work for this to work properly. for now shove all on first content node oldValue = [*self.args] - self.args = (eval(HtmlToPy(value), globals()),) + self.args = (value,) self._add_mutation(**{ "name": "innerHTML", "type": "characterData", @@ -2497,9 +2572,7 @@ def outerHTML(self, value): if isinstance(value, Element): self = value if isinstance(value, str): - node = eval(HtmlToPy(value), globals()) - if self.parentNode: - self.parentNode.replaceChild(node, self) + pass return self def html(self, *args): @@ -2591,11 +2664,12 @@ def dataset(self): # loop all attributes and return the ones that start with data- # return {key: value for key, value in self.kwargs.items() if key.startswith('data-')} + from domonic.utils import Utils dsmap = DOMStringMap() for key, value in self.kwargs.items(): if key.startswith("data-"): # remove data from the key and change case to lower - key = case_camel(key.replace("data-", "")) + key = Utils.case_camel(key.replace("data-", "")) dsmap[key] = value return dsmap @@ -4471,7 +4545,7 @@ def __init__( self, callback: t.Optional[t.Callable[[list[MutationRecord]], None]] = None, interval=.5, append_callback: t.Optional[t.Callable[[MutationRecord], None]] = None ): - self.is_connected = threading.Event() + self.is_connected = Event() self.callback = callback self.append_callback = append_callback self.interval = interval From 16157cb443d2d4e1a9998e2d6da58cef6f5e4d68 Mon Sep 17 00:00:00 2001 From: 7HR4IZ3 <90985774+7HR4IZ3@users.noreply.github.com> Date: Tue, 6 Feb 2024 06:21:43 +0100 Subject: [PATCH 5/6] Fixed bug in generate_xpath function Added a global instantiation of the 'current' variable which was previously only created when looping through the child of the target node, but is also accessed outside the loop causing the function to fail for single nodes with no children. --- domonic/dom.py | 1 + 1 file changed, 1 insertion(+) diff --git a/domonic/dom.py b/domonic/dom.py index a178a7a..ef6b839 100644 --- a/domonic/dom.py +++ b/domonic/dom.py @@ -43,6 +43,7 @@ def daemon_task(func, *args, **kwargs): def generate_xpath(node): if not node: return None + current = "" temp_one = get_element_index( node, node.parentNode.children if node.parentNode else [] From b086f7aa75aae976452101c5d3d89ec6756e86dd Mon Sep 17 00:00:00 2001 From: 7HR4IZ3 <90985774+7HR4IZ3@users.noreply.github.com> Date: Sat, 10 Feb 2024 14:53:14 +0100 Subject: [PATCH 6/6] Removed xpath generation --- domonic/dom.py | 82 ++------------------------------------------------ 1 file changed, 2 insertions(+), 80 deletions(-) diff --git a/domonic/dom.py b/domonic/dom.py index ef6b839..a5c00a4 100644 --- a/domonic/dom.py +++ b/domonic/dom.py @@ -40,79 +40,6 @@ def daemon_task(func, *args, **kwargs): return task(func, *args, handler=Thread, daemon=True, **kwargs) -def generate_xpath(node): - if not node: return None - - current = "" - temp_one = get_element_index( - node, - node.parentNode.children if node.parentNode else [] - ) - - try: - last_node_index = temp_one.index(node) - except ValueError: - last_node_index = 1 - - if len(temp_one) == 1: - path = "/" + node.name - elif len(temp_one) > 1: - last_node_index = last_node_index + 1 - path = "/" + node.name + "[" + last_node_index + "]" - else: - path = "" - - while node and node.name != "html" and node.parentNode != None: - node = node.parentNode - - # When loop reaches the last element of the dom (body)*/ - if (node.name == "body"): - current = "/body" - path = current + path - break - - # if the node has id attribute and is not the last element */ - if node.id and node.id != "" and node.name != "body": - current = "/" + node.name + "[@id='" + node.id + "']" - path = current + path - break - - # if the node has class attribute and has no id attribute or is not the last element */ - if ((not node.id or node.id == "") and node.name != "body"): - if (node.parentNode != None): - temp = get_element_index( - node, node.parentNode.children - ) - try: - node_index = temp_one.index(node) - except ValueError: - node_index = 1 - - if len(temp) == 1: - current = "/" + node.name - elif len(temp) > 1: - node_index = node_index + 1 - current = "/" + node.name + "[" + node_index + "]" - - path = current + path - - return "/" + path - - -def get_element_index(node, children): - if not node: - return - - temp = [] - - for child in children: - if child and child.name == node.name: - temp.append(child) - - return temp - - - # TODO - unit tests class DOMConfig: """DOMConfig: Not to be confused with the obsolete DOMConfiguration. @@ -1207,9 +1134,6 @@ def removeChild(self, node): if each == node: n = node - # NOTE: Remove in main implementation - xpath = generate_xpath(node) - n.parentNode = None replace_args = list(self.args) replace_args.remove(node) @@ -1223,7 +1147,7 @@ def removeChild(self, node): "nextSibling": None, "oldValue": None, "previousSibling": None, - "removedNodes": NodeList([(node, xpath)]), + "removedNodes": NodeList([node]), "target": self, }) @@ -1246,8 +1170,6 @@ def replaceChild(self, newChild, oldChild): """ for count, each in enumerate(self.args): if each == oldChild: - # NOTE: Remove in main implementation - xpath = generate_xpath(oldChild) replace_args = list(self.args) replace_args[count] = newChild @@ -1260,7 +1182,7 @@ def replaceChild(self, newChild, oldChild): "nextSibling": None, "oldValue": None, "previousSibling": None, - "removedNodes": NodeList([(oldChild, xpath)]), + "removedNodes": NodeList([oldChild]), "target": self, }) return oldChild