From a6905b19e506297f7972561cb2f02945f9ad7b75 Mon Sep 17 00:00:00 2001 From: Irtaza Akram Date: Tue, 11 Feb 2025 17:20:57 +0500 Subject: [PATCH 1/3] fix: extract annotatable xblock --- xblocks_contrib/annotatable/annotatable.py | 249 ++++++++--- .../annotatable/static/css/annotatable.css | 193 ++++++++- .../static/css/annotatable_editor.css | 23 + .../annotatable/static/js/src/annotatable.js | 392 ++++++++++++++++-- .../static/js/src/annotatable_editor.js | 75 ++++ .../annotatable/templates/annotatable.html | 27 +- .../templates/annotatable_editor.html | 20 + .../annotatable/tests/test_annotatable.py | 178 +++++++- 8 files changed, 1039 insertions(+), 118 deletions(-) create mode 100644 xblocks_contrib/annotatable/static/css/annotatable_editor.css create mode 100644 xblocks_contrib/annotatable/static/js/src/annotatable_editor.js create mode 100644 xblocks_contrib/annotatable/templates/annotatable_editor.html diff --git a/xblocks_contrib/annotatable/annotatable.py b/xblocks_contrib/annotatable/annotatable.py index e851e54..f138c4b 100644 --- a/xblocks_contrib/annotatable/annotatable.py +++ b/xblocks_contrib/annotatable/annotatable.py @@ -1,104 +1,235 @@ -"""TO-DO: Write a description of what this XBlock is.""" - +""" +AnnotatableXBlock allows instructors to add interactive annotations to course content. +Annotations can have configurable attributes such as title, body, problem index, and highlight color. +This block enhances the learning experience by enabling students to view embedded comments, questions, or explanations. +The block supports internationalization (i18n) for multilingual courses. +""" + +import logging +import textwrap +import uuid from importlib.resources import files -from django.utils import translation +import markupsafe +from django.utils.translation import gettext_noop as _ +from lxml import etree from web_fragments.fragment import Fragment from xblock.core import XBlock -from xblock.fields import Integer, Scope +from xblock.fields import Scope, String, XMLString from xblock.utils.resources import ResourceLoader +log = logging.getLogger(__name__) + resource_loader = ResourceLoader(__name__) -# This Xblock is just to test the strucutre of xblocks-contrib @XBlock.needs("i18n") class AnnotatableBlock(XBlock): """ - TO-DO: document what your XBlock does. + AnnotatableXBlock allows instructors to create annotated content that students can view interactively. + Annotations can be styled and customized, with internationalization support for multilingual environments. """ - # Fields are defined on the class. You can access them in your code as - # self.. + display_name = String( + display_name=_("Display Name"), + help=_("The display name for this component."), + scope=Scope.settings, + default=_("Annotation"), + ) - # TO-DO: delete count, and define your own fields. - count = Integer( - default=0, - scope=Scope.user_state, - help="A simple counter, to show something happening", + data = XMLString( + help=_("XML data for the annotation"), + scope=Scope.content, + default=textwrap.dedent( + markupsafe.Markup( + """ + + +

Enter your (optional) instructions for the exercise in HTML format.

+

Annotations are specified by an {}annotation{} tag which may + may have the following attributes:

+
    +
  • title (optional). Title of the annotation. Defaults to + Commentary if omitted.
  • +
  • body (required). Text of the annotation.
  • +
  • problem (optional). Numeric index of the problem + associated with this annotation. This is a zero-based index, so the first + problem on the page would have problem="0".
  • +
  • highlight (optional). Possible values: yellow, red, + orange, green, blue, or purple. Defaults to yellow if this attribute is + omitted.
  • +
+
+

Add your HTML with annotation spans here.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + Ut sodales laoreet est, egestas gravida felis egestas nec. Aenean + at volutpat erat. Cras commodo viverra nibh in aliquam.

+

Nulla facilisi. + Pellentesque id vestibulum libero. Suspendisse potenti. Morbi + scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam + volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci + in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. + Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus + elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus + eros.

+
+ """ + ).format(markupsafe.escape("<"), markupsafe.escape(">")) + ), ) - # Indicates that this XBlock has been extracted from edx-platform. - is_extracted = True + # List of supported highlight colors for annotations + HIGHLIGHT_COLORS = ["yellow", "orange", "purple", "blue", "green"] - def resource_string(self, path): - """Handy helper for getting resources from our kit.""" - return files(__package__).joinpath(path).read_text(encoding="utf-8") - - # TO-DO: change this view to display your data your own way. - def student_view(self, context=None): + def _get_annotation_class_attr(self, index, el): # pylint: disable=unused-argument + """Returns a dict with the CSS class attribute to set on the annotation + and an XML key to delete from the element. """ - Create primary view of the AnnotatableBlock, shown to students when viewing courses. + + attr = {} + cls = ["annotatable-span", "highlight"] + highlight_key = "highlight" + color = el.get(highlight_key) + + if color is not None: + if color in self.HIGHLIGHT_COLORS: + cls.append("highlight-" + color) + attr["_delete"] = highlight_key + attr["value"] = " ".join(cls) + + return {"class": attr} + + def _get_annotation_data_attr(self, index, el): # pylint: disable=unused-argument + """Returns a dict in which the keys are the HTML data attributes + to set on the annotation element. Each data attribute has a + corresponding 'value' and (optional) '_delete' key to specify + an XML attribute to delete. """ - if context: - pass # TO-DO: do something based on the context. + data_attrs = {} + attrs_map = { + "body": "data-comment-body", + "title": "data-comment-title", + "problem": "data-problem-id", + } + + for xml_key, html_key in attrs_map.items(): + if xml_key in el.attrib: + value = el.get(xml_key, "") + data_attrs[html_key] = {"value": value, "_delete": xml_key} + + return data_attrs + + def _render_annotation(self, index, el): + """Renders an annotation element for HTML output.""" + attr = {} + attr.update(self._get_annotation_class_attr(index, el)) + attr.update(self._get_annotation_data_attr(index, el)) + + el.tag = "span" + + for key, value in attr.items(): + el.set(key, value["value"]) + if "_delete" in value and value["_delete"] is not None: + delete_key = value["_delete"] + del el.attrib[delete_key] + + def _render_content(self): + """Renders annotatable content with annotation spans and returns HTML.""" + + xmltree = etree.fromstring(self.data) + content = etree.tostring(xmltree, encoding="unicode") + + xmltree = etree.fromstring(content) + xmltree.tag = "div" + if "display_name" in xmltree.attrib: + del xmltree.attrib["display_name"] + + index = 0 + for el in xmltree.findall(".//annotation"): + self._render_annotation(index, el) + index += 1 + + return etree.tostring(xmltree, encoding="unicode") + + def _extract_instructions(self, xmltree): + """Removes from the xmltree and returns them as a string, otherwise None.""" + instructions = xmltree.find("instructions") + if instructions is not None: + instructions.tag = "div" + xmltree.remove(instructions) + return etree.tostring(instructions, encoding="unicode") + return None + + def student_view(self, context=None): # pylint: disable=unused-argument + """Renders the output that a student will see.""" frag = Fragment() frag.add_content( resource_loader.render_django_template( "templates/annotatable.html", { - "count": self.count, + "id": uuid.uuid1(0), + "display_name": self.display_name, + "instructions_html": self._extract_instructions(etree.fromstring(self.data)), + "content_html": self._render_content(), }, i18n_service=self.runtime.service(self, "i18n"), ) ) - frag.add_css(self.resource_string("static/css/annotatable.css")) frag.add_javascript(self.resource_string("static/js/src/annotatable.js")) - frag.initialize_js("AnnotatableBlock") + frag.initialize_js("Annotatable") + return frag + + def studio_view(self, context=None): # pylint: disable=unused-argument + """Return the studio view.""" + frag = Fragment() + frag.add_content( + resource_loader.render_django_template( + "templates/annotatable_editor.html", + { + "data": self.data, + }, + i18n_service=self.runtime.service(self, "i18n"), + ) + ) + + frag.add_css(self.resource_string("static/css/annotatable_editor.css")) + frag.add_javascript(self.resource_string("static/js/src/annotatable_editor.js")) + frag.initialize_js("XMLEditingDescriptor") return frag - # TO-DO: change this handler to perform your own actions. You may need more - # than one handler, or you may not need any handlers at all. @XBlock.json_handler - def increment_count(self, data, suffix=""): - """ - Increments data. An example handler. - """ - if suffix: - pass # TO-DO: Use the suffix when storing data. - # Just to show data coming in... - assert data["hello"] == "world" + def submit_studio_edits(self, data, suffix=""): # pylint: disable=unused-argument + """AJAX handler for saving the studio edits.""" + display_name = data.get("display_name") + xml_data = data.get("data") + + if display_name is not None: + self.display_name = display_name + if xml_data is not None: + self.data = xml_data - self.count += 1 - return {"count": self.count} + return {"result": "success"} - # TO-DO: change this to create the scenarios you'd like to see in the - # workbench while developing your XBlock. @staticmethod def workbench_scenarios(): - """Create canned scenario for display in the workbench.""" + """Defines scenarios for displaying the XBlock in the XBlock workbench.""" return [ + ("AnnotatableXBlock", "<_annotatable_extracted/>"), ( - "AnnotatableBlock", - """<_annotatable_extracted/> - """, - ), - ( - "Multiple AnnotatableBlock", - """ - <_annotatable_extracted/> - <_annotatable_extracted/> - <_annotatable_extracted/> + "Multiple AnnotatableXBlock", + """ + + <_annotatable_extracted/> + <_annotatable_extracted/> + <_annotatable_extracted/> - """, + """, ), ] - @staticmethod - def get_dummy(): - """ - Generate initial i18n with dummy method. - """ - return translation.gettext_noop("Dummy") + def resource_string(self, path): + """Handy helper for getting resources from our kit.""" + return files(__package__).joinpath(path).read_text(encoding="utf-8") diff --git a/xblocks_contrib/annotatable/static/css/annotatable.css b/xblocks_contrib/annotatable/static/css/annotatable.css index 660c1a0..778ba5f 100644 --- a/xblocks_contrib/annotatable/static/css/annotatable.css +++ b/xblocks_contrib/annotatable/static/css/annotatable.css @@ -1,9 +1,190 @@ -/* CSS for AnnotatableBlock */ +@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.annotatable .count { - font-weight: bold; +.annotatable-wrapper { + position: relative; } - -.annotatable p { - cursor: pointer; +.annotatable-header { + margin-bottom: 0.5em; +} +.annotatable-section { + position: relative; + padding: 0.5em 1em; + border: 1px solid #c8c8c8; + border-radius: 0.5em; + margin-bottom: 0.5em; +} +.annotatable-section.shaded { + background-color: #ededed; +} +.annotatable-section .annotatable-section-title { + font-weight: bold; +} +.annotatable-section .annotatable-section-title a { + font-weight: normal; +} +.annotatable-section .annotatable-section-body { + border-top: 1px solid #c8c8c8; + margin-top: 0.5em; + padding-top: 0.5em; +} +.annotatable-section .annotatable-section-body:after { + content: ""; + display: table; + clear: both; +} +.annotatable-section ul.instructions-template { + list-style: disc; + margin-left: 4em; +} +.annotatable-section ul.instructions-template b { + font-weight: bold; +} +.annotatable-section ul.instructions-template i { + font-style: italic; +} +.annotatable-section ul.instructions-template code { + display: inline; + white-space: pre; + font-family: Courier New, monospace; +} +.annotatable-toggle { + position: absolute; + right: 0; + margin: 2px 1em 2px 0; +} +.annotatable-toggle.expanded::after { + content: " \2191"; +} +.annotatable-toggle.collapsed::after { + content: " \2193"; +} +.annotatable-span { + display: inline; +} +.annotatable-span.highlight { + background-color: rgba(255, 255, 10, 0.3); +} +.annotatable-span.highlight.selected { + background-color: rgba(255, 255, 10, 0.9); +} +.annotatable-span.highlight-yellow { + background-color: rgba(255, 255, 10, 0.3); +} +.annotatable-span.highlight-yellow.selected { + background-color: rgba(255, 255, 10, 0.9); +} +.annotatable-span.highlight-red { + background-color: rgba(178, 19, 16, 0.3); +} +.annotatable-span.highlight-red.selected { + background-color: rgba(178, 19, 16, 0.9); +} +.annotatable-span.highlight-orange { + background-color: rgba(255, 165, 0, 0.3); +} +.annotatable-span.highlight-orange.selected { + background-color: rgba(255, 165, 0, 0.9); +} +.annotatable-span.highlight-green { + background-color: rgba(25, 255, 132, 0.3); +} +.annotatable-span.highlight-green.selected { + background-color: rgba(25, 255, 132, 0.9); +} +.annotatable-span.highlight-blue { + background-color: rgba(35, 163, 255, 0.3); +} +.annotatable-span.highlight-blue.selected { + background-color: rgba(35, 163, 255, 0.9); +} +.annotatable-span.highlight-purple { + background-color: rgba(115, 9, 178, 0.3); +} +.annotatable-span.highlight-purple.selected { + background-color: rgba(115, 9, 178, 0.9); +} +.annotatable-span.hide { + cursor: none; + background-color: inherit; +} +.annotatable-span.hide .annotatable-icon { + display: none; +} +.annotatable-span .annotatable-comment { + display: none; +} +.ui-tooltip.qtip.ui-tooltip { + font-size: 0.875em; + border: 1px solid #333; + border-radius: 1em; + background-color: rgba(0, 0, 0, 0.85); + color: #fff; + -webkit-font-smoothing: antialiased; +} +.ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar { + font-size: 1em; + color: inherit; + background-color: transparent; + padding: 5px 10px; + border: none; +} +.ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar .ui-tooltip-title { + padding: 5px 0; + border-bottom: 2px solid #333; + font-weight: bold; +} +.ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar .ui-tooltip-icon { + right: 10px; + background: #333; +} +.ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar .ui-state-hover { + color: inherit; + border: 1px solid #c8c8c8; +} +.ui-tooltip.qtip.ui-tooltip .ui-tooltip-content { + color: inherit; + font-size: 0.875em; + text-align: left; + font-weight: 400; + padding: 0 10px 10px 10px; + background-color: transparent; + border-color: transparent; +} +.ui-tooltip.qtip.ui-tooltip p { + color: inherit; + line-height: normal; +} +.ui-tooltip.qtip.ui-tooltip-annotatable { + max-width: 375px; +} +.ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content { + padding: 0 10px; +} +.ui-tooltip.qtip.ui-tooltip-annotatable + .ui-tooltip-content + .annotatable-comment { + display: block; + margin: 0 0 10px 0; + max-height: 225px; + overflow: auto; + line-height: normal; +} +.ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content .annotatable-reply { + display: block; + border-top: 2px solid #333; + padding: 5px 0; + margin: 0; + text-align: center; +} +.ui-tooltip.qtip.ui-tooltip-annotatable::after { + content: ""; + display: inline-block; + position: absolute; + bottom: -20px; + left: 50%; + height: 0; + width: 0; + margin-left: -5px; + border: 10px solid transparent; + border-top-color: rgba(0, 0, 0, 0.85); } diff --git a/xblocks_contrib/annotatable/static/css/annotatable_editor.css b/xblocks_contrib/annotatable/static/css/annotatable_editor.css new file mode 100644 index 0000000..62e3245 --- /dev/null +++ b/xblocks_contrib/annotatable/static/css/annotatable_editor.css @@ -0,0 +1,23 @@ +.wrapper-comp-editor { + display: block; +} + +.wrapper-comp-editor.is-inactive { + display: none; +} + +.wrapper-comp-editor.latex-problem { + margin-top: 50px; +} + +div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler { + display: none; +} + +.wrapper-comp-editor.latex-problem { + margin-top: 50px; +} + +div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler { + display: none; +} diff --git a/xblocks_contrib/annotatable/static/js/src/annotatable.js b/xblocks_contrib/annotatable/static/js/src/annotatable.js index 4d28b95..f27d48b 100644 --- a/xblocks_contrib/annotatable/static/js/src/annotatable.js +++ b/xblocks_contrib/annotatable/static/js/src/annotatable.js @@ -1,38 +1,372 @@ +function Annotatable(element) { + var _debug = false; -/* JavaScript for AnnotatableBlock. */ -function AnnotatableBlock(runtime, element) { - const updateCount = (result) => { - $('.count', element).text(result.count); - }; - - const handlerUrl = runtime.handlerUrl(element, 'increment_count'); - - $('p', element).on('click', (eventObject) => { - $.ajax({ - type: 'POST', - url: handlerUrl, - contentType: 'application/json', - data: JSON.stringify({hello: 'world'}), - success: updateCount + /* + Selectors for the annotatable xmodule + */ + var wrapperSelector = '.annotatable-wrapper'; + var toggleAnnotationsSelector = '.annotatable-toggle-annotations'; + var toggleInstructionsSelector = '.annotatable-toggle-instructions'; + var instructionsSelector = '.annotatable-instructions'; + var sectionSelector = '.annotatable-section'; + var spanSelector = '.annotatable-span'; + var replySelector = '.annotatable-reply'; + + /* + Selectors for responding to events from the annotation capa problem type + */ + var problemXModuleSelector = '.xmodule_CapaModule'; + var problemSelector = 'div.problem'; + var problemInputSelector = 'div.problem .annotation-input'; + var problemReturnSelector = 'div.problem .annotation-return'; + + var annotationsHidden = false; + var instructionsHidden = false; + + var el = element; + var $el = $(element); + + if (_debug) { + console.log('loaded Annotatable'); + } + + init(); + + function init() { + initEvents(); + initTips(); + } + + function initEvents() { + annotationsHidden = false; + instructionsHidden = false; + + /* + Initialize toggle handlers for the instructions and annotations sections + */ + $(toggleAnnotationsSelector).on('click', onClickToggleAnnotations); + $(toggleInstructionsSelector).on('click', onClickToggleInstructions); + + /* + Initialize handler for 'reply to annotation' events that scroll to + the associated problem. The reply buttons are part of the tooltip + content. It's important that the tooltips be configured to render + as descendants of the annotation module and *not* the document.body. + */ + $el.on('click', replySelector, onClickReply); + + /* + Initialize handler for 'return to annotation' events triggered from problems. + 1) There are annotationinput capa problems rendered on the page + 2) Each one has an embedded return link (see annotation capa problem template). + Since the capa problem injects HTML content via AJAX, the best we can do is + let the click events bubble up to the body and handle them there. + */ + $(document).on('click', problemReturnSelector, onClickReturn); + } + + function initTips() { + /* + Tooltips are used to display annotations for highlighted text spans + */ + $(spanSelector).each(function(index, el) { + $(el).qtip(getSpanTipOptions(el)); }); - }); + } + + function getSpanTipOptions(el) { + return { + content: { + title: { + text: makeTipTitle(el), + }, + text: makeTipContent(el), + }, + position: { + /* + Of tooltip + */ + my: 'bottom center', + /* + Of target + */ + at: 'top center', + /* + Where the tooltip was triggered (i.e., the annotation span) + */ + target: $(el), + container: $(wrapperSelector), + adjust: { + y: -5, + }, + }, + show: { + event: 'click mouseenter', + solo: true, + }, + hide: { + event: 'click mouseleave', + delay: 500, + /* + Don't hide the tooltip if it is moused over + */ + fixed: true, + }, + style: { + classes: 'ui-tooltip-annotatable', + }, + events: { + show: onShowTip, + move: onMoveTip, + }, + }; + } + + function onClickToggleAnnotations(e) { + toggleAnnotations(); + } + + function onClickToggleInstructions(e) { + toggleInstructions(); + } + + function onClickReply(e) { + replyTo(e.currentTarget); + } + + function onClickReturn(e) { + returnFrom(e.currentTarget); + } + + function onShowTip(event, api) { + if (annotationsHidden) { + event.preventDefault(); + } + } + + function onMoveTip(event, api, position) { + /* + This method handles a vertical positioning bug in Firefox as + well as an edge case in which a tooltip is displayed above a + non-overlapping span like this: + + (( TOOLTIP )) + \/ + text text text ... text text text ...... + + + The problem is that the tooltip looks disconnected from both spans, so + we should re-position the tooltip to appear above the span. + */ + var tip = api.elements.tooltip; + var adjustY = (api.options.position && api.options.position.adjust && api.options.position.adjust.y) || 0; + var container = (api.options.position && api.options.position.container) || $('body'); + var target = api.elements.target; + var rects = $(target).get(0).getClientRects(); + var isNonOverlapping = rects && rects.length === 2 && rects[0].left > rects[1].right; + var focusRect; + + if (isNonOverlapping) { + /* + Choose the largest of the two non-overlapping spans and display + the tooltip above the center of it + */ + focusRect = rects[0].width > rects[1].width ? rects[0] : rects[1]; + } else { + /* + Always compute the new position because Firefox doesn't + properly vertically position the tooltip + */ + focusRect = rects[0]; + } + + var rectCenter = focusRect.left + focusRect.width / 2; + var rectTop = focusRect.top; + var tipWidth = $(tip).width(); + var tipHeight = $(tip).height(); - $(() => { /* - Use `gettext` provided by django-statici18n for static translations + Tooltip is positioned relative to its container, so we need to factor in offsets */ + var containerOffset = $(container).offset(); + var offsetLeft = -containerOffset.left; + var offsetTop = $(document).scrollTop() - containerOffset.top; + var tipLeft = offsetLeft + rectCenter - tipWidth / 2; + var tipTop = offsetTop + rectTop - tipHeight + adjustY; + + /* + Make sure the new tip position doesn't clip the edges of the screen + */ + var winWidth = $(window).width(); + if (tipLeft < offsetLeft) { + tipLeft = offsetLeft; + } else if (tipLeft + tipWidth > winWidth + offsetLeft) { + tipLeft = winWidth + offsetLeft - tipWidth; + } + + /* + Update the position object (used by qTip2 to show the tip after the move event) + */ + $.extend(position, { + left: tipLeft, + top: tipTop, + }); + } + + function getSpanForProblemReturn(el) { + var problemId = $(problemReturnSelector).index(el); + return $(spanSelector).filter("[data-problem-id='" + problemId + "']"); + } + + function getProblem(el) { + var problemId = getProblemId(el); + return $(problemInputSelector).eq(problemId); + } + + function getProblemId(el) { + return $(el).data('problem-id'); + } + + function toggleAnnotations() { + annotationsHidden = !annotationsHidden; + var hide = annotationsHidden; + toggleAnnotationButtonText(hide); + toggleSpans(hide); + toggleTips(hide); + } + + function toggleTips(hide) { + var visible = findVisibleTips(); + hideTips(visible); + } + + function toggleAnnotationButtonText(hide) { + var buttonText = hide ? gettext('Show Annotations') : gettext('Hide Annotations'); + $(toggleAnnotationsSelector).text(buttonText); + } + + function toggleInstructions() { + instructionsHidden = !instructionsHidden; + var hide = instructionsHidden; + toggleInstructionsButton(hide); + toggleInstructionsText(hide); + } + + function toggleInstructionsButton(hide) { + var txt = hide ? gettext('Expand Instructions') : gettext('Collapse Instructions'); + var cls = hide ? ['expanded', 'collapsed'] : ['collapsed', 'expanded']; + $(toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1]); + } + + function toggleInstructionsText(hide) { + var slideMethod = hide ? 'slideUp' : 'slideDown'; + $(instructionsSelector)[slideMethod](); + } + + function toggleSpans(hide) { + $(spanSelector).toggleClass('hide', hide, 250); + } + + function replyTo(buttonEl) { + var offset = -20; + var el = getProblem(buttonEl); + if (el.length > 0) { + scrollTo(el, afterScrollToProblem, offset); + } else if (_debug) { + console.log('Problem not found. Element:', buttonEl); + } + } + + function returnFrom(buttonEl) { + var offset = -200; + var el = getSpanForProblemReturn(buttonEl); + if (el.length > 0) { + scrollTo(el, afterScrollToSpan, offset); + } else if (_debug) { + console.log('Span not found. Element:', buttonEl); + } + } + + function scrollTo(el, after, offset) { + offset = offset || -20; + if ($(el).length > 0) { + $('html,body').scrollTo(el, { + duration: 500, + onAfter: _once(function() { + if (after) { + after.call(this, el); + } + }), + offset: offset, + }); + } + } + + function afterScrollToProblem(problemEl) { + problemEl.effect('highlight', {}, 500); + } + + function afterScrollToSpan(spanEl) { + spanEl.addClass('selected', 400, 'swing', function() { + spanEl.removeClass('selected', 400, 'swing'); + }); + } + + function makeTipContent(el) { + return function(api) { + var text = $(el).data('comment-body'); + var comment = createComment(text); + var problemId = getProblemId(el); + var reply = createReplyLink(problemId); + return $(comment).add(reply); + }; + } + + function makeTipTitle(el) { + return function(api) { + var title = $(el).data('comment-title'); + return title || gettext('Commentary'); + }; + } + + function createComment(text) { + return $('
' + text + '
'); // xss-lint: disable=javascript-concat-html + } + + function createReplyLink(problemId) { + var linkText = gettext('Reply to Annotation'); + return $( + '' + + linkText + + '', + ); // xss-lint: disable=javascript-concat-html + } + + function findVisibleTips() { + var visible = []; + $(spanSelector).each(function(index, el) { + var api = $(el).qtip('api'); + var tip = $(api && api.elements.tooltip); + if (tip.is(':visible')) { + visible.push(el); + } + }); + return visible; + } - // eslint-disable-next-line no-undef - const dummyText = gettext('Hello World'); + function hideTips(elements) { + $(elements).qtip('hide'); + } - // Example usage of interpolation for translated strings - // eslint-disable-next-line no-undef - const message = StringUtils.interpolate( - gettext('You are enrolling in {courseName}'), - { - courseName: 'Rock & Roll 101' + function _once(fn) { + var done = false; + return function() { + if (!done) { + fn.call(this); + done = true; } - ); - console.log(message); // This is just for demonstration purposes - }); + }; + } } diff --git a/xblocks_contrib/annotatable/static/js/src/annotatable_editor.js b/xblocks_contrib/annotatable/static/js/src/annotatable_editor.js new file mode 100644 index 0000000..140e3a5 --- /dev/null +++ b/xblocks_contrib/annotatable/static/js/src/annotatable_editor.js @@ -0,0 +1,75 @@ +function XMLEditingDescriptor(runtime, element) { + var saveButtonSelector = '.save-button'; + var cancelButtonSelector = '.cancel-button'; + var editBoxSelector = '.edit-box'; + var _debug = false; + + var el = element; + var $el = $(element); + + var textarea = $(editBoxSelector, el)[0]; + if (!textarea) { + console.error('Error: Textarea with class "edit-box" not found.'); + return; + } + + var editBox = CodeMirror.fromTextArea(textarea, { + mode: 'xml', + lineNumbers: true, + lineWrapping: true + }); + + if (_debug) { + console.log('loaded XMLEditingDescriptor'); + } + + function init() { + initEvents(); + } + + function initEvents() { + $(saveButtonSelector, el).on('click', onClickSave); + $(cancelButtonSelector, el).on('click', onClickCancel); + } + + function onClickSave(e) { + e.preventDefault(); + submit(); + } + + function onClickCancel(e) { + e.preventDefault(); + closeEditor(); + } + + function save() { + return { + data: editBox.getValue() + }; + } + + function submit() { + var handlerUrl = runtime.handlerUrl(el, 'submit_studio_edits'); + runtime.notify('save', {state: 'start', message: 'Saving...'}); + var data = save(); + + $.ajax({ + type: 'POST', + url: handlerUrl, + data: JSON.stringify(data), + dataType: 'json', + success: function(response) { + runtime.notify('save', {state: 'end'}); + } + }).fail(function(jqXHR) { + var message = 'There was an issue saving the settings. Please try again.'; + runtime.notify('error', {title: 'Unable to save', message: message}); + }); + } + + function closeEditor() { + runtime.notify('cancel', {}); + } + + init(); +} diff --git a/xblocks_contrib/annotatable/templates/annotatable.html b/xblocks_contrib/annotatable/templates/annotatable.html index 0c43172..631b6b6 100644 --- a/xblocks_contrib/annotatable/templates/annotatable.html +++ b/xblocks_contrib/annotatable/templates/annotatable.html @@ -1,7 +1,24 @@ {% load i18n %} - -
-

- AnnotatableBlock: {% trans "count is now" %} {{ count }} {% trans "click me to increment." %} -

+
+
+ {% if display_name %}

{{ display_name }}

{% endif %} +
+ {% if instructions_html %} +
+
+ {% trans "Instructions" %} + {% trans "Collapse Instructions" %} +
+
{{ instructions_html|safe }}
+
+ {% endif %} +
+
+ {% trans "Guided Discussion" %} + {% trans "Hide Annotations" %} +
+
{{ content_html|safe }}
+
diff --git a/xblocks_contrib/annotatable/templates/annotatable_editor.html b/xblocks_contrib/annotatable/templates/annotatable_editor.html new file mode 100644 index 0000000..2eb105e --- /dev/null +++ b/xblocks_contrib/annotatable/templates/annotatable_editor.html @@ -0,0 +1,20 @@ +{% load i18n %} +
+
+
+ +
+
+ + +
diff --git a/xblocks_contrib/annotatable/tests/test_annotatable.py b/xblocks_contrib/annotatable/tests/test_annotatable.py index 6c48b61..e8be16b 100644 --- a/xblocks_contrib/annotatable/tests/test_annotatable.py +++ b/xblocks_contrib/annotatable/tests/test_annotatable.py @@ -1,26 +1,166 @@ -""" -Tests for AnnotatableBlock -""" +import json +import unittest +from unittest.mock import MagicMock -from django.test import TestCase +from lxml import etree +from xblock.field_data import DictFieldData from xblock.fields import ScopeIds -from xblock.test.toy_runtime import ToyRuntime +from xblock.runtime import Runtime -from xblocks_contrib import AnnotatableBlock +from xblocks_contrib.annotatable.annotatable import AnnotatableBlock -class TestAnnotatableBlock(TestCase): - """Tests for AnnotatableBlock""" +class MockRuntime(Runtime): + """A mock implementation of the Runtime class for testing purposes.""" - def test_my_student_view(self): - """Test the basic view loads.""" - scope_ids = ScopeIds("1", "2", "3", "4") - block = AnnotatableBlock(ToyRuntime(), scope_ids=scope_ids) - frag = block.student_view() - as_dict = frag.to_dict() - content = as_dict["content"] - self.assertIn( - "AnnotatableBlock: count is now", - content, - "XBlock did not render correct student view", + def __init__(self): + # id_reader and id_generator are required by Runtime. + super().__init__(id_reader=lambda: None, id_generator=lambda: None) + + def handler_url(self, block, handler_name, suffix="", query=""): + return f"/mock_url/{handler_name}" + + def local_resource_url(self, block, resource): + return f"/mock_resource_url/{resource}" + + def resource_url(self, resource): + return f"/mock_resource/{resource}" + + def publish(self, block, event_type, event_data): + pass + + +class AnnotatableBlockTestCase(unittest.TestCase): + sample_xml = """ + + Read the text. +

+ Sing, + O goddess, + the anger of Achilles + son of Peleus, that brought countless ills upon the + Achaeans. Many a brave soul did it send hurrying down to Hades, and many a hero + did it yield a prey to dogs and +

+ vultures, for so were + the counsels of Jove fulfilled from the day on which the son of Atreus, king + of men, and great Achilles, first fell out with one another. +
+

+ The Iliad of Homer by Samuel Butler + +
+ """ + + def setUp(self): + super().setUp() + runtime = MockRuntime() + scope_ids = ScopeIds("user_id", "block_type", "block_id", "course_id") + field_data = DictFieldData({"data": self.sample_xml}) + self.annotatable = AnnotatableBlock(runtime, field_data, scope_ids) + + def test_annotation_data_attr(self): + el = etree.fromstring('test') + + expected_attr = { + "data-comment-body": {"value": "foo", "_delete": "body"}, + "data-comment-title": {"value": "bar", "_delete": "title"}, + "data-problem-id": {"value": "0", "_delete": "problem"}, + } + + actual_attr = self.annotatable._get_annotation_data_attr(0, el) + + assert isinstance(actual_attr, dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_annotation_class_attr_default(self): + xml = 'test' + el = etree.fromstring(xml) + + expected_attr = {"class": {"value": "annotatable-span highlight"}} + actual_attr = self.annotatable._get_annotation_class_attr(0, el) + + assert isinstance(actual_attr, dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_annotation_class_attr_with_valid_highlight(self): + xml = 'test' + + for color in self.annotatable.HIGHLIGHT_COLORS: + el = etree.fromstring(xml.format(highlight=color)) + value = f"annotatable-span highlight highlight-{color}" + + expected_attr = {"class": {"value": value, "_delete": "highlight"}} + actual_attr = self.annotatable._get_annotation_class_attr(0, el) + + assert isinstance(actual_attr, dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_annotation_class_attr_with_invalid_highlight(self): + xml = 'test' + + for invalid_color in ["rainbow", "blink", "invisible", "", None]: + el = etree.fromstring(xml.format(highlight=invalid_color)) + expected_attr = { + "class": {"value": "annotatable-span highlight", "_delete": "highlight"} + } + actual_attr = self.annotatable._get_annotation_class_attr(0, el) + + assert isinstance(actual_attr, dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_render_annotation(self): + expected_html = ( + '' + "z" ) + expected_el = etree.fromstring(expected_html) + + actual_el = etree.fromstring( + 'z' + ) + self.annotatable._render_annotation(0, actual_el) + + assert expected_el.tag == actual_el.tag + assert expected_el.text == actual_el.text + self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib)) + + def test_render_content(self): + content = self.annotatable._render_content() + el = etree.fromstring(content) + + assert "div" == el.tag, "root tag is a div" + + expected_num_annotations = 5 + actual_num_annotations = el.xpath('count(//span[contains(@class,"annotatable-span")])') + assert expected_num_annotations == actual_num_annotations, "check number of annotations" + + def test_extract_instructions(self): + xmltree = etree.fromstring(self.sample_xml) + + expected_xml = "
Read the text.
" + actual_xml = self.annotatable._extract_instructions(xmltree) + assert actual_xml is not None + assert expected_xml.strip() == actual_xml.strip() + + xmltree = etree.fromstring("foo") + actual = self.annotatable._extract_instructions(xmltree) + assert actual is None + + def test_submit_studio_edits(self): + mock_request = MagicMock() + mock_request.method = "POST" + mock_request.params = { + "display_name": "New Display Name", + "data": self.sample_xml, + } + mock_request.body = json.dumps(mock_request.params).encode("utf-8") + + response = self.annotatable.submit_studio_edits(mock_request) + + response_body = json.loads(response.body.decode("utf-8")) + + self.assertEqual(response_body["result"], "success") + self.assertEqual(self.annotatable.display_name, "New Display Name") + self.assertEqual(self.annotatable.data, self.sample_xml) From f2b3b83a8fc0b4742c59bd1ea8d6c38dc4de1071 Mon Sep 17 00:00:00 2001 From: Irtaza Akram Date: Wed, 12 Feb 2025 13:03:18 +0500 Subject: [PATCH 2/3] fix: tests --- xblocks_contrib/annotatable/annotatable.py | 2 +- .../annotatable/static/css/annotatable_editor.css | 8 -------- xblocks_contrib/annotatable/static/js/src/annotatable.js | 2 +- xblocks_contrib/annotatable/templates/annotatable.html | 2 +- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/xblocks_contrib/annotatable/annotatable.py b/xblocks_contrib/annotatable/annotatable.py index f138c4b..daf491f 100644 --- a/xblocks_contrib/annotatable/annotatable.py +++ b/xblocks_contrib/annotatable/annotatable.py @@ -169,7 +169,7 @@ def student_view(self, context=None): # pylint: disable=unused-argument resource_loader.render_django_template( "templates/annotatable.html", { - "id": uuid.uuid1(0), + "element_id": uuid.uuid1(0), "display_name": self.display_name, "instructions_html": self._extract_instructions(etree.fromstring(self.data)), "content_html": self._render_content(), diff --git a/xblocks_contrib/annotatable/static/css/annotatable_editor.css b/xblocks_contrib/annotatable/static/css/annotatable_editor.css index 62e3245..2e32250 100644 --- a/xblocks_contrib/annotatable/static/css/annotatable_editor.css +++ b/xblocks_contrib/annotatable/static/css/annotatable_editor.css @@ -13,11 +13,3 @@ div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler { display: none; } - -.wrapper-comp-editor.latex-problem { - margin-top: 50px; -} - -div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler { - display: none; -} diff --git a/xblocks_contrib/annotatable/static/js/src/annotatable.js b/xblocks_contrib/annotatable/static/js/src/annotatable.js index f27d48b..f264b9d 100644 --- a/xblocks_contrib/annotatable/static/js/src/annotatable.js +++ b/xblocks_contrib/annotatable/static/js/src/annotatable.js @@ -1,4 +1,4 @@ -function Annotatable(element) { +function Annotatable(runtime, element) { var _debug = false; /* diff --git a/xblocks_contrib/annotatable/templates/annotatable.html b/xblocks_contrib/annotatable/templates/annotatable.html index 631b6b6..5017b69 100644 --- a/xblocks_contrib/annotatable/templates/annotatable.html +++ b/xblocks_contrib/annotatable/templates/annotatable.html @@ -1,5 +1,5 @@ {% load i18n %} -
+
{% if display_name %}

{{ display_name }}

{% endif %}
From ace7c2ca3a35cd4242341654b9fe2d716a63360d Mon Sep 17 00:00:00 2001 From: Irtaza Akram Date: Wed, 12 Feb 2025 15:58:06 +0500 Subject: [PATCH 3/3] fix: add is_extracted --- xblocks_contrib/annotatable/annotatable.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/xblocks_contrib/annotatable/annotatable.py b/xblocks_contrib/annotatable/annotatable.py index daf491f..0d262c2 100644 --- a/xblocks_contrib/annotatable/annotatable.py +++ b/xblocks_contrib/annotatable/annotatable.py @@ -29,6 +29,8 @@ class AnnotatableBlock(XBlock): AnnotatableXBlock allows instructors to create annotated content that students can view interactively. Annotations can be styled and customized, with internationalization support for multilingual environments. """ + # Indicates that this XBlock has been extracted from edx-platform. + is_extracted = True display_name = String( display_name=_("Display Name"),