Repository: zauberzeug/nicegui
Pull request #2594 (2024-02-22 10:23:56)

Add clipboard functions
This PR adds clipboard functions to read and write to/from the clipboard. The functions include `read()`, `readText()`, `write()`, and `writeText()`. These functions should allow users to interact with the clipboard and perform operations such as reading data from the clipboard and writing data to the clipboard.

Note: For now I've only tested `ui.clipboard.write()` and `ui.clipboard.writeText()`, which is why this PR will be a draft until we figure out if everything works as expected.

---

Feature request: #2575

Open tasks:

- [x] tests
- [x] documentation


Patch:
diff --git a/README.md b/README.md
index 5c5730d8..374740b4 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,18 @@ which itself is based on the ASGI framework [Starlette](https://www.starlette.io
 and the ASGI webserver [Uvicorn](https://www.uvicorn.org/)
 because of their great performance and ease of use.
 
+## Sponsors
+
+Maintenance of this project is made possible by all the [contributors](https://github.com/zauberzeug/nicegui/graphs/contributors) and [sponsors](https://github.com/sponsors/zauberzeug).
+If you would like to support this project and have your avatar or company logo appear below, please [sponsor us](https://github.com/sponsors/zauberzeug). 💖
+
+<p align="center">
+   <a href="https://github.com/lechler-gmbh"><img src="https://github.com/lechler-gmbh.png" width="50px" alt="Lechler GmbH" /></a>
+</p>
+
+Consider this low-barrier form of contribution yourself.
+Your [support](https://github.com/sponsors/zauberzeug) is much appreciated.
+
 ## Contributing
 
 Thank you for your interest in contributing to NiceGUI! We are thrilled to have you on board and appreciate your efforts to make this project even better.
@@ -113,6 +125,6 @@ We have provided a detailed guide on how to contribute to NiceGUI in our [CONTRI
 
 If you have any questions or need help with anything, please don't hesitate to reach out to us. We are always here to support and guide you through the contribution process.
 
-### Included Web Dependencies
+## Included Web Dependencies
 
 See [DEPENDENCIES.md](https://github.com/zauberzeug/nicegui/blob/main/DEPENDENCIES.md) for a list of web frameworks NiceGUI depends on.
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/nicegui/functions/clipboard.py b/nicegui/functions/clipboard.py
new file mode 100644
index 00000000..5b70a09a
--- /dev/null
+++ b/nicegui/functions/clipboard.py
@@ -0,0 +1,15 @@
+from .. import json
+from .javascript import run_javascript
+
+
+async def read() -> str:
+    """Read text from the clipboard."""
+    return await run_javascript('navigator.clipboard.readText()')
+
+
+def write(text: str) -> None:
+    """Write text to the clipboard.
+
+    :param text: text to write
+    """
+    run_javascript(f'navigator.clipboard.writeText({json.dumps(text)})')
diff --git a/nicegui/ui.py b/nicegui/ui.py
index 2082dc7b..ac87b239 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -13,6 +13,7 @@ __all__ = [
     'chart',
     'chat_message',
     'checkbox',
+    'clipboard',
     'code',
     'color_input',
     'color_picker',
@@ -204,6 +205,7 @@ from .elements.tooltip import Tooltip as tooltip
 from .elements.tree import Tree as tree
 from .elements.upload import Upload as upload
 from .elements.video import Video as video
+from .functions import clipboard
 from .functions.download import download
 from .functions.html import add_body_html, add_head_html
 from .functions.javascript import run_javascript
diff --git a/website/documentation/content/clipboard_documentation.py b/website/documentation/content/clipboard_documentation.py
new file mode 100644
index 00000000..1870c606
--- /dev/null
+++ b/website/documentation/content/clipboard_documentation.py
@@ -0,0 +1,29 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo('Read and write to the clipboard', '''
+    The following demo shows how to use `ui.clipboard.read()` and `ui.clipboard.write()` to interact with the clipboard.
+    Note that your browser may ask for permission to access the clipboard or may not support this feature at all.
+''')
+def main_demo() -> None:
+    ui.button('Write', on_click=lambda: ui.clipboard.write('Hi!'))
+
+    async def read() -> None:
+        ui.notify(await ui.clipboard.read())
+    ui.button('Read', on_click=read)
+
+
+@doc.demo('Client-side clipboard', '''
+    In order to avoid the round-trip to the server, you can also use the client-side clipboard API directly.
+    This might be supported by more browsers because the clipboard access is directly triggered by a user action.
+''')
+def client_side_clipboard() -> None:
+    ui.button('Write').on('click', js_handler='''
+        () => navigator.clipboard.writeText("Ho!")
+    ''')
+    ui.button('Read').on('click', js_handler='''
+        async () => emitEvent("clipboard", await navigator.clipboard.readText())
+    ''')
+    ui.on('clipboard', lambda e: ui.notify(e.args))
diff --git a/website/documentation/content/section_action_events.py b/website/documentation/content/section_action_events.py
index e5ce80bc..684c343a 100644
--- a/website/documentation/content/section_action_events.py
+++ b/website/documentation/content/section_action_events.py
@@ -1,7 +1,7 @@
 from nicegui import app, ui
 
-from . import (doc, generic_events_documentation, keyboard_documentation, refreshable_documentation,
-               run_javascript_documentation, storage_documentation, timer_documentation)
+from . import (clipboard_documentation, doc, generic_events_documentation, keyboard_documentation,
+               refreshable_documentation, run_javascript_documentation, storage_documentation, timer_documentation)
 
 doc.title('Action & *Events*')
 
@@ -105,6 +105,7 @@ def io_bound_demo():
 
 
 doc.intro(run_javascript_documentation)
+doc.intro(clipboard_documentation)
 
 
 @doc.demo('Events', '''


Test patch:
diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py
new file mode 100644
index 00000000..a6e0c01b
--- /dev/null
+++ b/tests/test_clipboard.py
@@ -0,0 +1,20 @@
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+def test_clipboard(screen: Screen):
+    ui.button('Copy to clipboard', on_click=lambda: ui.clipboard.write('Hello, World!'))
+
+    async def read_clipboard():
+        ui.notify('Clipboard: ' + await ui.clipboard.read())
+    ui.button('Read from clipboard', on_click=read_clipboard)
+
+    screen.open('/')
+    screen.selenium.set_permissions('clipboard-read', 'granted')
+    screen.selenium.set_permissions('clipboard-write', 'granted')
+
+    screen.click('Copy to clipboard')
+    assert screen.selenium.execute_script('return navigator.clipboard.readText()') == 'Hello, World!'
+
+    screen.click('Read from clipboard')
+    screen.should_contain('Clipboard: Hello, World!')
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()