diff --git a/reflex/.templates/web/utils/helpers/paste.js b/reflex/.templates/web/utils/helpers/paste.js new file mode 100644 index 00000000000..f30fe9499ed --- /dev/null +++ b/reflex/.templates/web/utils/helpers/paste.js @@ -0,0 +1,59 @@ +import { useEffect } from "react"; + +const handle_paste_data = (clipboardData) => + new Promise((resolve, reject) => { + const pasted_data = []; + const n_items = clipboardData.items.length; + const extract_data = (item) => { + const type = item.type; + if (item.kind === "string") { + item.getAsString((data) => { + pasted_data.push([type, data]); + if (pasted_data.length === n_items) { + resolve(pasted_data); + } + }); + } else if (item.kind === "file") { + const file = item.getAsFile(); + const reader = new FileReader(); + reader.onload = (e) => { + pasted_data.push([type, e.target.result]); + if (pasted_data.length === n_items) { + resolve(pasted_data); + } + }; + if (type.indexOf("text/") === 0) { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + } + }; + for (const item of clipboardData.items) { + extract_data(item); + } + }); + +export default function usePasteHandler(target_ids, event_actions, on_paste) { + return useEffect(() => { + const handle_paste = (_ev) => { + event_actions.preventDefault && _ev.preventDefault(); + event_actions.stopPropagation && _ev.stopPropagation(); + handle_paste_data(_ev.clipboardData).then(on_paste); + }; + const targets = target_ids + .map((id) => document.getElementById(id)) + .filter((element) => !!element); + if (target_ids.length === 0) { + targets.push(document); + } + targets.forEach((target) => + target.addEventListener("paste", handle_paste, false), + ); + return () => { + targets.forEach((target) => + target.removeEventListener("paste", handle_paste, false), + ); + }; + }); +} diff --git a/reflex/__init__.py b/reflex/__init__.py index a71a6cc46cb..3cf8bfe8fe6 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -212,6 +212,7 @@ "components.core.debounce": ["debounce_input"], "components.core.html": ["html"], "components.core.match": ["match"], + "components.core.clipboard": ["clipboard"], "components.core.colors": ["color"], "components.core.responsive": [ "desktop_only", diff --git a/reflex/__init__.pyi b/reflex/__init__.pyi index 5233e8b501e..77ac16456fa 100644 --- a/reflex/__init__.pyi +++ b/reflex/__init__.pyi @@ -129,6 +129,7 @@ from .components.core.foreach import foreach as foreach from .components.core.debounce import debounce_input as debounce_input from .components.core.html import html as html from .components.core.match import match as match +from .components.core.clipboard import clipboard as clipboard from .components.core.colors import color as color from .components.core.responsive import desktop_only as desktop_only from .components.core.responsive import mobile_and_tablet as mobile_and_tablet diff --git a/reflex/components/core/__init__.py b/reflex/components/core/__init__.py index 6c866269ec6..30297574064 100644 --- a/reflex/components/core/__init__.py +++ b/reflex/components/core/__init__.py @@ -16,6 +16,7 @@ "connection_toaster", "connection_pulser", ], + "clipboard": ["Clipboard", "clipboard"], "colors": [ "color", ], diff --git a/reflex/components/core/__init__.pyi b/reflex/components/core/__init__.pyi index 85e0dd32877..0d11eade48a 100644 --- a/reflex/components/core/__init__.pyi +++ b/reflex/components/core/__init__.pyi @@ -13,6 +13,8 @@ from .banner import connection_banner as connection_banner from .banner import connection_modal as connection_modal from .banner import connection_toaster as connection_toaster from .banner import connection_pulser as connection_pulser +from .clipboard import Clipboard as Clipboard +from .clipboard import clipboard as clipboard from .colors import color as color from .cond import Cond as Cond from .cond import color_mode_cond as color_mode_cond diff --git a/reflex/components/core/clipboard.py b/reflex/components/core/clipboard.py new file mode 100644 index 00000000000..3ffc4e77a24 --- /dev/null +++ b/reflex/components/core/clipboard.py @@ -0,0 +1,94 @@ +"""Global on_paste handling for Reflex app.""" +from __future__ import annotations + +from typing import Dict, List, Union + +from reflex.components.base.fragment import Fragment +from reflex.components.tags.tag import Tag +from reflex.event import EventChain, EventHandler +from reflex.utils.format import format_prop, wrap +from reflex.utils.imports import ImportVar +from reflex.vars import Var, get_unique_variable_name + + +class Clipboard(Fragment): + """Clipboard component.""" + + # The element ids to attach the event listener to. Defaults to all child components or the document. + targets: Var[List[str]] + + # Called when the user pastes data into the document. Data is a list of tuples of (mime_type, data). Binary types will be base64 encoded as a data uri. + on_paste: EventHandler[lambda data: [data]] + + # Save the original event actions for the on_paste event. + on_paste_event_actions: Var[Dict[str, Union[bool, int]]] + + @classmethod + def create(cls, *children, **props): + """Create a Clipboard component. + + Args: + *children: The children of the component. + **props: The properties of the component. + + Returns: + The Clipboard Component. + """ + if "targets" not in props: + # Add all children as targets if not specified. + targets = props.setdefault("targets", []) + for c in children: + if c.id is None: + c.id = f"clipboard_{get_unique_variable_name()}" + targets.append(c.id) + + if "on_paste" in props: + # Capture the event actions for the on_paste handler if not specified. + props.setdefault("on_paste_event_actions", props["on_paste"].event_actions) + + return super().create(*children, **props) + + def _exclude_props(self) -> list[str]: + return super()._exclude_props() + ["on_paste", "on_paste_event_actions"] + + def _render(self) -> Tag: + tag = super()._render() + tag.remove_props("targets") + # Ensure a different Fragment component is created whenever targets differ + tag.add_props(key=self.targets) + return tag + + def add_imports(self) -> dict[str, ImportVar]: + """Add the imports for the Clipboard component. + + Returns: + The import dict for the component. + """ + return { + "/utils/helpers/paste.js": ImportVar( + tag="usePasteHandler", is_default=True + ), + } + + def add_hooks(self) -> list[str]: + """Add hook to register paste event listener. + + Returns: + The hooks to add to the component. + """ + on_paste = self.event_triggers["on_paste"] + if on_paste is None: + return [] + if isinstance(on_paste, EventChain): + on_paste = wrap(str(format_prop(on_paste)).strip("{}"), "(") + return [ + "usePasteHandler(%s, %s, %s)" + % ( + self.targets._var_name_unwrapped, + self.on_paste_event_actions._var_name_unwrapped, + on_paste, + ) + ] + + +clipboard = Clipboard.create diff --git a/reflex/components/core/clipboard.pyi b/reflex/components/core/clipboard.pyi new file mode 100644 index 00000000000..6cc7163e20a --- /dev/null +++ b/reflex/components/core/clipboard.pyi @@ -0,0 +1,105 @@ +"""Stub file for reflex/components/core/clipboard.py""" +# ------------------- DO NOT EDIT ---------------------- +# This file was generated by `reflex/utils/pyi_generator.py`! +# ------------------------------------------------------ + +from typing import Any, Dict, Literal, Optional, Union, overload +from reflex.vars import Var, BaseVar, ComputedVar +from reflex.event import EventChain, EventHandler, EventSpec +from reflex.style import Style +from typing import Dict, List, Union +from reflex.components.base.fragment import Fragment +from reflex.components.tags.tag import Tag +from reflex.event import EventChain, EventHandler +from reflex.utils.format import format_prop, wrap +from reflex.utils.imports import ImportVar +from reflex.vars import Var, get_unique_variable_name + +class Clipboard(Fragment): + @overload + @classmethod + def create( # type: ignore + cls, + *children, + targets: Optional[Union[Var[List[str]], List[str]]] = None, + on_paste_event_actions: Optional[ + Union[Var[Dict[str, Union[bool, int]]], Dict[str, Union[bool, int]]] + ] = None, + style: Optional[Style] = None, + key: Optional[Any] = None, + id: Optional[Any] = None, + class_name: Optional[Any] = None, + autofocus: Optional[bool] = None, + custom_attrs: Optional[Dict[str, Union[Var, str]]] = None, + on_blur: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_click: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_context_menu: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_double_click: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_focus: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mount: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_down: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_enter: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_leave: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_move: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_out: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_over: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_up: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_paste: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_scroll: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_unmount: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + **props + ) -> "Clipboard": + """Create a Clipboard component. + + Args: + *children: The children of the component. + targets: The element ids to attach the event listener to. Defaults to all child components or the document. + on_paste_event_actions: Save the original event actions for the on_paste event. + style: The style of the component. + key: A unique key for the component. + id: The id for the component. + class_name: The class name for the component. + autofocus: Whether the component should take the focus once the page is loaded + custom_attrs: custom attribute + **props: The properties of the component. + + Returns: + The Clipboard Component. + """ + ... + def add_imports(self) -> dict[str, ImportVar]: ... + def add_hooks(self) -> list[str]: ... + +clipboard = Clipboard.create diff --git a/reflex/utils/pyi_generator.py b/reflex/utils/pyi_generator.py index 30f885f3333..c9414def042 100644 --- a/reflex/utils/pyi_generator.py +++ b/reflex/utils/pyi_generator.py @@ -960,7 +960,6 @@ def scan_all(self, targets, changed_files: list[Path] | None = None): target_path.is_file() and target_path.suffix == ".py" and target_path.name not in EXCLUDED_FILES - and "reflex/components" in str(target_path) ): file_targets.append(target_path) continue