From 994e7a9a6c581c6ef00d34dcf6a92bcfca96e07f Mon Sep 17 00:00:00 2001 From: jh Date: Sat, 20 May 2023 01:44:55 +0200 Subject: [PATCH 1/6] feat: Add Editor component This adds a Rich Text Editor --- reflex/components/forms/editor.py | 206 ++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 reflex/components/forms/editor.py diff --git a/reflex/components/forms/editor.py b/reflex/components/forms/editor.py new file mode 100644 index 0000000000..47d41d54a2 --- /dev/null +++ b/reflex/components/forms/editor.py @@ -0,0 +1,206 @@ +"""A Rich Text Editor based on SunEditor.""" + +import enum +from typing import Dict, List, Optional + +from reflex.base import Base +from reflex.components.component import Component +from reflex.event import EVENT_ARG +from reflex.vars import Var + + +class EditorButtonList(list, enum.Enum): + """List enum that provides three predefined button lists.""" + + BASIC = [ + ["font", "fontSize"], + ["fontColor"], + ["horizontalRule"], + ["link", "image"], + ] + FORMATTING = [ + ["undo", "redo"], + ["bold", "underline", "italic", "strike", "subscript", "superscript"], + ["removeFormat"], + ["outdent", "indent"], + ["fullScreen", "showBlocks", "codeView"], + ["preview", "print"], + ] + COMPLEX = [ + ["undo", "redo"], + ["font", "fontSize", "formatBlock"], + ["bold", "underline", "italic", "strike", "subscript", "superscript"], + ["removeFormat"], + "/", + ["fontColor", "hiliteColor"], + ["outdent", "indent"], + ["align", "horizontalRule", "list", "table"], + ["link", "image", "video"], + ["fullScreen", "showBlocks", "codeView"], + ["preview", "print"], + ["save", "template"], + ] + + +class EditorOptions(Base): + """Some of the additional options to configure the Editor. + Complete list of options found here: + https://github.com/JiHong88/SunEditor/blob/master/README.md#options. + """ + + # Specifies default tag name of the editor. + # default: 'p' {String} + default_tag: Optional[str] = None + + # The mode of the editor ('classic', 'inline', 'balloon', 'balloon-always'). + # default: 'classic' {String} + mode: Optional[str] = None + + # If true, the editor is set to RTL(Right To Left) mode. + # default: false {Boolean} + rtl: Optional[bool] = None + + # List of buttons to use in the toolbar. + button_list: Optional[List[List[str] | str]] + + +class Editor(Component): + """A Rich Text Editor component based on SunEditor. + Not every JS prop is listed here (some are not easily usable from python), + refer to the library docs for a complete list. + """ + + library = "suneditor-react" + + tag = "SunEditor" + + # Language of the editor. + # Alternatively to a string, a dict of your language can be passed to this prop. + # Please refer to the library docs for this. + # options: "en" | "da" | "de" | "es" | "fr" | "ja" | "ko" | "pt_br" | + # "ru" | "zh_cn" | "ro" | "pl" | "ckb" | "lv" | "se" | "ua" | "he" | "it" + # default : "en" + lang: Var[str | dict] + + # This is used to set the HTML form name of the editor. + # This means on HTML form submission, + # it will be submitted together with contents of the editor by the name provided. + name: Var[str] + + # Sets the default value of the editor. + # This is useful if you don't want the on_change method to be called on render. + # If you want the on_change method to be called on render please use the set_contents prop + default_value: Var[str] + + # Sets the width of the editor. + # px and percentage values are accepted, eg width="100%" or width="500px" + # default: 100% + width: Var[str] + + # Sets the height of the editor. + # px and percentage values are accepted, eg height="100%" or height="100px" + height: Var[str] + + # Sets the placeholder of the editor. + placeholder: Var[str] + + # Should the editor receive focus when initialized? + auto_focus: Var[bool] + + # Pass an EditorOptions instance to modify the behaviour of Editor even more. + set_options: Var[Dict] + + # Whether all SunEditor plugins should be loaded. + # default: True + set_all_plugins: Var[bool] + + # Set the content of the editor. + # Note: To set the initial contents of the editor + # without calling the on_change event, + # please use the default_value prop. + # set_contents is used to set the contents of the editor programmatically. + # You must be aware that, when the set_contents's prop changes, + # the on_change event is triggered. + set_contents: Var[str] + + # Append editor content + append_contents: Var[str] + + # Sets the default style of the editor's edit area + set_default_style: Var[str] + + # Disable the editor + # default: False + disable: Var[bool] + + # Hide the editor + # default: False + hide: Var[bool] + + # Hide the editor toolbar + # default: False + hide_toolbar: Var[bool] + + # Disable the editor toolbar + # default: False + disable_toolbar: Var[bool] + + def _get_imports(self): + return {} + + def _get_custom_code(self) -> str: + return """import dynamic from 'next/dynamic'; +import 'suneditor/dist/css/suneditor.min.css'; +const SunEditor = dynamic(() => import('suneditor-react'), { ssr: false });""" + + def get_controlled_triggers(self): + """Get the event triggers that pass the component's value to the handler. + + Returns: + A dict mapping the event trigger to the var that is passed to the handler. + """ + return { + "on_change": EVENT_ARG.target.value, + "on_scroll": None, + "on_click": None, + "on_mouse_down": None, + "on_input": None, + "on_key_up": None, + "on_key_down": None, + "on_focus": None, + "on_blur": None, + "on_drop": None, + "on_image_upload_before": None, + "on_image_upload": None, + "on_image_upload_error": None, + "on_video_upload_before": None, + "on_video_upload": None, + "on_video_upload_error": None, + "on_audio_upload_before": None, + "on_audio_upload": None, + "on_audio_upload_error": None, + "on_resize_editor": None, + "on_copy": None, + "on_cut": None, + "on_paste": None, + "image_upload_handler": None, + "toggle_code_view": None, + "toggle_full_screen": None, + "show_inline": None, + "show_controller": None, + } + + @classmethod + def create(cls, set_options: Optional[EditorOptions] = None, **props) -> Component: + """Create an instance of Editor. No children allowed. + + Args: + set_options(Optional[EditorOptions]): Configuration object to further configure the instance. + **props: Any properties to be passed to the Editor + + Returns: + An Editor instance. + """ + if set_options is not None: + props["set_options"] = set_options.dict() + return super().create(*[], **props) From c6a55d699a04e623826123cf6209d64002738afa Mon Sep 17 00:00:00 2001 From: jh Date: Sat, 20 May 2023 01:45:23 +0200 Subject: [PATCH 2/6] chore: Add Editor and related classes to __init__.py files --- reflex/components/forms/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/reflex/components/forms/__init__.py b/reflex/components/forms/__init__.py index 1982319920..d5ff4a164c 100644 --- a/reflex/components/forms/__init__.py +++ b/reflex/components/forms/__init__.py @@ -12,6 +12,7 @@ from .date_time_picker import DateTimePicker from .debounce import DebounceInput from .editable import Editable, EditableInput, EditablePreview, EditableTextarea +from .editor import Editor, EditorButtonList, EditorOptions from .email import Email from .form import Form, FormControl, FormErrorMessage, FormHelperText, FormLabel from .iconbutton import IconButton From 83678737355a6ef621af3ff4bc12cab094b9c9b5 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 21 Sep 2023 14:36:33 -0700 Subject: [PATCH 3/6] utils/types: better TypeError from invalid issubclass check --- reflex/utils/types.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/reflex/utils/types.py b/reflex/utils/types.py index a0c9a1f8c0..136839ee46 100644 --- a/reflex/utils/types.py +++ b/reflex/utils/types.py @@ -100,6 +100,9 @@ def _issubclass(cls: GenericType, cls_check: GenericType) -> bool: Returns: Whether the class is a subclass of the other class. + + Raises: + TypeError: If the base class is not valid for issubclass. """ # Special check for Any. if cls_check == Any: @@ -116,7 +119,12 @@ def _issubclass(cls: GenericType, cls_check: GenericType) -> bool: return False # Check if the types match. - return cls_check_base == Any or issubclass(cls_base, cls_check_base) + try: + return cls_check_base == Any or issubclass(cls_base, cls_check_base) + except TypeError as te: + # These errors typically arise from bad annotations and are hard to + # debug without knowing the type that we tried to compare. + raise TypeError(f"Invalid type for issubclass: {cls_base}") from te def _isinstance(obj: Any, cls: GenericType) -> bool: From 90dd6011f49ba2111ed4f79935e3fb9caf94c8b8 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 21 Sep 2023 14:37:27 -0700 Subject: [PATCH 4/6] Editor: update for reflex 0.2.8 Use new get_event_triggers API Use NoSSRComponent --- reflex/components/__init__.py | 1 + reflex/components/forms/editor.py | 72 +++++++++++++------------------ 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/reflex/components/__init__.py b/reflex/components/__init__.py index 075700d69c..f9a580032e 100644 --- a/reflex/components/__init__.py +++ b/reflex/components/__init__.py @@ -100,6 +100,7 @@ editable_input = EditableInput.create editable_preview = EditablePreview.create editable_textarea = EditableTextarea.create +editor = Editor.create form = Form.create form_control = FormControl.create form_error_message = FormErrorMessage.create diff --git a/reflex/components/forms/editor.py b/reflex/components/forms/editor.py index 47d41d54a2..5b177c0509 100644 --- a/reflex/components/forms/editor.py +++ b/reflex/components/forms/editor.py @@ -1,12 +1,13 @@ """A Rich Text Editor based on SunEditor.""" +from __future__ import annotations import enum -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from reflex.base import Base -from reflex.components.component import Component -from reflex.event import EVENT_ARG -from reflex.vars import Var +from reflex.components.component import Component, NoSSRComponent +from reflex.constants import EventTriggers +from reflex.vars import ImportVar, Var class EditorButtonList(list, enum.Enum): @@ -64,7 +65,7 @@ class EditorOptions(Base): button_list: Optional[List[List[str] | str]] -class Editor(Component): +class Editor(NoSSRComponent): """A Rich Text Editor component based on SunEditor. Not every JS prop is listed here (some are not easily usable from python), refer to the library docs for a complete list. @@ -74,6 +75,10 @@ class Editor(Component): tag = "SunEditor" + is_default = True + + lib_dependencies: list[str] = ["suneditor"] + # Language of the editor. # Alternatively to a string, a dict of your language can be passed to this prop. # Please refer to the library docs for this. @@ -108,7 +113,7 @@ class Editor(Component): auto_focus: Var[bool] # Pass an EditorOptions instance to modify the behaviour of Editor even more. - set_options: Var[Dict] + set_options: Var[EditorOptions] # Whether all SunEditor plugins should be loaded. # default: True @@ -146,48 +151,33 @@ class Editor(Component): disable_toolbar: Var[bool] def _get_imports(self): - return {} - - def _get_custom_code(self) -> str: - return """import dynamic from 'next/dynamic'; -import 'suneditor/dist/css/suneditor.min.css'; -const SunEditor = dynamic(() => import('suneditor-react'), { ssr: false });""" + imports = super()._get_imports() + imports[""] = { + ImportVar(tag="suneditor/dist/css/suneditor.min.css", install=False) + } + return imports - def get_controlled_triggers(self): + def get_event_triggers(self) -> Dict[str, Any]: """Get the event triggers that pass the component's value to the handler. Returns: A dict mapping the event trigger to the var that is passed to the handler. """ return { - "on_change": EVENT_ARG.target.value, - "on_scroll": None, - "on_click": None, - "on_mouse_down": None, - "on_input": None, - "on_key_up": None, - "on_key_down": None, - "on_focus": None, - "on_blur": None, - "on_drop": None, - "on_image_upload_before": None, - "on_image_upload": None, - "on_image_upload_error": None, - "on_video_upload_before": None, - "on_video_upload": None, - "on_video_upload_error": None, - "on_audio_upload_before": None, - "on_audio_upload": None, - "on_audio_upload_error": None, - "on_resize_editor": None, - "on_copy": None, - "on_cut": None, - "on_paste": None, - "image_upload_handler": None, - "toggle_code_view": None, - "toggle_full_screen": None, - "show_inline": None, - "show_controller": None, + **super().get_event_triggers(), + EventTriggers.ON_CHANGE: lambda content: [content], + "on_input": lambda _e: [_e], + EventTriggers.ON_BLUR: lambda _e, content: [content], + "on_load": lambda reload: [reload], + "on_resize_editor": lambda height, prev_height: [height, prev_height], + "on_copy": lambda _e, clipboard_data: [clipboard_data], + "on_cut": lambda _e, clipboard_data: [clipboard_data], + "on_paste": lambda _e, clean_data, max_char_count: [ + clean_data, + max_char_count, + ], + "toggle_code_view": lambda is_code_view: [is_code_view], + "toggle_full_screen": lambda is_full_screen: [is_full_screen], } @classmethod From 5affd5e4e5c952aa03766cb2418c42d200f69d1f Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 21 Sep 2023 14:52:53 -0700 Subject: [PATCH 5/6] editor: fix passing button_list (must be camelCase) --- reflex/components/forms/editor.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/reflex/components/forms/editor.py b/reflex/components/forms/editor.py index 5b177c0509..51fd84d8c9 100644 --- a/reflex/components/forms/editor.py +++ b/reflex/components/forms/editor.py @@ -7,6 +7,7 @@ from reflex.base import Base from reflex.components.component import Component, NoSSRComponent from reflex.constants import EventTriggers +from reflex.utils.format import to_camel_case from reflex.vars import ImportVar, Var @@ -113,7 +114,7 @@ class Editor(NoSSRComponent): auto_focus: Var[bool] # Pass an EditorOptions instance to modify the behaviour of Editor even more. - set_options: Var[EditorOptions] + set_options: Var[Dict] # Whether all SunEditor plugins should be loaded. # default: True @@ -190,7 +191,16 @@ def create(cls, set_options: Optional[EditorOptions] = None, **props) -> Compone Returns: An Editor instance. + + Raises: + ValueError: If set_options is a state Var. """ if set_options is not None: - props["set_options"] = set_options.dict() + if isinstance(set_options, Var): + raise ValueError("EditorOptions cannot be a state Var") + props["set_options"] = { + to_camel_case(k): v + for k, v in set_options.dict().items() + if v is not None + } return super().create(*[], **props) From cab28f237d310b06d0cfad20b54b0edee4677c58 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 21 Sep 2023 15:15:57 -0700 Subject: [PATCH 6/6] Add pyi file for editor, and fix types for py38 pydantic --- reflex/components/forms/editor.py | 8 ++++---- reflex/components/forms/editor.pyi | 33 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 reflex/components/forms/editor.pyi diff --git a/reflex/components/forms/editor.py b/reflex/components/forms/editor.py index 51fd84d8c9..9e3276f231 100644 --- a/reflex/components/forms/editor.py +++ b/reflex/components/forms/editor.py @@ -2,7 +2,7 @@ from __future__ import annotations import enum -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from reflex.base import Base from reflex.components.component import Component, NoSSRComponent @@ -63,7 +63,7 @@ class EditorOptions(Base): rtl: Optional[bool] = None # List of buttons to use in the toolbar. - button_list: Optional[List[List[str] | str]] + button_list: Optional[List[Union[List[str], str]]] class Editor(NoSSRComponent): @@ -78,7 +78,7 @@ class Editor(NoSSRComponent): is_default = True - lib_dependencies: list[str] = ["suneditor"] + lib_dependencies: List[str] = ["suneditor"] # Language of the editor. # Alternatively to a string, a dict of your language can be passed to this prop. @@ -86,7 +86,7 @@ class Editor(NoSSRComponent): # options: "en" | "da" | "de" | "es" | "fr" | "ja" | "ko" | "pt_br" | # "ru" | "zh_cn" | "ro" | "pl" | "ckb" | "lv" | "se" | "ua" | "he" | "it" # default : "en" - lang: Var[str | dict] + lang: Var[Union[str, dict]] # This is used to set the HTML form name of the editor. # This means on HTML form submission, diff --git a/reflex/components/forms/editor.pyi b/reflex/components/forms/editor.pyi new file mode 100644 index 0000000000..240ca20b85 --- /dev/null +++ b/reflex/components/forms/editor.pyi @@ -0,0 +1,33 @@ +"""Stub file for editor.py""" +# ------------------- DO NOT EDIT ---------------------- +# This file was generated by `scripts/pyi_generator.py`! +# ------------------------------------------------------ + +import enum +from typing import Any, Dict, List, Optional, Union, overload +from reflex.base import Base +from reflex.components.component import Component +from reflex.components.component import NoSSRComponent +from reflex.vars import Var, BaseVar, ComputedVar +from reflex.event import EventHandler, EventChain, EventSpec + +class EditorButtonList(list, enum.Enum): ... +class EditorOptions(Base): ... + +class Editor(NoSSRComponent): + @overload + @classmethod + def create(cls, *children, lib_dependencies: Optional[List[str]] = None, lang: Optional[Union[Var[Union[str, dict]], Union[str, dict]]] = None, name: Optional[Union[Var[str], str]] = None, default_value: Optional[Union[Var[str], str]] = None, width: Optional[Union[Var[str], str]] = None, height: Optional[Union[Var[str], str]] = None, placeholder: Optional[Union[Var[str], str]] = None, auto_focus: Optional[Union[Var[bool], bool]] = None, set_options: Optional[Union[Var[Dict], Dict]] = None, set_all_plugins: Optional[Union[Var[bool], bool]] = None, set_contents: Optional[Union[Var[str], str]] = None, append_contents: Optional[Union[Var[str], str]] = None, set_default_style: Optional[Union[Var[str], str]] = None, disable: Optional[Union[Var[bool], bool]] = None, hide: Optional[Union[Var[bool], bool]] = None, hide_toolbar: Optional[Union[Var[bool], bool]] = None, disable_toolbar: Optional[Union[Var[bool], bool]] = None, on_blur: Optional[Union[EventHandler, EventSpec, List, function, BaseVar]] = None, on_change: 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_copy: Optional[Union[EventHandler, EventSpec, List, function, BaseVar]] = None, on_cut: 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_input: Optional[Union[EventHandler, EventSpec, List, function, BaseVar]] = None, on_load: 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_resize_editor: 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, toggle_code_view: Optional[Union[EventHandler, EventSpec, List, function, BaseVar]] = None, toggle_full_screen: Optional[Union[EventHandler, EventSpec, List, function, BaseVar]] = None, **props) -> "Editor": # type: ignore + """Create an instance of Editor. No children allowed. + + Args: + set_options(Optional[EditorOptions]): Configuration object to further configure the instance. + **props: Any properties to be passed to the Editor + + Returns: + An Editor instance. + + Raises: + ValueError: If set_options is a state Var. + """ + ...