__init__.py
1 # SPDX-License-Identifier: MIT 2 # 3 # Copyright (c) 2021 The Anvil Extras project team members listed at 4 # https://github.com/anvilistas/anvil-extras/graphs/contributors 5 # 6 # This software is published at https://github.com/anvilistas/anvil-extras 7 8 from anvil import HtmlPanel as _HtmlPanel 9 from anvil import RichText as _RT 10 from anvil import Spacer as _Spacer 11 from anvil.js import get_dom_node as _get_dom_node 12 from anvil.js import import_from as _import_from 13 from anvil.js import window as _window 14 15 from ..utils._component_helpers import _html_injector, _spacing_property 16 from ._anvil_designer import QuillTemplate 17 18 __version__ = "3.1.0" 19 20 # <!-- Theme included stylesheets --> 21 prefix = "//cdn.quilljs.com/" 22 quill_version = "1.3.6" 23 24 _html_injector.cdn(f"{prefix}{quill_version}/quill.snow.css") 25 _html_injector.cdn(f"{prefix}{quill_version}/quill.bubble.css") 26 27 # <!-- Main Quill library --> 28 if _window.get("Quill") is None: 29 # support including Quill in the native libraries for easier module imports 30 _html_injector.cdn(f"{prefix}{quill_version}/quill.min.js") 31 _Quill = _window.Quill 32 33 34 _defaults = { 35 "auto_expand": True, 36 "content": None, 37 "enabled": True, 38 "height": 150, 39 "modules": None, 40 "placeholder": None, 41 "readonly": False, 42 "spacing_above": "small", 43 "spacing_below": "small", 44 "sanitize": True, 45 "theme": "snow", 46 "toolbar": True, 47 "visible": True, 48 } 49 50 51 def _quill_prop(propname, setter=None): 52 def prop_getter(self): 53 return self._props[propname] 54 55 def prop_setter(self, value): 56 self._props[propname] = value 57 if setter is not None: 58 setter(self, value) 59 60 return property(prop_getter, prop_setter) 61 62 63 def _quill_init_prop(propname): 64 def setter(self, _value): 65 self._init_quill() 66 67 return _quill_prop(propname, setter) 68 69 70 class Quill(QuillTemplate): 71 _quill = None # otherwise we get a recursion error from __getattr__ 72 _deltaToMarkdown = None 73 74 def __init__(self, **properties): 75 # Set Form properties and Data Bindings. 76 self._dom_node = _get_dom_node(self) 77 self._spacer = _Spacer() 78 self._el = _get_dom_node(self._spacer) 79 self._quill = None 80 self._rt = None 81 self._min_height = None 82 83 def click_guard(e): 84 # if you click the quill element below the text, you want to focus the editor 85 # but only if you're clicking the spacer itself 86 q = self._quill 87 if not q.hasFocus() and e.srcElement is self._el: 88 q.focus() 89 q.setSelection(len(q.getText())) 90 91 self._el.addEventListener("click", click_guard) 92 93 self._props = props = _defaults | properties 94 props_to_init = { 95 key: props[key] 96 for key in ( 97 "height", 98 "content", 99 "auto_expand", 100 "spacing_above", 101 "spacing_below", 102 ) 103 } 104 init_if_false = { 105 key: props[key] for key in ("enabled", "visible") if not props[key] 106 } 107 self._init_quill() 108 self.init_components(**props_to_init, **init_if_false) 109 110 @staticmethod 111 def _clear_elements(el): 112 while el.firstElementChild: 113 el.removeChild(el.firstElementChild) 114 115 def _init_quill(self): 116 html = self.get_html() 117 118 self._spacer.remove_from_parent() 119 self._clear_elements(self._dom_node) 120 self._clear_elements(self._el) 121 self.add_component(self._spacer) 122 123 # these properties have to be set for initialization and cannot be changed 124 # If they are changed at runtime we need to create a new quill object 125 q = self._quill = _Quill( 126 self._el, 127 { 128 "modules": {"toolbar": self._props["toolbar"]} 129 | (self._props["modules"] or {}), 130 "theme": self._props["theme"], 131 "placeholder": self._props["placeholder"], 132 "readOnly": self._props["readonly"], 133 "bounds": self._dom_node, 134 }, 135 ) 136 137 #### EVENTS #### 138 q.on( 139 "text-change", 140 lambda delta, old_delta, source: self.raise_event( 141 "text_change", delta=delta, old_delta=old_delta, source=source 142 ), 143 ) 144 q.on( 145 "selection-change", 146 lambda range, old_range, source: self.raise_event( 147 "selection_change", range=range, old_range=old_range, source=source 148 ), 149 ) 150 151 if html: 152 self.set_html(html, False) 153 154 def __getattr__(self, name): 155 init, *rest = name.split("_") 156 name = init + "".join(map(str.title, rest)) 157 return getattr(self._quill, name) 158 159 #### Properties #### 160 def _set_enabled(self, value): 161 self._quill.enable(bool(value)) 162 163 def _set_auto_expand(self, value): 164 self._spacer.height = "auto" if value else self._min_height 165 166 def _set_height(self, value): 167 if isinstance(value, (int, float)) or value.isdigit(): 168 value = f"{value}px" 169 self._el.style.minHeight = value 170 self._min_height = value 171 172 @property 173 def content(self): 174 # NB: since each quill object is only one level deep we can just call __serialize__ 175 # We could just return the proxyobjects but probably nicer to turn them into python dicts here 176 return list(map(lambda x: x.__serialize__({}), self._quill.getContents().ops)) 177 178 @content.setter 179 def content(self, value): 180 if isinstance(value, str): 181 return self._quill.setText(value) 182 self._quill.setContents(value) 183 184 enabled = _quill_prop("enabled", _set_enabled) 185 auto_expand = _quill_prop("auto_expand", _set_auto_expand) 186 height = _quill_prop("height", _set_height) 187 sanitize = _quill_prop("sanitize") 188 spacing_above = _spacing_property("above") 189 spacing_below = _spacing_property("below") 190 visible = _HtmlPanel.visible 191 tag = _HtmlPanel.tag 192 193 #### QUILL INIT PROPS #### 194 toolbar = _quill_init_prop("toolbar") 195 readonly = _quill_init_prop("readonly") 196 theme = _quill_init_prop("theme") 197 placeholder = _quill_init_prop("placeholder") 198 modules = _quill_init_prop("modules") 199 200 #### ANVIL METHODS #### 201 def get_markdown(self): 202 if Quill._deltaToMarkdown is None: 203 mod = _import_from("https://esm.sh/quill-delta-to-markdown@0.6.0") 204 Quill._deltaToMarkdown = mod.deltaToMarkdown 205 contents = self.get_contents() 206 return Quill._deltaToMarkdown(contents.ops) 207 208 def get_html(self): 209 """convert the contents of the quill object to html which can be used 210 as the content to a RichText editor in 'restricted_html' format 211 Can also be used as a classmethod by calling it with a simple object Quill.to_html(content) 212 """ 213 return self._quill and self._quill.root.innerHTML 214 215 def set_html(self, html, sanitize=None): 216 """set the content to html. This method sanitizes the html 217 in the same way the RichText compeonent does""" 218 if sanitize is None: 219 sanitize = self._props["sanitize"] 220 if sanitize: 221 self._rt = self._rt or _RT(visible=False, format="restricted_html") 222 self._rt.content = html 223 html = _get_dom_node(self._rt).innerHTML 224 self._quill.root.innerHTML = html