Commit f19ab786 by Tim Krones

WYSIWYG functionality for Studio, Part 1: Add support for definining

vectors by drawing them.
parent 708b2cca
.vectordraw_edit_block {
border-top: 1px solid #e5e5e5;
margin-left: 20px;
margin-right: 20px;
padding-top: 20px;
}
.vectordraw_edit_block h2 {
margin-bottom: 1em;
}
.vectordraw_edit_block p {
margin-bottom: 1em;
font-size: 0.9em;
}
.vectordraw_edit_block #vectordraw {
margin-bottom: 1.5em;
}
.vectordraw_edit_block .jxgboard {
border: 2px solid #1f628d;
margin-bottom: 1em;
}
.vectordraw_edit_block .jxgboard .JXGtext {
pointer-events: none; /* prevents cursor from turning into caret when over a label */
}
/* Javascript for StudioEditableXBlockMixin. */
function StudioEditableXBlockMixin(runtime, element) {
"use strict";
var fields = [];
var tinyMceAvailable = (typeof $.fn.tinymce !== 'undefined'); // Studio includes a copy of tinyMCE and its jQuery plugin
$(element).find('.field-data-control').each(function() {
var $field = $(this);
var $wrapper = $field.closest('li');
var $resetButton = $wrapper.find('button.setting-clear');
var type = $wrapper.data('cast');
fields.push({
name: $wrapper.data('field-name'),
isSet: function() { return $wrapper.hasClass('is-set'); },
hasEditor: function() { return tinyMceAvailable && $field.tinymce(); },
val: function() {
var val = $field.val();
// Cast values to the appropriate type so that we send nice clean JSON over the wire:
if (type == 'boolean')
return (val == 'true' || val == '1');
if (type == "integer")
return parseInt(val, 10);
if (type == "float")
return parseFloat(val);
return val;
},
removeEditor: function() {
$field.tinymce().remove();
}
});
var fieldChanged = function() {
// Field value has been modified:
$wrapper.addClass('is-set');
$resetButton.removeClass('inactive').addClass('active');
};
$field.bind("change input paste", fieldChanged);
$resetButton.click(function() {
$field.val($wrapper.attr('data-default')); // Use attr instead of data to force treating the default value as a string
$wrapper.removeClass('is-set');
$resetButton.removeClass('active').addClass('inactive');
});
if (type == 'html' && tinyMceAvailable) {
tinyMCE.baseURL = baseUrl + "/js/vendor/tinymce/js/tinymce";
$field.tinymce({
theme: 'modern',
skin: 'studio-tmce4',
height: '200px',
formats: { code: { inline: 'code' } },
codemirror: { path: "" + baseUrl + "/js/vendor" },
convert_urls: false,
plugins: "link codemirror",
menubar: false,
statusbar: false,
toolbar_items_size: 'small',
toolbar: "formatselect | styleselect | bold italic underline forecolor wrapAsCode | bullist numlist outdent indent blockquote | link unlink | code",
resize: "both",
setup : function(ed) {
ed.on('change', fieldChanged);
}
});
}
});
var studio_submit = function(data) {
var handlerUrl = runtime.handlerUrl(element, 'submit_studio_edits');
runtime.notify('save', {state: 'start', message: gettext("Saving")});
$.ajax({
type: "POST",
url: handlerUrl,
data: JSON.stringify(data),
dataType: "json",
global: false, // Disable Studio's error handling that conflicts with studio's notify('save') and notify('cancel') :-/
success: function(response) { runtime.notify('save', {state: 'end'}); }
}).fail(function(jqXHR) {
var message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.");
if (jqXHR.responseText) { // Is there a more specific error message we can show?
try {
message = JSON.parse(jqXHR.responseText).error;
if (typeof message === "object" && message.messages) {
// e.g. {"error": {"messages": [{"text": "Unknown user 'bob'!", "type": "error"}, ...]}} etc.
message = $.map(message.messages, function(msg) { return msg.text; }).join(", ");
}
} catch (error) { message = jqXHR.responseText.substr(0, 300); }
}
runtime.notify('error', {title: gettext("Unable to update settings"), message: message});
});
};
return {
save: function(data) {
var values = {};
var notSet = []; // List of field names that should be set to default values
for (var i in fields) {
var field = fields[i];
if (field.isSet()) {
values[field.name] = field.val();
} else {
notSet.push(field.name);
}
// Remove TinyMCE instances to make sure jQuery does not try to access stale instances
// when loading editor for another block:
if (field.hasEditor()) {
field.removeEditor();
}
}
// If WYSIWYG editor was used, prefer its data over value of "vectors" field:
if (data.vectors) {
values.vectors = JSON.stringify(data.vectors, undefined, 4);
}
studio_submit({values: values, defaults: notSet});
},
cancel: function() {
// Remove TinyMCE instances to make sure jQuery does not try to access stale instances
// when loading editor for another block:
for (var i in fields) {
var field = fields[i];
if (field.hasEditor()) {
field.removeEditor();
}
}
runtime.notify('cancel', {});
}
};
}
...@@ -10,7 +10,7 @@ function VectorDrawXBlock(runtime, element, init_args) { ...@@ -10,7 +10,7 @@ function VectorDrawXBlock(runtime, element, init_args) {
this.drawMode = false; this.drawMode = false;
this.history_stack = {undo: [], redo: []}; this.history_stack = {undo: [], redo: []};
this.settings = settings; this.settings = settings;
this.element = $('#' + element_id); this.element = $('#' + element_id, element);
this.element.on('click', '.reset', this.reset.bind(this)); this.element.on('click', '.reset', this.reset.bind(this));
this.element.on('click', '.add-vector', this.addElementFromList.bind(this)); this.element.on('click', '.add-vector', this.addElementFromList.bind(this));
...@@ -142,20 +142,6 @@ function VectorDrawXBlock(runtime, element, init_args) { ...@@ -142,20 +142,6 @@ function VectorDrawXBlock(runtime, element, init_args) {
return coords; return coords;
}; };
VectorDraw.prototype.getVectorStyle = function(vec) {
//width, color, size of control point, label (which should be a JSXGraph option)
var default_style = {
pointSize: 1,
pointColor: 'red',
width: 4,
color: "blue",
label: null,
labelColor: 'black'
};
return _.extend(default_style, vec.style);
};
VectorDraw.prototype.renderVector = function(idx, coords) { VectorDraw.prototype.renderVector = function(idx, coords) {
var vec = this.settings.vectors[idx]; var vec = this.settings.vectors[idx];
coords = coords || this.getVectorCoordinates(vec); coords = coords || this.getVectorCoordinates(vec);
......
function VectorDrawXBlockEdit(runtime, element, init_args) {
'use strict';
var VectorDraw = function(element_id, settings) {
this.board = null;
this.dragged_vector = null;
this.drawMode = false;
this.wasUsed = false;
this.settings = settings;
this.numberOfVectors = this.settings.vectors.length;
this.element = $('#' + element_id, element);
// Prevents default image drag and drop actions in some browsers.
this.element.on('mousedown', '.jxgboard image', function(evt) { evt.preventDefault(); });
this.render();
};
VectorDraw.prototype.render = function() {
// Assign the jxgboard element a random unique ID,
// because JXG.JSXGraph.initBoard needs it.
this.element.find('.jxgboard').prop('id', _.uniqueId('jxgboard'));
this.createBoard();
};
VectorDraw.prototype.createBoard = function() {
var id = this.element.find('.jxgboard').prop('id'),
self = this;
this.board = JXG.JSXGraph.initBoard(id, {
keepaspectratio: true,
boundingbox: this.settings.bounding_box,
axis: this.settings.axis,
showCopyright: false,
showNavigation: this.settings.show_navigation
});
function getImageRatio(bg, callback) {
var img = new Image();
$(img).load(function() {
var ratio = this.height / this.width;
callback(bg, ratio);
}).attr({
src: bg.src
});
}
function drawBackground(bg, ratio) {
var height = (bg.height) ? bg.height : bg.width * ratio;
var coords = (bg.coords) ? bg.coords : [-bg.width/2, -height/2];
self.board.create('image', [bg.src, coords, [bg.width, height]], {fixed: true});
}
if (this.settings.background) {
if (this.settings.background.height) {
drawBackground(this.settings.background);
}
else {
getImageRatio(this.settings.background, drawBackground);
}
}
this.settings.points.forEach(function(point, idx) {
this.renderPoint(idx);
}, this);
this.settings.vectors.forEach(function(vec, idx) {
this.renderVector(idx);
}, this);
this.board.on('down', this.onBoardDown.bind(this));
this.board.on('move', this.onBoardMove.bind(this));
this.board.on('up', this.onBoardUp.bind(this));
};
VectorDraw.prototype.renderPoint = function(idx, coords) {
var point = this.settings.points[idx];
var coords = coords || point.coords;
var board_object = this.board.elementsByName[point.name];
if (board_object) {
// If the point is already rendered, only update its coordinates.
board_object.setPosition(JXG.COORDS_BY_USER, coords);
return;
}
this.board.create('point', coords, point.style);
};
VectorDraw.prototype.getVectorCoordinates = function(vec) {
var coords = vec.coords;
if (!coords) {
var tail = vec.tail || [0, 0];
var length = 'length' in vec ? vec.length : 5;
var angle = 'angle' in vec ? vec.angle : 30;
var radians = angle * Math.PI / 180;
var tip = [
tail[0] + Math.cos(radians) * length,
tail[1] + Math.sin(radians) * length
];
coords = [tail, tip];
}
return coords;
};
VectorDraw.prototype.renderVector = function(idx, coords) {
var vec = this.settings.vectors[idx];
coords = coords || this.getVectorCoordinates(vec);
// If this vector is already rendered, only update its coordinates.
var board_object = this.board.elementsByName[vec.name];
if (board_object) {
board_object.point1.setPosition(JXG.COORDS_BY_USER, coords[0]);
board_object.point2.setPosition(JXG.COORDS_BY_USER, coords[1]);
return;
}
var style = vec.style;
var tail = this.board.create('point', coords[0], {
name: vec.name,
size: style.pointSize,
fillColor: style.pointColor,
strokeColor: style.pointColor,
withLabel: false,
fixed: (vec.type === 'arrow' | vec.type === 'vector'),
showInfoBox: false
});
var tip = this.board.create('point', coords[1], {
name: style.label || vec.name,
size: style.pointSize,
fillColor: style.pointColor,
strokeColor: style.pointColor,
withLabel: true,
showInfoBox: false
});
// Not sure why, but including labelColor in attributes above doesn't work,
// it only works when set explicitly with setAttribute.
tip.setAttribute({labelColor: style.labelColor});
tip.label.setAttribute({fontsize: 18, highlightStrokeColor: 'black'});
var line_type = (vec.type === 'vector') ? 'arrow' : vec.type;
var line = this.board.create(line_type, [tail, tip], {
name: vec.name,
strokeWidth: style.width,
strokeColor: style.color
});
// a11y
var lineElement = $(line.rendNode);
var lineID = lineElement.attr("id");
var titleID = lineID + "-title";
var titleElement = $("<title>").attr("id", titleID).text(vec.name);
lineElement.append(titleElement);
lineElement.attr("aria-labelledby", titleID);
var descID = lineID + "-desc";
var descElement = $("<desc>").attr("id", descID).text(vec.description);
lineElement.append(descElement);
lineElement.attr("aria-describedby", descID);
return line;
};
VectorDraw.prototype.getMouseCoords = function(evt) {
var i = evt[JXG.touchProperty] ? 0 : undefined;
var c_pos = this.board.getCoordsTopLeftCorner(evt, i);
var abs_pos = JXG.getPosition(evt, i);
var dx = abs_pos[0] - c_pos[0];
var dy = abs_pos[1] - c_pos[1];
return new JXG.Coords(JXG.COORDS_BY_SCREEN, [dx, dy], this.board);
};
VectorDraw.prototype.getVectorForObject = function(obj) {
if (obj instanceof JXG.Line) {
return obj;
}
if (obj instanceof JXG.Text) {
return this.getVectorForObject(obj.element);
}
if (obj instanceof JXG.Point) {
return _.find(obj.descendants, function (d) { return (d instanceof JXG.Line); });
}
return null;
};
VectorDraw.prototype.isVectorTailDraggable = function(vector) {
return vector.elType !== 'arrow';
};
VectorDraw.prototype.canCreateVectorOnTopOf = function(el) {
// If the user is trying to drag the arrow of an existing vector, we should not create a new vector.
if (el instanceof JXG.Line) {
return false;
}
// If this is tip/tail of a vector, it's going to have a descendant Line - we should not create a new vector
// when over the tip. Creating on top of the tail is allowed for plain vectors but not for segments.
// If it doesn't have a descendant Line, it's a point from settings.points - creating a new vector is allowed.
if (el instanceof JXG.Point) {
var vector = this.getVectorForObject(el);
if (!vector) {
return el.getProperty('fixed');
} else if (el === vector.point1 && !this.isVectorTailDraggable(vector)) {
return true;
} else {
return false;
}
}
return true;
};
VectorDraw.prototype.objectsUnderMouse = function(coords) {
var filter = function(el) {
return !(el instanceof JXG.Image) && el.hasPoint(coords.scrCoords[1], coords.scrCoords[2]);
};
return _.filter(_.values(this.board.objects), filter);
};
VectorDraw.prototype.getDefaultVector = function(coords) {
this.numberOfVectors += 1;
var name = "" + this.numberOfVectors,
description = "Vector " + name;
return {
name: name,
description: description,
coords: coords,
type: "vector",
render: false,
length_factor: 1,
length_units: "",
base_angle: 0,
style: {
pointSize: 1,
pointColor: "red",
width: 4,
color: "blue",
label: null,
labelColor: "black"
}
};
};
VectorDraw.prototype.onBoardDown = function(evt) {
var coords = this.getMouseCoords(evt);
var targetObjects = this.objectsUnderMouse(coords);
if (!targetObjects || _.all(targetObjects, this.canCreateVectorOnTopOf.bind(this))) {
// Add vector to board
var point_coords = [coords.usrCoords[1], coords.usrCoords[2]];
var defaultVector = this.getDefaultVector([point_coords, point_coords]);
this.settings.vectors.push(defaultVector);
var lastIndex = this.numberOfVectors - 1;
this.drawMode = true;
this.dragged_vector = this.renderVector(lastIndex);
}
else {
// Move existing vector around
this.drawMode = false;
var vectorPoint = _.find(targetObjects, this.getVectorForObject.bind(this));
if (vectorPoint) {
this.dragged_vector = this.getVectorForObject(vectorPoint);
this.dragged_vector.point1.setProperty({fixed: false});
}
}
};
VectorDraw.prototype.onBoardMove = function(evt) {
if (this.drawMode) {
var coords = this.getMouseCoords(evt);
this.dragged_vector.point2.moveTo(coords.usrCoords);
}
};
VectorDraw.prototype.onBoardUp = function(evt) {
if (!this.wasUsed) {
this.wasUsed = true;
}
this.drawMode = false;
if (this.dragged_vector && !this.isVectorTailDraggable(this.dragged_vector)) {
this.dragged_vector.point1.setProperty({fixed: true});
}
this.dragged_vector = null;
};
VectorDraw.prototype.getVectorCoords = function(name) {
var object = this.board.elementsByName[name];
return {
tail: [object.point1.X(), object.point1.Y()],
tip: [object.point2.X(), object.point2.Y()]
};
};
VectorDraw.prototype.getState = function() {
var vectors = [];
this.settings.vectors.forEach(function(vec) {
var coords = this.getVectorCoords(vec.name),
tail = coords.tail,
tip = coords.tip,
x1 = tail[0],
y1 = tail[1],
x2 = tip[0],
y2 = tip[1];
// Update coordinates
vec.coords = [tail, tip];
vec.tail = tail;
vec.tip = tip;
// Update length, angle
vec.length = Math.sqrt(Math.pow(x2-x1, 2) + Math.pow(y2-y1, 2));;
// Update angle
vec.angle = ((Math.atan2(y2-y1, x2-x1)/Math.PI*180)) % 360;
vectors.push(vec);
}, this);
return vectors;
};
// Initialization logic
// Initialize functionality for non-WYSIWYG editing functionality
var fieldEditor = StudioEditableXBlockMixin(runtime, element);
// Initialize WYSIWYG editor
var vectordraw = new VectorDraw('vectordraw', init_args.settings);
// Set up click handlers
$('.save-button', element).on('click', function(e) {
e.preventDefault();
var data = {};
if (vectordraw.wasUsed) {
var state = vectordraw.getState();
data = { vectors: state };
}
fieldEditor.save(data);
});
$('.cancel-button', element).on('click', function(e) {
e.preventDefault();
fieldEditor.cancel();
});
}
{% load i18n %}
<div class="editor-with-buttons">
<div class="wrapper-comp-settings is-active editor-with-buttons" id="settings-tab">
<ul class="list-input settings-list">
{% for field in fields %}
<li class="field comp-setting-entry metadata_entry {% if field.is_set %}is-set{% endif %}"
data-field-name="{{field.name}}"
data-default="{% if field.type == 'boolean' %}{{ field.default|yesno:'1,0' }}{% else %}{{ field.default|default_if_none:"" }}{% endif %}"
data-cast="{{field.type}}">
<div class="wrapper-comp-setting{% if field.type == "set" %} metadata-list-enum {%endif%}">
<label class="label setting-label" for="xb-field-edit-{{field.name}}">{{field.display_name}}</label>
{% if field.type == "boolean" %}
<select class="field-data-control"
id="xb-field-edit-{{field.name}}"
aria-describedby="{{field.name}}-help">
<option value="1" {% if field.value %}selected{% endif %}>
True {% if field.default %}&nbsp;&nbsp;&nbsp;&nbsp;(Default){% endif %}
</option>
<option value="0" {% if not field.value %}selected{% endif %}>
False {% if not field.default %}&nbsp;&nbsp;&nbsp;&nbsp;(Default){% endif %}
</option>
</select>
{% elif field.type == "string" %}
<input type="text"
class="field-data-control"
id="xb-field-edit-{{field.name}}"
value="{{field.value|default_if_none:""}}"
aria-describedby="{{field.name}}-help">
{% elif field.type == "integer" or field.type == "float" %}
<input type="number"
class="field-data-control"
id="xb-field-edit-{{field.name}}"
{% if field.step %} step="{{field.step}}" {% elif field.type == "integer" %} step=1 {% endif %}
{% if field.max %} max="{{field.max}}" {% endif %}
{% if field.min %} min="{{field.min}}" {% endif %}
value="{{field.value|default_if_none:""}}"
aria-describedby="{{field.name}}-help">
{% elif field.type == "text" or field.type == "html" %}
<textarea class="field-data-control"
data-field-name="{{field.name}}"
id="xb-field-edit-{{field.name}}"
aria-describedby="{{field.name}}-help"
rows=10 cols=70>{{field.value}}</textarea>
{% else %}
Unsupported field type. This setting cannot be edited.
{% endif %}
{% if field.allow_reset %}
<button class="action setting-clear {% if field.is_set %}active{%else%}inactive{% endif %}"
type="button"
name="setting-clear"
value="{% trans "Clear" %}"
data-tooltip="{% trans "Clear" %}">
<i class="icon fa fa-undo"></i><span class="sr">{% trans "Clear Value" %}</span>
</button>
{% endif %}
</div>
{% if field.help %}
<span id="{{field.name}}-help" class="tip setting-help"> {{ field.help }} </span>
{% endif %}
</li>
{% endfor %}
<li>
<!-- WYSIWYG editor -->
<div class="vectordraw_edit_block">
<h2 aria-describedby="wysiwyg-description">WYSIWYG Editor</h2>
<p id="wysiwyg-description">
{% blocktrans %}
Use the board below to define a set of working vectors for this exercise.
To add a vector, left-click the board where you want the vector to originate.
Keep holding down the left mouse button and drag your mouse pointer across the board
to achieve the desired length and angle for the vector.
To modify an existing vector, left-click it, hold down the left mouse button,
and move your mouse pointer across the board.
{% endblocktrans %}
</p>
<div id="vectordraw">
<div class="jxgboard"
style="width: {{ self.width }}px; height: {{ self.height }}px;"
tabindex="0">
</div>
</div>
</div>
</li>
</ul>
</div>
<div class="xblock-actions">
<ul>
<li class="action-item">
<a href="#" class="button action-primary save-button">{% trans "Save" %}</a>
</li>
<li class="action-item">
<a href="#" class="button cancel-button">{% trans "Cancel" %}</a>
</li>
</ul>
</div>
</div>
...@@ -138,7 +138,9 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -138,7 +138,9 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock):
help=( help=(
"List of vectors to use for the exercise. " "List of vectors to use for the exercise. "
"You must specify it as an array of entries " "You must specify it as an array of entries "
"where each entry represents an individual vector." "where each entry represents an individual vector. "
"Note that edits to vectors made via the WYSIWYG editor below "
"take precedence over changes you make here when saving."
), ),
default="[]", default="[]",
multiline_editor=True, multiline_editor=True,
...@@ -384,6 +386,39 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -384,6 +386,39 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock):
) )
return fragment return fragment
def studio_view(self, context):
fragment = Fragment()
context = {'fields': [], 'self': self}
# Build a list of all the fields that can be edited:
for field_name in self.editable_fields:
field = self.fields[field_name]
assert field.scope in (Scope.content, Scope.settings), (
"Only Scope.content or Scope.settings fields can be used with "
"StudioEditableXBlockMixin. Other scopes are for user-specific data and are "
"not generally created/configured by content authors in Studio."
)
field_info = self._make_field_info(field_name, field)
if field_info is not None:
context["fields"].append(field_info)
fragment.add_content(loader.render_template("templates/html/vectordraw_edit.html", context))
# Add resources to studio_view fragment
fragment.add_css_url(
self.runtime.local_resource_url(self, 'public/css/vectordraw_edit.css')
)
fragment.add_javascript_url(
"//cdnjs.cloudflare.com/ajax/libs/jsxgraph/0.98/jsxgraphcore.js"
)
fragment.add_javascript_url(
self.runtime.local_resource_url(self, 'public/js/studio_edit.js')
)
fragment.add_javascript_url(
self.runtime.local_resource_url(self, 'public/js/vectordraw_edit.js')
)
fragment.initialize_js(
'VectorDrawXBlockEdit', {"settings": self.settings}
)
return fragment
def _validate_check_answer_data(self, data): # pylint: disable=no-self-use def _validate_check_answer_data(self, data): # pylint: disable=no-self-use
""" """
Validate answer data submitted by user. Validate answer data submitted by user.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment