Repository: zauberzeug/nicegui
Pull request #4265 (2025-01-24 14:56:45)

Prevent leaflet from resetting center and zoom when browser resizes
This PR tries to solve #4182 by changing the update mechanism for center and zoom yet again.

As it turned out, Vue's `updated()` lifecycle hook is not only called when a prop is updated from the server, but also when the window resizes (vertically). In this case the "center" and "zoom" props can be outdated if the user navigated the map manually. But the props are used to call `setView`, causing the map to reset to the last location sent from the server.

Various test setups from this issue as well as previous issues seem to work just right:
```py
l = ui.leaflet(zoom=5)
ui.button('set center', on_click=lambda: l.set_center((48.1373, 11.5755)))
```

```py
@ui.page('/')
async def page():
    m = ui.leaflet(zoom=5)
    central_park = m.generic_layer(name='polygon', args=[[
        (40.767809, -73.981249),
        (40.800273, -73.958291),
        (40.797011, -73.949683),
        (40.764704, -73.973741),
    ]])
    await m.initialized()
    bounds = await central_park.run_method('getBounds')
    m.run_map_method('fitBounds', [[bounds['_southWest'], bounds['_northEast']]])
```

```py
m = ui.leaflet(center=(48.1, 11.6), zoom=10)
m.on('map-moveend', lambda e: print(e.args['center'], e.args['zoom']))
ui.button('Berlin', on_click=lambda: m.run_map_method('flyTo', [52.5, 13.4], 9, {'duration': 1.0}))
```


Patch:
diff --git a/nicegui/elements/fullscreen.js b/nicegui/elements/fullscreen.js
new file mode 100644
index 00000000..f6bb34ba
--- /dev/null
+++ b/nicegui/elements/fullscreen.js
@@ -0,0 +1,32 @@
+export default {
+  props: {
+    requireEscapeHold: Boolean,
+  },
+  mounted() {
+    document.addEventListener("fullscreenchange", this.handleFullscreenChange);
+    document.addEventListener("mozfullscreenchange", this.handleFullscreenChange);
+    document.addEventListener("webkitfullscreenchange", this.handleFullscreenChange);
+    document.addEventListener("msfullscreenchange", this.handleFullscreenChange);
+  },
+  unmounted() {
+    document.removeEventListener("fullscreenchange", this.handleFullscreenChange);
+    document.removeEventListener("mozfullscreenchange", this.handleFullscreenChange);
+    document.removeEventListener("webkitfullscreenchange", this.handleFullscreenChange);
+    document.removeEventListener("msfullscreenchange", this.handleFullscreenChange);
+  },
+  methods: {
+    handleFullscreenChange() {
+      this.$emit("update:model-value", Quasar.AppFullscreen.isActive);
+    },
+    enter() {
+      Quasar.AppFullscreen.request().then(() => {
+        if (this.requireEscapeHold && navigator.keyboard && typeof navigator.keyboard.lock === "function") {
+          navigator.keyboard.lock(["Escape"]);
+        }
+      });
+    },
+    exit() {
+      Quasar.AppFullscreen.exit();
+    },
+  },
+};
diff --git a/nicegui/elements/fullscreen.py b/nicegui/elements/fullscreen.py
new file mode 100644
index 00000000..5fb11f5e
--- /dev/null
+++ b/nicegui/elements/fullscreen.py
@@ -0,0 +1,59 @@
+from typing import Optional
+
+from ..events import Handler, ValueChangeEventArguments
+from .mixins.value_element import ValueElement
+
+
+class Fullscreen(ValueElement, component='fullscreen.js'):
+    LOOPBACK = None
+
+    def __init__(self, *,
+                 require_escape_hold: bool = False,
+                 on_value_change: Optional[Handler[ValueChangeEventArguments]] = None) -> None:
+        """Fullscreen control element
+
+        This element is based on Quasar's `AppFullscreen <https://quasar.dev/quasar-plugins/app-fullscreen>`_ plugin
+        and provides a way to enter, exit and toggle the fullscreen mode.
+
+        Important notes:
+
+        * Due to security reasons, the fullscreen mode can only be entered from a previous user interaction such as a button click.
+        * The long-press escape requirement only works in some browsers like Google Chrome or Microsoft Edge.
+
+        *Added in version 2.11.0*
+
+        :param require_escape_hold: whether the user needs to long-press the escape key to exit fullscreen mode
+        :param on_value_change: callback which is invoked when the fullscreen state changes
+        """
+        super().__init__(value=False, on_value_change=on_value_change)
+        self._props['requireEscapeHold'] = require_escape_hold
+
+    @property
+    def require_escape_hold(self) -> bool:
+        """Whether the user needs to long-press of the escape key to exit fullscreen mode.
+
+        This feature is only supported in some browsers like Google Chrome or Microsoft Edge.
+        In unsupported browsers, this setting has no effect.
+        """
+        return self._props['requireEscapeHold']
+
+    @require_escape_hold.setter
+    def require_escape_hold(self, value: bool) -> None:
+        self._props['requireEscapeHold'] = value
+        self.update()
+
+    def enter(self) -> None:
+        """Enter fullscreen mode."""
+        self.value = True
+
+    def exit(self) -> None:
+        """Exit fullscreen mode."""
+        self.value = False
+
+    def toggle(self) -> None:
+        """Toggle fullscreen mode."""
+        self.value = not self.value
+
+    def _handle_value_change(self, value: bool) -> None:
+        super()._handle_value_change(value)
+        self.run_method('enter' if value else 'exit')
diff --git a/nicegui/elements/leaflet.js b/nicegui/elements/leaflet.js
index 438af927..1d554143 100644
--- a/nicegui/elements/leaflet.js
+++ b/nicegui/elements/leaflet.js
@@ -123,9 +123,6 @@ export default {
       clearInterval(connectInterval);
     }, 100);
   },
-  updated() {
-    this.map?.setView(this.center, this.zoom);
-  },
   methods: {
     add_layer(layer, id) {
       const l = L[layer.type](...layer.args);
diff --git a/nicegui/elements/leaflet.py b/nicegui/elements/leaflet.py
index 458e6d29..c779d204 100644
--- a/nicegui/elements/leaflet.py
+++ b/nicegui/elements/leaflet.py
@@ -110,7 +110,7 @@ class Leaflet(Element, component='leaflet.js', default_classes='nicegui-leaflet'
             return
         self._props['center'] = center
         if self._send_update_on_value_change:
-            self.update()
+            self.run_map_method('setView', center, self.zoom)
 
     def set_zoom(self, zoom: int) -> None:
         """Set the zoom level of the map."""
@@ -118,7 +118,7 @@ class Leaflet(Element, component='leaflet.js', default_classes='nicegui-leaflet'
             return
         self._props['zoom'] = zoom
         if self._send_update_on_value_change:
-            self.update()
+            self.run_map_method('setView', self.center, zoom)
 
     def remove_layer(self, layer: Layer) -> None:
         """Remove a layer from the map."""
diff --git a/nicegui/ui.py b/nicegui/ui.py
index 5279ab87..452db9d5 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -39,6 +39,7 @@ __all__ = [
     'element',
     'expansion',
     'footer',
+    'fullscreen',
     'grid',
     'header',
     'highchart',
@@ -154,6 +155,7 @@ from .elements.dialog import Dialog as dialog
 from .elements.echart import EChart as echart
 from .elements.editor import Editor as editor
 from .elements.expansion import Expansion as expansion
+from .elements.fullscreen import Fullscreen as fullscreen
 from .elements.grid import Grid as grid
 from .elements.highchart import highchart
 from .elements.html import Html as html
diff --git a/website/documentation/content/fullscreen_documentation.py b/website/documentation/content/fullscreen_documentation.py
new file mode 100644
index 00000000..0a7abc4a
--- /dev/null
+++ b/website/documentation/content/fullscreen_documentation.py
@@ -0,0 +1,42 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.fullscreen)
+def main_demo() -> None:
+    fullscreen = ui.fullscreen()
+
+    ui.button('Enter Fullscreen', on_click=fullscreen.enter)
+    ui.button('Exit Fullscreen', on_click=fullscreen.exit)
+    ui.button('Toggle Fullscreen', on_click=fullscreen.toggle)
+
+
+@doc.demo('Requiring long-press to exit', '''
+    You can require users to long-press the escape key to exit fullscreen mode.
+    This is useful to prevent accidental exits, for example when working on forms or editing data.
+
+    Note that this feature only works in some browsers like Google Chrome or Microsoft Edge.
+''')
+def long_press_demo():
+    fullscreen = ui.fullscreen()
+    ui.switch('Require escape hold').bind_value_to(fullscreen, 'require_escape_hold')
+    ui.button('Toggle Fullscreen', on_click=fullscreen.toggle)
+
+
+@doc.demo('Tracking fullscreen state', '''
+    You can track when the fullscreen state changes.
+
+    Note that due to security reasons, fullscreen mode can only be entered from a previous user interaction
+    such as a button click.
+''')
+def state_demo():
+    fullscreen = ui.fullscreen(
+        on_value_change=lambda e: ui.notify('Enter' if e.value else 'Exit')
+    )
+    ui.button('Toggle Fullscreen', on_click=fullscreen.toggle)
+    ui.label().bind_text_from(fullscreen, 'state',
+                              lambda state: 'Fullscreen' if state else '')
+
+
+doc.reference(ui.fullscreen)
diff --git a/website/documentation/content/overview.py b/website/documentation/content/overview.py
index ecbd75a1..c8d48774 100644
--- a/website/documentation/content/overview.py
+++ b/website/documentation/content/overview.py
@@ -291,6 +291,7 @@ def map_of_nicegui():
             - `ui.context`: get the current UI context including the `client` and `request` objects
             - [`ui.dark_mode`](/documentation/dark_mode): get and set the dark mode on a page
             - [`ui.download`](/documentation/download): download a file to the client
+            - [`ui.fullscreen`](/documentation/fullscreen): enter, exit and toggle fullscreen mode
             - [`ui.keyboard`](/documentation/keyboard): define keyboard event handlers
             - [`ui.navigate`](/documentation/navigate): let the browser navigate to another location
             - [`ui.notify`](/documentation/notification): show a notification
diff --git a/website/documentation/content/section_page_layout.py b/website/documentation/content/section_page_layout.py
index da2d01f3..aa130dca 100644
--- a/website/documentation/content/section_page_layout.py
+++ b/website/documentation/content/section_page_layout.py
@@ -8,6 +8,7 @@ from . import (
     dialog_documentation,
     doc,
     expansion_documentation,
+    fullscreen_documentation,
     grid_documentation,
     list_documentation,
     menu_documentation,
@@ -55,6 +56,7 @@ doc.intro(column_documentation)
 doc.intro(row_documentation)
 doc.intro(grid_documentation)
 doc.intro(list_documentation)
+doc.intro(fullscreen_documentation)
 
 
 @doc.demo('Clear Containers', '''


Test patch:
diff --git a/tests/test_fullscreen.py b/tests/test_fullscreen.py
new file mode 100644
index 00000000..290137b4
--- /dev/null
+++ b/tests/test_fullscreen.py
@@ -0,0 +1,68 @@
+from unittest.mock import patch
+
+import pytest
+
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+@pytest.mark.parametrize('require_escape_hold', [True, False])
+def test_fullscreen_creation(screen: Screen, require_escape_hold: bool):
+    fullscreen = ui.fullscreen(require_escape_hold=require_escape_hold)
+    assert not fullscreen.value
+    assert fullscreen.require_escape_hold == require_escape_hold
+
+    screen.open('/')
+
+
+def test_fullscreen_methods(screen: Screen):
+    values = []
+
+    fullscreen = ui.fullscreen(on_value_change=lambda e: values.append(e.value))
+
+    screen.open('/')
+
+    with patch.object(fullscreen, 'run_method') as mock_run:
+        fullscreen.enter()
+        mock_run.assert_called_once_with('enter')
+        mock_run.reset_mock()
+
+        fullscreen.exit()
+        mock_run.assert_called_once_with('exit')
+        mock_run.reset_mock()
+
+        fullscreen.toggle()
+        mock_run.assert_called_once_with('enter')
+        mock_run.reset_mock()
+
+        fullscreen.value = False
+        mock_run.assert_called_once_with('exit')
+        mock_run.reset_mock()
+
+        fullscreen.value = True
+        mock_run.assert_called_once_with('enter')
+        mock_run.reset_mock()
+
+    assert values == [True, False, True, False, True]
+
+
+def test_fullscreen_button_click(screen: Screen):
+    """Test that clicking a button to enter fullscreen creates the correct JavaScript call.
+
+    Note: We cannot test actual fullscreen behavior as it requires user interaction,
+    but we can verify the JavaScript method is called correctly.
+    """
+    values = []
+
+    fullscreen = ui.fullscreen(on_value_change=lambda e: values.append(e.value))
+    ui.button('Enter Fullscreen', on_click=fullscreen.enter)
+    ui.button('Exit Fullscreen', on_click=fullscreen.exit)
+
+    screen.open('/')
+    screen.click('Enter Fullscreen')
+    screen.wait(0.5)
+    assert values == [True]
+
+    screen.click('Exit Fullscreen')
+    screen.wait(0.5)
+    assert values == [True, False]