|
1 | | -"""TO-DO: Write a description of what this XBlock is.""" |
2 | | - |
| 1 | +""" |
| 2 | +AnnotatableXBlock allows instructors to add interactive annotations to course content. |
| 3 | +Annotations can have configurable attributes such as title, body, problem index, and highlight color. |
| 4 | +This block enhances the learning experience by enabling students to view embedded comments, questions, or explanations. |
| 5 | +The block supports internationalization (i18n) for multilingual courses. |
| 6 | +""" |
| 7 | + |
| 8 | +import logging |
| 9 | +import textwrap |
| 10 | +import uuid |
3 | 11 | from importlib.resources import files |
4 | 12 |
|
5 | | -from django.utils import translation |
| 13 | +import markupsafe |
| 14 | +from django.utils.translation import gettext_noop as _ |
| 15 | +from lxml import etree |
6 | 16 | from web_fragments.fragment import Fragment |
7 | 17 | from xblock.core import XBlock |
8 | | -from xblock.fields import Integer, Scope |
| 18 | +from xblock.fields import Scope, String, XMLString |
9 | 19 | from xblock.utils.resources import ResourceLoader |
10 | 20 |
|
| 21 | +log = logging.getLogger(__name__) |
| 22 | + |
11 | 23 | resource_loader = ResourceLoader(__name__) |
12 | 24 |
|
13 | 25 |
|
14 | | -# This Xblock is just to test the strucutre of xblocks-contrib |
15 | 26 | @XBlock.needs("i18n") |
16 | 27 | class AnnotatableBlock(XBlock): |
17 | 28 | """ |
18 | | - TO-DO: document what your XBlock does. |
| 29 | + AnnotatableXBlock allows instructors to create annotated content that students can view interactively. |
| 30 | + Annotations can be styled and customized, with internationalization support for multilingual environments. |
19 | 31 | """ |
20 | 32 |
|
21 | | - # Fields are defined on the class. You can access them in your code as |
22 | | - # self.<fieldname>. |
| 33 | + display_name = String( |
| 34 | + display_name=_("Display Name"), |
| 35 | + help=_("The display name for this component."), |
| 36 | + scope=Scope.settings, |
| 37 | + default=_("Annotation"), |
| 38 | + ) |
23 | 39 |
|
24 | | - # TO-DO: delete count, and define your own fields. |
25 | | - count = Integer( |
26 | | - default=0, |
27 | | - scope=Scope.user_state, |
28 | | - help="A simple counter, to show something happening", |
| 40 | + data = XMLString( |
| 41 | + help=_("XML data for the annotation"), |
| 42 | + scope=Scope.content, |
| 43 | + default=textwrap.dedent( |
| 44 | + markupsafe.Markup( |
| 45 | + """ |
| 46 | + <annotatable> |
| 47 | + <instructions> |
| 48 | + <p>Enter your (optional) instructions for the exercise in HTML format.</p> |
| 49 | + <p>Annotations are specified by an <code>{}annotation{}</code> tag which may |
| 50 | + may have the following attributes:</p> |
| 51 | + <ul class="instructions-template"> |
| 52 | + <li><code>title</code> (optional). Title of the annotation. Defaults to |
| 53 | + <i>Commentary</i> if omitted.</li> |
| 54 | + <li><code>body</code> (<b>required</b>). Text of the annotation.</li> |
| 55 | + <li><code>problem</code> (optional). Numeric index of the problem |
| 56 | + associated with this annotation. This is a zero-based index, so the first |
| 57 | + problem on the page would have <code>problem="0"</code>.</li> |
| 58 | + <li><code>highlight</code> (optional). Possible values: yellow, red, |
| 59 | + orange, green, blue, or purple. Defaults to yellow if this attribute is |
| 60 | + omitted.</li> |
| 61 | + </ul> |
| 62 | + </instructions> |
| 63 | + <p>Add your HTML with annotation spans here.</p> |
| 64 | + <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. |
| 65 | + <annotation title="My title" body="My comment" highlight="yellow" problem="0"> |
| 66 | + Ut sodales laoreet est, egestas gravida felis egestas nec.</annotation> Aenean |
| 67 | + at volutpat erat. Cras commodo viverra nibh in aliquam.</p> |
| 68 | + <p>Nulla facilisi. <annotation body="Basic annotation example." problem="1"> |
| 69 | + Pellentesque id vestibulum libero.</annotation> Suspendisse potenti. Morbi |
| 70 | + scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam |
| 71 | + volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci |
| 72 | + in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. |
| 73 | + Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus |
| 74 | + elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus |
| 75 | + eros.</p> |
| 76 | + </annotatable> |
| 77 | + """ |
| 78 | + ).format(markupsafe.escape("<"), markupsafe.escape(">")) |
| 79 | + ), |
29 | 80 | ) |
30 | 81 |
|
31 | | - # Indicates that this XBlock has been extracted from edx-platform. |
32 | | - is_extracted = True |
| 82 | + # List of supported highlight colors for annotations |
| 83 | + HIGHLIGHT_COLORS = ["yellow", "orange", "purple", "blue", "green"] |
33 | 84 |
|
34 | | - def resource_string(self, path): |
35 | | - """Handy helper for getting resources from our kit.""" |
36 | | - return files(__package__).joinpath(path).read_text(encoding="utf-8") |
37 | | - |
38 | | - # TO-DO: change this view to display your data your own way. |
39 | | - def student_view(self, context=None): |
| 85 | + def _get_annotation_class_attr(self, index, el): # pylint: disable=unused-argument |
| 86 | + """Returns a dict with the CSS class attribute to set on the annotation |
| 87 | + and an XML key to delete from the element. |
40 | 88 | """ |
41 | | - Create primary view of the AnnotatableBlock, shown to students when viewing courses. |
| 89 | + |
| 90 | + attr = {} |
| 91 | + cls = ["annotatable-span", "highlight"] |
| 92 | + highlight_key = "highlight" |
| 93 | + color = el.get(highlight_key) |
| 94 | + |
| 95 | + if color is not None: |
| 96 | + if color in self.HIGHLIGHT_COLORS: |
| 97 | + cls.append("highlight-" + color) |
| 98 | + attr["_delete"] = highlight_key |
| 99 | + attr["value"] = " ".join(cls) |
| 100 | + |
| 101 | + return {"class": attr} |
| 102 | + |
| 103 | + def _get_annotation_data_attr(self, index, el): # pylint: disable=unused-argument |
| 104 | + """Returns a dict in which the keys are the HTML data attributes |
| 105 | + to set on the annotation element. Each data attribute has a |
| 106 | + corresponding 'value' and (optional) '_delete' key to specify |
| 107 | + an XML attribute to delete. |
42 | 108 | """ |
43 | | - if context: |
44 | | - pass # TO-DO: do something based on the context. |
45 | 109 |
|
| 110 | + data_attrs = {} |
| 111 | + attrs_map = { |
| 112 | + "body": "data-comment-body", |
| 113 | + "title": "data-comment-title", |
| 114 | + "problem": "data-problem-id", |
| 115 | + } |
| 116 | + |
| 117 | + for xml_key, html_key in attrs_map.items(): |
| 118 | + if xml_key in el.attrib: |
| 119 | + value = el.get(xml_key, "") |
| 120 | + data_attrs[html_key] = {"value": value, "_delete": xml_key} |
| 121 | + |
| 122 | + return data_attrs |
| 123 | + |
| 124 | + def _render_annotation(self, index, el): |
| 125 | + """Renders an annotation element for HTML output.""" |
| 126 | + attr = {} |
| 127 | + attr.update(self._get_annotation_class_attr(index, el)) |
| 128 | + attr.update(self._get_annotation_data_attr(index, el)) |
| 129 | + |
| 130 | + el.tag = "span" |
| 131 | + |
| 132 | + for key, value in attr.items(): |
| 133 | + el.set(key, value["value"]) |
| 134 | + if "_delete" in value and value["_delete"] is not None: |
| 135 | + delete_key = value["_delete"] |
| 136 | + del el.attrib[delete_key] |
| 137 | + |
| 138 | + def _render_content(self): |
| 139 | + """Renders annotatable content with annotation spans and returns HTML.""" |
| 140 | + |
| 141 | + xmltree = etree.fromstring(self.data) |
| 142 | + content = etree.tostring(xmltree, encoding="unicode") |
| 143 | + |
| 144 | + xmltree = etree.fromstring(content) |
| 145 | + xmltree.tag = "div" |
| 146 | + if "display_name" in xmltree.attrib: |
| 147 | + del xmltree.attrib["display_name"] |
| 148 | + |
| 149 | + index = 0 |
| 150 | + for el in xmltree.findall(".//annotation"): |
| 151 | + self._render_annotation(index, el) |
| 152 | + index += 1 |
| 153 | + |
| 154 | + return etree.tostring(xmltree, encoding="unicode") |
| 155 | + |
| 156 | + def _extract_instructions(self, xmltree): |
| 157 | + """Removes <instructions> from the xmltree and returns them as a string, otherwise None.""" |
| 158 | + instructions = xmltree.find("instructions") |
| 159 | + if instructions is not None: |
| 160 | + instructions.tag = "div" |
| 161 | + xmltree.remove(instructions) |
| 162 | + return etree.tostring(instructions, encoding="unicode") |
| 163 | + return None |
| 164 | + |
| 165 | + def student_view(self, context=None): # pylint: disable=unused-argument |
| 166 | + """Renders the output that a student will see.""" |
46 | 167 | frag = Fragment() |
47 | 168 | frag.add_content( |
48 | 169 | resource_loader.render_django_template( |
49 | 170 | "templates/annotatable.html", |
50 | 171 | { |
51 | | - "count": self.count, |
| 172 | + "id": uuid.uuid1(0), |
| 173 | + "display_name": self.display_name, |
| 174 | + "instructions_html": self._extract_instructions(etree.fromstring(self.data)), |
| 175 | + "content_html": self._render_content(), |
52 | 176 | }, |
53 | 177 | i18n_service=self.runtime.service(self, "i18n"), |
54 | 178 | ) |
55 | 179 | ) |
56 | | - |
57 | 180 | frag.add_css(self.resource_string("static/css/annotatable.css")) |
58 | 181 | frag.add_javascript(self.resource_string("static/js/src/annotatable.js")) |
59 | | - frag.initialize_js("AnnotatableBlock") |
| 182 | + frag.initialize_js("Annotatable") |
| 183 | + return frag |
| 184 | + |
| 185 | + def studio_view(self, context=None): # pylint: disable=unused-argument |
| 186 | + """Return the studio view.""" |
| 187 | + frag = Fragment() |
| 188 | + frag.add_content( |
| 189 | + resource_loader.render_django_template( |
| 190 | + "templates/annotatable_editor.html", |
| 191 | + { |
| 192 | + "data": self.data, |
| 193 | + }, |
| 194 | + i18n_service=self.runtime.service(self, "i18n"), |
| 195 | + ) |
| 196 | + ) |
| 197 | + |
| 198 | + frag.add_css(self.resource_string("static/css/annotatable_editor.css")) |
| 199 | + frag.add_javascript(self.resource_string("static/js/src/annotatable_editor.js")) |
| 200 | + frag.initialize_js("XMLEditingDescriptor") |
60 | 201 | return frag |
61 | 202 |
|
62 | | - # TO-DO: change this handler to perform your own actions. You may need more |
63 | | - # than one handler, or you may not need any handlers at all. |
64 | 203 | @XBlock.json_handler |
65 | | - def increment_count(self, data, suffix=""): |
66 | | - """ |
67 | | - Increments data. An example handler. |
68 | | - """ |
69 | | - if suffix: |
70 | | - pass # TO-DO: Use the suffix when storing data. |
71 | | - # Just to show data coming in... |
72 | | - assert data["hello"] == "world" |
| 204 | + def submit_studio_edits(self, data, suffix=""): # pylint: disable=unused-argument |
| 205 | + """AJAX handler for saving the studio edits.""" |
| 206 | + display_name = data.get("display_name") |
| 207 | + xml_data = data.get("data") |
| 208 | + |
| 209 | + if display_name is not None: |
| 210 | + self.display_name = display_name |
| 211 | + if xml_data is not None: |
| 212 | + self.data = xml_data |
73 | 213 |
|
74 | | - self.count += 1 |
75 | | - return {"count": self.count} |
| 214 | + return {"result": "success"} |
76 | 215 |
|
77 | | - # TO-DO: change this to create the scenarios you'd like to see in the |
78 | | - # workbench while developing your XBlock. |
79 | 216 | @staticmethod |
80 | 217 | def workbench_scenarios(): |
81 | | - """Create canned scenario for display in the workbench.""" |
| 218 | + """Defines scenarios for displaying the XBlock in the XBlock workbench.""" |
82 | 219 | return [ |
| 220 | + ("AnnotatableXBlock", "<_annotatable_extracted/>"), |
83 | 221 | ( |
84 | | - "AnnotatableBlock", |
85 | | - """<_annotatable_extracted/> |
86 | | - """, |
87 | | - ), |
88 | | - ( |
89 | | - "Multiple AnnotatableBlock", |
90 | | - """<vertical_demo> |
91 | | - <_annotatable_extracted/> |
92 | | - <_annotatable_extracted/> |
93 | | - <_annotatable_extracted/> |
| 222 | + "Multiple AnnotatableXBlock", |
| 223 | + """ |
| 224 | + <vertical_demo> |
| 225 | + <_annotatable_extracted/> |
| 226 | + <_annotatable_extracted/> |
| 227 | + <_annotatable_extracted/> |
94 | 228 | </vertical_demo> |
95 | | - """, |
| 229 | + """, |
96 | 230 | ), |
97 | 231 | ] |
98 | 232 |
|
99 | | - @staticmethod |
100 | | - def get_dummy(): |
101 | | - """ |
102 | | - Generate initial i18n with dummy method. |
103 | | - """ |
104 | | - return translation.gettext_noop("Dummy") |
| 233 | + def resource_string(self, path): |
| 234 | + """Handy helper for getting resources from our kit.""" |
| 235 | + return files(__package__).joinpath(path).read_text(encoding="utf-8") |
0 commit comments