Repository: zauberzeug/nicegui
Pull request #2765 (2024-03-25 22:45:43)

Adding upload_all argument for upload element
Not sure what the most performant approach here is. I could remove the initial conditional check and have the lists populate regardless and only get used if the later conditional checks are met. Also had to adjust UploadEventArguments to accept lists, let me know if that's no good.

---

Feature request: #2698


Patch:
diff --git a/nicegui/elements/editor.py b/nicegui/elements/editor.py
index a08e02c0..a9ce450b 100644
--- a/nicegui/elements/editor.py
+++ b/nicegui/elements/editor.py
@@ -5,6 +5,7 @@ from .mixins.value_element import ValueElement
 
 
 class Editor(ValueElement, DisableableElement):
+    LOOPBACK = None
 
     def __init__(self,
                  *,
diff --git a/nicegui/elements/interactive_image.js b/nicegui/elements/interactive_image.js
index e6493fb0..37c68f4d 100644
--- a/nicegui/elements/interactive_image.js
+++ b/nicegui/elements/interactive_image.js
@@ -10,7 +10,7 @@ export default {
         v-on="onUserEvents"
         draggable="false"
       />
-      <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
+      <svg ref="svg" style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
         <g v-if="cross" :style="{ display: showCross ? 'block' : 'none' }">
           <line :x1="x" y1="0" :x2="x" y2="100%" :stroke="cross === true ? 'black' : cross" />
           <line x1="0" :y1="y" x2="100%" :y2="y" :stroke="cross === true ? 'black' : cross" />
@@ -45,6 +45,18 @@ export default {
     };
     this.$refs.img.addEventListener("load", handle_completion);
     this.$refs.img.addEventListener("error", handle_completion);
+    for (const event of [
+      "pointermove",
+      "pointerdown",
+      "pointerup",
+      "pointerover",
+      "pointerout",
+      "pointerenter",
+      "pointerleave",
+      "pointercancel",
+    ]) {
+      this.$refs.svg.addEventListener(event, (e) => this.onPointerEvent(event, e));
+    }
   },
   updated() {
     this.compute_src();
@@ -79,12 +91,12 @@ export default {
       this.$emit("loaded", { width: this.loaded_image_width, height: this.loaded_image_height, source: e.target.src });
     },
     onMouseEvent(type, e) {
-      const width = this.src ? this.loaded_image_width : this.size ? this.size[0] : 1;
-      const height = this.src ? this.loaded_image_height : this.size ? this.size[1] : 1;
+      const imageWidth = this.src ? this.loaded_image_width : this.size ? this.size[0] : 1;
+      const imageHeight = this.src ? this.loaded_image_height : this.size ? this.size[1] : 1;
       this.$emit("mouse", {
         mouse_event_type: type,
-        image_x: (e.offsetX * width) / e.target.clientWidth,
-        image_y: (e.offsetY * height) / e.target.clientHeight,
+        image_x: (e.offsetX * imageWidth) / this.$refs.img.clientWidth,
+        image_y: (e.offsetY * imageHeight) / this.$refs.img.clientHeight,
         button: e.button,
         buttons: e.buttons,
         altKey: e.altKey,
@@ -93,6 +105,16 @@ export default {
         shiftKey: e.shiftKey,
       });
     },
+    onPointerEvent(type, e) {
+      const imageWidth = this.src ? this.loaded_image_width : this.size ? this.size[0] : 1;
+      const imageHeight = this.src ? this.loaded_image_height : this.size ? this.size[1] : 1;
+      this.$emit(`svg:${type}`, {
+        type: type,
+        element_id: e.target.id,
+        image_x: (e.offsetX * imageWidth) / this.$refs.svg.clientWidth,
+        image_y: (e.offsetY * imageHeight) / this.$refs.svg.clientHeight,
+      });
+    },
   },
   computed: {
     onCrossEvents() {
diff --git a/nicegui/elements/interactive_image.py b/nicegui/elements/interactive_image.py
index a3005f1b..81f186a8 100644
--- a/nicegui/elements/interactive_image.py
+++ b/nicegui/elements/interactive_image.py
@@ -55,6 +55,7 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
         :param on_mouse: callback for mouse events (contains image coordinates `image_x` and `image_y` in pixels)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)
         :param cross: whether to show crosshairs or a color string (default: `False`)
+        :param on_pointer: callback for pointer events (contains image coordinates `image_x` and `image_y` in pixels, and `type` of the event)
         """
         super().__init__(source=source, content=content)
         self._props['events'] = events
diff --git a/nicegui/elements/mixins/value_element.py b/nicegui/elements/mixins/value_element.py
index dd1b7910..5b2a3a3e 100644
--- a/nicegui/elements/mixins/value_element.py
+++ b/nicegui/elements/mixins/value_element.py
@@ -9,7 +9,16 @@ from ...events import GenericEventArguments, ValueChangeEventArguments, handle_e
 
 class ValueElement(Element):
     VALUE_PROP: str = 'model-value'
-    LOOPBACK: bool = True
+    """Name of the prop that holds the value of the element"""
+
+    LOOPBACK: Optional[bool] = True
+    """Whether to set the new value directly on the client or after getting an update from the server.
+    
+    - ``True``: The value is updated by sending a change event to the server which responds with an update.
+    - ``False``: The value is updated by setting the VALUE_PROP directly on the client.
+    - ``None``: The value is updated automatically by the Vue element.
+    """
+
     value = BindableProperty(
         on_change=lambda sender, value: cast(Self, sender)._handle_value_change(value))  # pylint: disable=protected-access
 
@@ -27,7 +36,7 @@ class ValueElement(Element):
         self._change_handlers: list[Callable[..., Any]] = [on_value_change] if on_value_change else []
 
         def handle_change(e: GenericEventArguments) -> None:
-            self._send_update_on_value_change = self.LOOPBACK
+            self._send_update_on_value_change = self.LOOPBACK is True
             self.set_value(self._event_args_to_value(e))
             self._send_update_on_value_change = True
         self.on(f'update:{self.VALUE_PROP}', handle_change, [None], throttle=throttle)
diff --git a/nicegui/elements/upload.py b/nicegui/elements/upload.py
index 11c25aa3..c0c81aa5 100644
--- a/nicegui/elements/upload.py
+++ b/nicegui/elements/upload.py
@@ -1,10 +1,10 @@
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, Optional, cast
 
 from fastapi import Request
 from starlette.datastructures import UploadFile
 from typing_extensions import Self
 
-from ..events import UiEventArguments, UploadEventArguments, handle_event
+from ..events import MultiUploadEventArguments, UiEventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
 from .mixins.disableable_element import DisableableElement
 
@@ -17,6 +17,7 @@ class Upload(DisableableElement, component='upload.js'):
                  max_total_size: Optional[int] = None,
                  max_files: Optional[int] = None,
                  on_upload: Optional[Callable[..., Any]] = None,
+                 on_multi_upload: Optional[Callable[..., Any]] = None,
                  on_rejected: Optional[Callable[..., Any]] = None,
                  label: str = '',
                  auto_upload: bool = False,
@@ -29,7 +30,8 @@ class Upload(DisableableElement, component='upload.js'):
         :param max_file_size: maximum file size in bytes (default: `0`)
         :param max_total_size: maximum total size of all files in bytes (default: `0`)
         :param max_files: maximum number of files (default: `0`)
-        :param on_upload: callback to execute for each uploaded file (type: nicegui.events.UploadEventArguments)
+        :param on_upload: callback to execute for each uploaded file
+        :param on_multi_upload: callback to execute after multiple files have been uploaded
         :param on_rejected: callback to execute for each rejected file
         :param label: label for the uploader (default: `''`)
         :param auto_upload: automatically upload files when they are selected (default: `False`)
@@ -49,21 +51,32 @@ class Upload(DisableableElement, component='upload.js'):
         if max_files is not None:
             self._props['max-files'] = max_files
 
+        if multiple and on_multi_upload:
+            self._props['batch'] = True
+
         self._upload_handlers = [on_upload] if on_upload else []
+        self._multi_upload_handlers = [on_multi_upload] if on_multi_upload else []
 
         @app.post(self._props['url'])
         async def upload_route(request: Request) -> Dict[str, str]:
-            for data in (await request.form()).values():
-                assert isinstance(data, UploadFile)
-                args = UploadEventArguments(
+            form = await request.form()
+            for data in form.values():
+                for upload_handler in self._upload_handlers:
+                    handle_event(upload_handler, UploadEventArguments(
+                        sender=self,
+                        client=self.client,
+                        content=cast(UploadFile, data).file,
+                        name=cast(UploadFile, data).filename or '',
+                        type=cast(UploadFile, data).content_type or '',
+                    ))
+            for multi_upload_handler in self._multi_upload_handlers:
+                handle_event(multi_upload_handler, MultiUploadEventArguments(
                     sender=self,
                     client=self.client,
-                    content=data.file,
-                    name=data.filename or '',
-                    type=data.content_type or '',
-                )
-                for handler in self._upload_handlers:
-                    handle_event(handler, args)
+                    contents=[cast(UploadFile, data).file for data in form.values()],
+                    names=[cast(UploadFile, data).filename or '' for data in form.values()],
+                    types=[cast(UploadFile, data).content_type or '' for data in form.values()],
+                ))
             return {'upload': 'success'}
 
         if on_rejected:
@@ -74,6 +87,11 @@ class Upload(DisableableElement, component='upload.js'):
         self._upload_handlers.append(callback)
         return self
 
+    def on_multi_upload(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when multiple files have been uploaded."""
+        self._multi_upload_handlers.append(callback)
+        return self
+
     def on_rejected(self, callback: Callable[..., Any]) -> Self:
         """Add a callback to be invoked when a file is rejected."""
         self.on('rejected', lambda: handle_event(callback, UiEventArguments(sender=self, client=self.client)), args=[])
diff --git a/nicegui/events.py b/nicegui/events.py
index c4ecc440..dde00c0f 100644
--- a/nicegui/events.py
+++ b/nicegui/events.py
@@ -117,6 +117,13 @@ class UploadEventArguments(UiEventArguments):
     type: str
 
 
+@dataclass(**KWONLY_SLOTS)
+class MultiUploadEventArguments(UiEventArguments):
+    contents: List[BinaryIO]
+    names: List[str]
+    types: List[str]
+
+
 @dataclass(**KWONLY_SLOTS)
 class ValueChangeEventArguments(UiEventArguments):
     value: Any
diff --git a/website/documentation/content/interactive_image_documentation.py b/website/documentation/content/interactive_image_documentation.py
index 49071fce..c269c8c7 100644
--- a/website/documentation/content/interactive_image_documentation.py
+++ b/website/documentation/content/interactive_image_documentation.py
@@ -70,4 +70,26 @@ def crosshairs():
     ui.interactive_image('https://picsum.photos/id/565/640/360', cross='red')
 
 
+@doc.demo('SVG events', '''
+    You can subscribe to events of the SVG elements by using the `on` method with an "svg:" prefix.
+    Make sure to set `pointer-events="all"` for the SVG elements you want to receive events from.
+
+    Currently the following SVG events are supported:
+
+    - pointermove
+    - pointerdown
+    - pointerup
+    - pointerover
+    - pointerout
+    - pointerenter
+    - pointerleave
+    - pointercancel
+''')
+def svg_content():
+    ui.interactive_image('https://picsum.photos/id/565/640/360', cross=True, content='''
+        <rect id="A" x="85" y="70" width="80" height="60" fill="none" stroke="red" pointer-events="all" cursor="pointer" />
+        <rect id="B" x="180" y="70" width="80" height="60" fill="none" stroke="red" pointer-events="all" cursor="pointer" />
+    ''').on('svg:pointerdown', lambda e: ui.notify(f'SVG clicked: {e.args}'))
+
+
 doc.reference(ui.interactive_image)


Test patch:
diff --git a/tests/test_upload.py b/tests/test_upload.py
index 29cb4864..8b06c1ce 100644
--- a/tests/test_upload.py
+++ b/tests/test_upload.py
@@ -16,7 +16,7 @@ def test_uploading_text_file(screen: Screen):
     screen.should_contain('Test Title')
     screen.find_by_class('q-uploader__input').send_keys(str(test_path1))
     screen.wait(0.1)
-    screen.find_all_by_class('q-btn')[1].click()
+    screen.click('cloud_upload')
     screen.wait(0.1)
     assert len(results) == 1
     assert results[0].name == test_path1.name
@@ -32,7 +32,7 @@ def test_two_upload_elements(screen: Screen):
     screen.open('/')
     screen.should_contain('Test Title 1')
     screen.should_contain('Test Title 2')
-    screen.find_by_class('q-uploader__input').send_keys(str(test_path1))
+    screen.find_all_by_class('q-uploader__input')[0].send_keys(str(test_path1))
     screen.find_all_by_class('q-uploader__input')[1].send_keys(str(test_path2))
     screen.wait(0.1)
     assert len(results) == 2
@@ -90,3 +90,19 @@ def test_reset_upload(screen: Screen):
     screen.click('Reset')
     screen.wait(0.5)
     screen.should_not_contain(test_path1.name)
+
+
+def test_multi_upload_event(screen: Screen):
+    results: List[events.MultiUploadEventArguments] = []
+    ui.upload(on_multi_upload=results.append, multiple=True)
+
+    screen.open('/')
+    screen.find_by_class('q-uploader__input').send_keys(f'{test_path1}\n{test_path2}')
+    screen.wait(0.1)
+    screen.click('cloud_upload')
+    screen.wait(0.1)
+
+    assert len(results) == 1
+    assert results[0].names == [test_path1.name, test_path2.name]
+    assert results[0].contents[0].read() == test_path1.read_bytes()
+    assert results[0].contents[1].read() == test_path2.read_bytes()