/ client_code / Quill / __init__.py
__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