Skip to content

Commit a6905b1

Browse files
committed
fix: extract annotatable xblock
1 parent eaa279a commit a6905b1

File tree

8 files changed

+1039
-118
lines changed

8 files changed

+1039
-118
lines changed
Lines changed: 190 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,235 @@
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
311
from importlib.resources import files
412

5-
from django.utils import translation
13+
import markupsafe
14+
from django.utils.translation import gettext_noop as _
15+
from lxml import etree
616
from web_fragments.fragment import Fragment
717
from xblock.core import XBlock
8-
from xblock.fields import Integer, Scope
18+
from xblock.fields import Scope, String, XMLString
919
from xblock.utils.resources import ResourceLoader
1020

21+
log = logging.getLogger(__name__)
22+
1123
resource_loader = ResourceLoader(__name__)
1224

1325

14-
# This Xblock is just to test the strucutre of xblocks-contrib
1526
@XBlock.needs("i18n")
1627
class AnnotatableBlock(XBlock):
1728
"""
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.
1931
"""
2032

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+
)
2339

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+
),
2980
)
3081

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"]
3384

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.
4088
"""
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.
42108
"""
43-
if context:
44-
pass # TO-DO: do something based on the context.
45109

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."""
46167
frag = Fragment()
47168
frag.add_content(
48169
resource_loader.render_django_template(
49170
"templates/annotatable.html",
50171
{
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(),
52176
},
53177
i18n_service=self.runtime.service(self, "i18n"),
54178
)
55179
)
56-
57180
frag.add_css(self.resource_string("static/css/annotatable.css"))
58181
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")
60201
return frag
61202

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.
64203
@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
73213

74-
self.count += 1
75-
return {"count": self.count}
214+
return {"result": "success"}
76215

77-
# TO-DO: change this to create the scenarios you'd like to see in the
78-
# workbench while developing your XBlock.
79216
@staticmethod
80217
def workbench_scenarios():
81-
"""Create canned scenario for display in the workbench."""
218+
"""Defines scenarios for displaying the XBlock in the XBlock workbench."""
82219
return [
220+
("AnnotatableXBlock", "<_annotatable_extracted/>"),
83221
(
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/>
94228
</vertical_demo>
95-
""",
229+
""",
96230
),
97231
]
98232

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

Comments
 (0)