Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DOM support/manipulation #1218

Closed
r0x0r opened this issue Sep 14, 2023 · 8 comments
Closed

DOM support/manipulation #1218

r0x0r opened this issue Sep 14, 2023 · 8 comments
Labels

Comments

@r0x0r
Copy link
Owner

r0x0r commented Sep 14, 2023

The next major version of pywebview (5.0) is going to have support for DOM manipulation and event handling. The idea is to provide a basic set of functions that would allow to manipulate DOM and handle DOM events in Python. Event handling is already implemented in the dom branch. Syntax so far looks like this

element = window.dom.get_element('#element')
element.on('click', click_handler) 
# this work as well
element.events.click += click_handler

You can take a look at a working example at https://github.com/r0x0r/pywebview/blob/dom/examples/dom_events.py

window.get_elements got refactored so it returns a new Element object. The underlying DOM node is available via element.node. I am not 100% sure about this syntax, so this might change. Also should properties of element.node be accessible via class dot notation instead of the dict? Ie. element.node.offsetTop instead of element.node['offsetTop']

Window object got a new window.dom property that currently hosts window.dom.document and window.dom.window (correspond to DOM's window.document and window respectively). These are instances of Element, meaning that they follow the element.node syntax. Ie to get a window scroll position, you call window.dom.window.node['scrollY']. Would `window.dom.window.scrollY make a better choice?

DOM manipulation will provide a basic jQuery like set of functions. hide/show/toggle/remove/next/set_style etc. Nothing is done on this front yet.

Implementation follows a single source of truth principle, ie no data is cached on the Python side, but the latest state is always fetched from DOM .

Please note that these are preliminary ideas and nothing is set it stone. Discussion, ideas and suggestions are welcomed.

So far API changes are as follows

Window

dom - and instance of a DOM object
get_elements(selector: str) returns a list of Element objects. Deprecated in favour of window.dom.get_elements
evaluate_js raises a JavascriptException (serialized Javascript error) if executed code has an error.

DOM

body: Element - an instance of document body
document: Element - an instance of window.document
window: Element - an instance of window

create_element(html: str, parent: Optional[Element]=None) -> Element - creates a DOM structure that corresponds to the given html code. Returns an Element of the root object . If parent is provided, created DOM is attached to the parent as a last child. Otherwise attached to document body.
get_element(selector: str) -> Optional[Element]) - get a first Element matching the selector. None if not found.
get_elements(selector: str) -> list[Element]) - get a list of Element matching the selector.

webview.dom.Element

node: dict- jsonified representation of the matching DOM node. Node's children are not included.
id: str - node's id. Get/set property
tag: str - a tag name
tabindex: int - Get/set tabindex.
text: str- Get/set text content of the element
value: any - Get/set value of the element. Applicable only to input elements that have a value
focused: bool - whether element has a focus. Get property
visible: bool - whether element is visible
'classes: ClassList' - a list of node's classes. Returns a list like object that can be mutated to update node's classes. Get/set property, accepts an Iterable as a setter param.
attributes: PropsDict - Node's attributes. Returns a dict like object that can be mutated to update node's attributes, ie. element.attributes['id'] = 'container' Get/set property, accepts a dictionary as a setter param. When assigning a new value, new attributes are appended to existing ones overwriting matching attribute keys. Existing non-matching attributes are kept.
style: PropsDict - Node's styles. Get/set property. Works in the same way as attributes.
events - a container class of node's all DOM events. ie events.click, event.keydown etc

children -> list['Element'] - get a list of node's children. Get property.
parent -> Union['Element', None] - get node's parent or None for the root node . Get property.
append(html: str) -> Element - create and append html as a last child
next -> Union['Element', None] - get node's next sibling. Get property.
previous -> Union['Element', None] - get node's previous sibling. Get property.

hide() - hide element
show() - show element
focus() - focus element
blur() - blur element
toggle() - toggle element's visibility
copy(target: Union[str, 'Element']=None, mode=ManipulationMode.LastChild, id: str=None) - creates a new copy of the element. If target is omitted, a copy is created in the current element's parent. mode parameters specifies in which fashion the copy will be inserted to the target. The id parameter is stripped from the copy. Optionally you can set a new id by specifying the id parameter.
move(target: Union[str, 'Element'], mode=ManipulationMode.LastChild) - moves the element to the target container. mode parameters specifies in which fashion the copy will be inserted to the target.
remove() - remove element from DOM. After element is removed, trying to access any of element's properties/methods results in a warning.
empty() - empty element by removing all its children.
on(event: str, callback: Callable) - attach an event handler to element's DOM event.
off(event: str, callback: Callable) - remove a previously attached event handler from element's DOM event.

webview.dom.ManipulationMode enum

Can be used as an argument to element.copy and element.move to set in which fashion element is moved/copied to another parent. Possible values are LastChild, FirstChild, Before, After, Replace

webview.dom.DOMEventHandler

DOMEventHandler(callback: Callback, prevent_default=False, stop_propagation=False) an event handler container used if you need to prevent default behaviour of the catch event or stop event propagation. Example usage element.events.click += DOMEventHandler(lambda e: e, prevent_default=True, stop_propagation=True)

@r0x0r r0x0r added the future label Sep 14, 2023
@louisnw01
Copy link
Contributor

Hey @r0x0r,

I was actually implementing something similar myself. I think this is a great idea!

Would it make more sense to have a get_element instead which returns a single element as you are using HTML id's? And then use get_elements on types like div, span, h1, etc.

It would also be great if you could call get_elements prior to webview.start, by saving anything called before to a list and evaluating after the DOM has loaded.

Syntax wise, an alternative to element.on('click', foo) could be element.click += foo, which is more pythonic but dissimilar to javascript, so thats your call.

@r0x0r
Copy link
Owner Author

r0x0r commented Sep 18, 2023

@louisnw01 Thanks for suggestions. I have implemented both get_element and an alternative way to handle DOM events. get_element and get_elements are now moved to window.dom (window.get_elements is marked deprecated) and DOM events are generated automatically and can be found under element.events. I left element.on and element.off intact as an alternative way to attach/remove events.

Calling get_elements before webview.start is tricky, as pywebview does not assume anything about DOM, but rather makes Javascript calls to get the latest state. What is your use case for this? Does architecture in examples/dom_manipulation.py help your case?

These and other changes are pushed to the dom branch. I have also updated the original post with API changes to easier tracking.

@louisnw01
Copy link
Contributor

Great solution for get_elements and element.events.

As for calling before start, I achieved something similar in my charting library (using pywebview) by creating a run_script function (shown here), which either uses evaluate_js, or saves the script to a list and executes it once the window has loaded.

However, as your implementation requires an object to be returned to a user-declared variable (create_element for example) you would need a way to define unique elements without the DOM giving you any information about that element. I achieved this functionality with a random generator, which returns an id window.<random_str>. That way the node_id wouldn't be required. So your create_element and Element class could look something like this:

class Element:
    def __init__(self, window, node_id) -> None:
        self.__window = window
        self.events = EventContainer()
        self._id = window.generate_unique_id()  # window.iudjrnmk
        ...

def create_element(self, html: str, parent: Optional[Element]=None) -> Element:
        parent_selector = parent._query_string if parent else 'document.body'

       element = Element(self.__window)

        self.run_script(f"""
            var parent = {parent_selector};
            var template = document.createElement('template');
            template.innerHTML = '{escape_quotes(html)}'.trim();
            {element._id} = template.content.firstChild;
            parent.appendChild({element._id});
            pywebview._getNodeId({element._id});
        """)

        

        return element

This way, the python representations of the DOM elements are not dependant upon objects returned from JavaScript, meaning the scripts can be evaluated in the future (once the window has loaded) by using the element._id to access the respective element.

@r0x0r
Copy link
Owner Author

r0x0r commented Sep 21, 2023

@louisnw01 Thanks for the suggestion. What about get_element? How should it be handled before program start? Does it make any sense at all?
I am vary about introducing any more complexity at this point. As this work is quite a massive change already. So far I have been sticking to the single source of truth principle, getting the latest state directly from DOM and not caching anything on the Python side.

@r0x0r
Copy link
Owner Author

r0x0r commented Sep 21, 2023

More updates on the implementation.

  • Added element.show(), element.hide(), element.toggle() and element.visible (the latter is read only), as well as element.focus(), element.blur() and element.focused (read only). I have been thinking of getting rid of getter/setter and making element.visible and element.focused property setters, but it might make not sense. As element might be hidden for other reasons other than hiding it with display: none. Same applies to focus. Opinions?
  • elevate_js throws a JavascriptException if an error is thrown on a JS side.
  • Added add_class, remove_class and toggle_class, but been thinking about replacing them with a list like class. So element.add_class would become element.classes.append, remove_class -> element.classes.remove, element.toggle_class -> element.classes.toggle
  • Similar thing could be done to element.style and element.attributes. The current way of setting style is rather cumbersome, ie element.style = {'display': 'flex'}. element.style['display'] = 'flex' would be much nicer.

@r0x0r r0x0r pinned this issue Sep 21, 2023
@r0x0r
Copy link
Owner Author

r0x0r commented Sep 24, 2023

  • Added element.copy and element.move
  • Added a ManipulationMode enum to control how node is copied/moved
  • element.classes returns now a list like object. Classes can set/removed/toggled like element.classes.append('class'), element.classes.remove('class'), element.classes.toggle('class'),
  • element.attributes returns a dict like object. To set an attribute element.attributes['id'] = 'test'. To remove an attribute del element.attributes['id'].
  • element.style returns a dict like object. To set a style element.style['background-color'] = 'red'. To reset a style del element.style['background-color']

The DOM implementation seems to be pretty much complete basic feature wise to me. I take comments / suggestions on the implementation.
The target date for the release is near the end of this year.

@r0x0r
Copy link
Owner Author

r0x0r commented Sep 27, 2023

Added DOMEventHandler(callback: Callback, prevent_default=False, stop_propagation=False) an event handler container.
It is used if you need to prevent default behaviour of the catch event or stop event propagation. Example usage element.events.click += DOMEventHandler(lambda e: e, prevent_default=True, stop_propagation=True)

element.events.click += event_handler is equivalent to element.events.click += DOMEventHandler(event_handler, prevent_default=True, stop_propagation=True)

@r0x0r r0x0r mentioned this issue Sep 29, 2023
@r0x0r
Copy link
Owner Author

r0x0r commented Mar 2, 2024

Released in 5.0.1

@r0x0r r0x0r closed this as completed Mar 2, 2024
@r0x0r r0x0r unpinned this issue Mar 7, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants