Permalink
Browse files

Refactored to move all active components into the activity and out of…

… the view. Now auto-updates the contents of the text area on window resize after a delay, fixed a bug in the link rendering code which was interpreting a string as an int (incorrectly)
  • Loading branch information...
1 parent 16f3542 commit f5955b4078ddfe19a5406b058655259b70ab027d @tomoinn tomoinn committed Apr 13, 2012
Showing with 283 additions and 311 deletions.
  1. +191 −70 src/public/js/activities/readTextActivity.js
  2. +92 −241 src/public/js/views/textView.js
View
261 src/public/js/activities/readTextActivity.js
@@ -1,21 +1,174 @@
-define([ 'jquery', 'underscore', 'backbone', 'textus', 'views/textView' ],
- function($, _, Backbone, textus, TextView) {
+define([ 'jquery', 'underscore', 'backbone', 'textus', 'views/textView' ], function($, _, Backbone, textus, TextView) {
- return function(models) {
+ return function(models) {
- var presenter = {
+ /**
+ * Called when populating the model, retrieves a single extent of text along with its
+ * typographical and semantic annotations.
+ */
+ var retrieveText = function(offset, length, callback) {
+ console.log("Retrieving " + length + " characters of text from " + offset);
+ $.getJSON("api/text/textid/" + offset + "/" + (offset + length), function(data) {
+ callback(data);
+ });
+ };
+ /**
+ * The maximum number of character to retrieve in a single request from the text service
+ * when populating the text container. This must be a power of two - we don't check for this
+ * later on and setting it to anything else will cause confusion.
+ */
+ var textChunkSize = 2048;
+
+ /**
+ * Updates models.textModel with the newly retrieved text and annotations.
+ *
+ * @param offset
+ * The character offset to start pulling text
+ * @param forwards
+ * If true treat the offset value as the first character in the resultant text,
+ * otherwise treat it as the character beyond the final character in the
+ * resultant text.
+ * @param height
+ * Target height to fill to, relative to the value returned by the measure
+ * function.
+ * @param measure
+ * A function used to determine the height of a block of retrieved text and
+ * annotations. Accepts HTML as its single argument and returns the pixel height
+ * of the result.
+ */
+ var updateTextAsync = function(offset, forwards, height, measure) {
+
+ var textBoundaryReached = false;
+
+ var markupStruct = function(struct) {
+ return textus.markupText(struct.text, struct.offset, struct.typography, struct.semantics);
+ };
+
+ /* Struct is {offset:int, text:string, typography:[], semantics:[]}} */
+ var fetch = function(struct) {
+ if (measure(markupStruct(struct)) > height || textBoundaryReached) {
+ trim(struct);
+ } else {
+ if (forwards) {
+ retrieveText(struct.offset + struct.text.length, textChunkSize, function(data) {
+ if (data.text.length < textChunkSize) {
+ textBoundaryReached = true;
+ }
+ if (struct.text == "") {
+ struct.typography = data.typography;
+ struct.semantics = data.semantics;
+ } else {
+ data.typography.forEach(function(annotation) {
+ if (annotation.start > offset + struct.text.length) {
+ struct.typography.push(annotation);
+ }
+ });
+ data.semantics.forEach(function(annotation) {
+ if (annotation.start > offset + struct.text.length) {
+ struct.semantics.push(annotation);
+ }
+ });
+ }
+ struct.text = struct.text + data.text;
+ fetch(struct);
+ });
+ } else {
+ var newOffset = Math.max(0, struct.offset - textChunkSize);
+ if (newOffset == 0) {
+ textBoundaryReached = true;
+ }
+ var sizeToFetch = struct.offset - newOffset;
+ retrieveText(newOffset, sizeToFetch, function(data) {
+ if (struct.text == "") {
+ struct.typography = data.typography;
+ struct.semantics = data.semantics;
+ } else {
+ data.typography.forEach(function(annotation) {
+ if (annotation.end < struct.offset) {
+ struct.typography.push(annotation);
+ }
+ });
+ data.semantics.forEach(function(annotation) {
+ if (annotation.end < struct.offset) {
+ struct.semantics.push(annotation);
+ }
+ });
+ }
+ struct.offset = newOffset;
+ struct.text = data.text + struct.text;
+ fetch(struct);
+ });
+ }
+ }
+ };
+
+ /**
+ * Trim the content of the input struct until the text exactly fits in the target
+ * container height. Do this by testing for a fit, and changing the start or end offset
+ * (depending on whether we're going forwards or backwards) by an amount which is
+ * progressively reduced each iteration.
+ */
+ var trim = function(data) {
+ var trimData = function(length) {
+ var amountRemoved = data.text.length - length;
+ return {
+ text : forwards ? (data.text.substring(0, length)) : (data.text
+ .substring(amountRemoved, length)),
+ offset : forwards ? (data.offset) : (data.offset - amountRemoved),
+ typography : data.typography,
+ semantics : data.semantics
+ };
+ };
+
+ var textLength = data.text.length - (textChunkSize - 1);
+ var i = textChunkSize;
+ while (i > 1) {
+ i = i / 2;
+ var test = trimData(textLength + i);
+ if (measure(markupStruct(test)) <= height) {
+ textLength = textLength + i;
+ }
+ }
+ var t = trimData(textLength);
+ var annotationFilter = function(a) {
+ return a.end >= t.offset && a.start <= (t.offset + t.text.length);
+ };
+ models.textModel.set({
+ text : t.text,
+ offset : t.offset,
+ typography : data.typography.filter(annotationFilter),
+ semantics : data.semantics.filter(annotationFilter)
+ });
+ };
+
+ fetch({
+ text : "",
+ offset : offset,
+ typography : [],
+ semantics : [],
+ cachedHTML : null
+ });
+
+ };
+
+ this.name = "ReadTextActivity";
+
+ this.start = function(location) {
+
+ // Create a new textView
+ var textView = new TextView({
+ textModel : models.textModel,
+ presenter : {
/**
- * Called by the view when a selection of text has been
- * made, used to set the text selection model.
+ * Called by the view when a selection of text has been made, used to set the
+ * text selection model.
*
* @param start
- * The absolute index of the first character in
- * the selected text.
+ * The absolute index of the first character in the selected text.
* @param end
- * The absolute index of the last character in
- * the selected text, should be start +
- * text.length assuming all's working.
+ * The absolute index of the last character in the selected text,
+ * should be start + text.length assuming all's working.
* @param text
* The text of the selection.
*/
@@ -28,67 +181,35 @@ define([ 'jquery', 'underscore', 'backbone', 'textus', 'views/textView' ],
},
/**
- * Called by the view when it needs to retrieve more text
- * and annotations to render, typically as a result of the
- * location changing or the view size being modified.
- *
- * @param offset
- * Absolute index of the first character of text
- * to retrieve
- * @param length
- * Length of desired text
- * @param callback
- * A callback function called with the retrieved
- * data in the form {text:string, offset:int,
- * typography:[], semantics:[]}
- *
- * @TODO - Currently a dummy implementation, should just
- * call a method on the server but for now this makes
- * testing without the server running much easier.
+ * Called by the view when it's been resized and needs to have its text
+ * re-filled.
*/
- retrieveText : function(offset, length, callback) {
- console.log("Retrieving "+length+" characters of text from "+offset);
- $.getJSON("api/text/textid/" + offset + "/"
- + (offset + length), function(data) {
- callback(data);
- });
+ requestTextFill : function() {
+ updateTextAsync(location.offset, true, textView.pageHeight(), textView.measure);
}
+ },
+ textLocationModel : models.textLocationModel,
+ el : $('.main')
+ });
- };
+ // Set up a listener on selection events on the text
+ // selection model
+ var s = models.textSelectionModel;
+ s.bind("change", function(event) {
+ if (s.get("text") != "") {
+ alert("Text selected '" + s.get("text") + "' character range [" + s.get("start") + ","
+ + s.get("end") + "]");
+ }
+ });
- this.name = "ReadTextActivity";
-
- this.start = function(location) {
-
- // Create a new textView
- var textView = new TextView({
- textModel : models.textModel,
- presenter : presenter,
- textLocationModel : models.textLocationModel,
- el : $('.main')
- });
-
- // Set up a listener on selection events on the text
- // selection model
- var s = models.textSelectionModel;
- s.bind("change", function(event) {
- if (s.get("text") != "") {
- alert("Text selected '" + s.get("text")
- + "' character range [" + s.get("start")
- + "," + s.get("end") + "]");
- }
- });
- textView.render();
- // Render it to the DOM, it'll be empty at this point
- // but this will set up the appropriate window
- // structure.
- textView.setTextLocation(location.offset);
- };
+ updateTextAsync(location.offset, true, textView.pageHeight(), textView.measure);
- this.stop = function(callback) {
- // Unbind the change listener on the text selection model
- models.textSelectionModel.unbind("change");
- callback(true);
- };
- };
- });
+ };
+
+ this.stop = function(callback) {
+ // Unbind the change listener on the text selection model
+ models.textSelectionModel.unbind("change");
+ callback(true);
+ };
+ };
+});
View
333 src/public/js/views/textView.js
@@ -16,6 +16,11 @@ define([ 'jquery', 'underscore', 'backbone', 'textus', 'text!templates/textView.
/**
* Create DIV elements in the annotation container corresponding to the supplied semantic
* annotations.
+ *
+ * @param semantics
+ * An array of semantic annotation objects to display in the container
+ * @param annotationContainer
+ * The div into which annotation object representations are to be injected
*/
var populateAnnotationContainer = function(semantics, annotationContainer) {
console.log("Populating annotation container");
@@ -38,6 +43,9 @@ define([ 'jquery', 'underscore', 'backbone', 'textus', 'text!templates/textView.
*
* @param canvas
* The CANVAS element to use when drawing in the annotation links
+ * @param textContainer
+ * The element containing the entire text area, used to set the canvas size
+ * appropriately.
* @param semantics
* The semantic annotations, which must have been updated with the 'anchor' property
* by the renderCanvas method prior to this being called.
@@ -93,6 +101,15 @@ define([ 'jquery', 'underscore', 'backbone', 'textus', 'text!templates/textView.
* that textContainer already contains the appropriate markup including the empty span elements
* indicating annotation start and end points. Updates the 'anchor' property of the annotation
* elements to be the coordinate to use when drawing lines to the divs.
+ *
+ * @param canvas
+ * The CANVAS element to use when drawing the annotation markers
+ * @param textContainer
+ * The element containing the entire text area, used to set the canvas size
+ * appropriately.
+ * @param semantics
+ * The semantic annotations, calling this will update the 'anchor' property which
+ * then allows the renderLinks method to run correctly.
*/
var renderCanvas = function(canvas, textContainer, semantics) {
var width = textContainer.outerWidth(true);
@@ -112,7 +129,7 @@ define([ 'jquery', 'underscore', 'backbone', 'textus', 'text!templates/textView.
var regionList = [];
$(".textus-annotation-start").each(function() {
var coords = relativeCoords(canvas, $(this));
- var lineHeight = $(this).css("line-height").match(/\d+/)[0];
+ var lineHeight = parseInt($(this).css("line-height").match(/\d+/)[0]);
var id = $(this).attr("annotation-id");
// If we're right on the end of the line move the start coordinates to the following
// line
@@ -124,7 +141,7 @@ define([ 'jquery', 'underscore', 'backbone', 'textus', 'text!templates/textView.
id : id,
startx : coords.x,
starty : coords.y,
- startlh : parseInt(lineHeight)
+ startlh :lineHeight
};
});
$(".textus-annotation-end").each(function() {
@@ -200,258 +217,93 @@ define([ 'jquery', 'underscore', 'backbone', 'textus', 'text!templates/textView.
});
};
- /**
- * Populate the text area from the model
- */
- var renderText = function(canvas, textContainer, model) {
- if (model.cachedHTML == null) {
- model.set({
- cachedHTML : textus.markupText(model.get("text"), model.get("offset"), model.get("typography"), model
- .get("semantics"))
- });
- }
- textContainer.html(model.get("cachedHTML"));
- populateAnnotationContainer(model.get("semantics"), $('.annotations'));
- renderCanvas(canvas, textContainer, model.get("semantics"));
- renderLinks(textContainer, $('#linkCanvas'), model.get("semantics"), $('.annotations'));
- };
-
- /**
- * Retrieve and test-render text to at least fill a particular height and width container,
- * calling the supplied callback with a structure containing the text, offset, semantic and
- * typographical annotations and cached HTML
- *
- * @param width
- * The target width of the container in pixels - the container's width will be
- * explicitly set to this value.
- * @param height
- * The target height of the container in pixels - text will be loaded into the
- * container to get to this height or more. The text will almost certainly then need
- * to be trimmed to the appropriate height.
- * @param offset
- * The offset within the source text from which to load data. This offset can be
- * either the first character in the stream (if forwards==true) or one beyond the
- * final character if (forwards==false)
- * @param presenter
- * The presenter to be used to retrieve text from the server, provides the
- * retrieveText(offset, size, callback) method.
- * @param forwards
- * Whether the offset should be interpreted as the start or end of the text.
- * @param callback
- * Called on completion and passed a data structure containing the fetched text which
- * will at least fill the specified dimensions along with annotations. The structure
- * is of the form: {text:string, offset:int, typography:[], semantics:[],
- * cachedHTML:string}
- */
- var fillContainer = function(width, height, offset, presenter, forwards, callback) {
- console.log("Attempting to fill container with height " + height);
- // Set up the test container
- var test = $('.pageTextMeasure');
- test.html("");
- test.width(width);
- var textBoundaryReached = false;
- var heightExceeded = false;
- var stepSize = 2048;
-
- /**
- * Fetch text from the presenter until it at least fills the text container to the target
- * height when rendered. Retrieve text in chunks of stepSize until we have enough.
- */
- var fetch = function(struct) {
- /*
- * Initially render the text in the struct to the test container, and see whether it
- * overflows.If it does then return the current state via the callback. We also return
- * if the last retrieval operation hit the boundary of the underlying text source,
- * either at the start when going backwards or at the end when going forwards.
- */
- test.html(textus.markupText(struct.text, struct.offset, struct.typography, []));
- heightExceeded = (test.height() > height);
- if (heightExceeded || textBoundaryReached) {
- test.html("");
- console.log(struct);
- trim(struct);
- }
- /*
- * If there wasn't enough text to over-fill the container then we need to fetch more.
- */
- else {
- if (forwards) {
- presenter.retrieveText(struct.offset + struct.text.length, stepSize, function(data) {
- if (data.text.length < stepSize) {
- textBoundaryReached = true;
- }
- if (struct.text == "") {
- struct.typography = data.typography;
- struct.semantics = data.semantics;
- } else {
- data.typography.forEach(function(annotation) {
- if (annotation.start > offset + struct.text.length) {
- struct.typography.push(annotation);
- }
- });
- data.semantics.forEach(function(annotation) {
- if (annotation.start > offset + struct.text.length) {
- struct.semantics.push(annotation);
- }
- });
- }
- struct.text = struct.text + data.text;
- fetch(struct);
- });
- } else {
- var newOffset = Math.max(0, struct.offset - stepSize);
- if (newOffset == 0) {
- textBoundaryReached = true;
- }
- var sizeToFetch = struct.offset - newOffset;
- presenter.retrieveText(newOffset, sizeToFetch, function(data) {
- if (struct.text == "") {
- struct.typography = data.typography;
- struct.semantics = data.semantics;
- } else {
- data.typography.forEach(function(annotation) {
- if (annotation.end < struct.offset) {
- struct.typography.push(annotation);
- }
- });
- data.semantics.forEach(function(annotation) {
- if (annotation.end < struct.offset) {
- struct.semantics.push(annotation);
- }
- });
- }
- struct.offset = newOffset;
- struct.text = data.text + struct.text;
- fetch(struct);
- });
- }
- }
- };
-
- /**
- * Trim the content of the input struct until the text exactly fits in the target container
- * height. Do this by testing for a fit, and changing the start or end offset (depending on
- * whether we're going forwards or backwards) by an amount which is progressively reduced
- * each iteration.
- */
- var trim = function(data) {
- var trimData = function(length) {
- var amountRemoved = data.text.length - length;
- return {
- text : forwards ? (data.text.substring(0, length)) : (data.text.substring(amountRemoved, length)),
- offset : forwards ? (data.offset) : (data.offset - amountRemoved)
- };
- };
-
- var textLength = data.text.length - (stepSize - 1);
- var i = stepSize;
- while (i > 1) {
- i = i / 2;
- // 'i' will range from stepsize/2 to 1 then terminate
- var testData = trimData(textLength + i);
- test.html(textus.markupText(testData.text, testData.offset, data.typography, data.semantics));
- if (test.height() <= height) {
- textLength = textLength + i;
- }
- }
- var trimmed = trimData(textLength);
- var annotationFilter = function(annotation) {
- console.log(annotation);
- console.log(trimmed.offset + ", " + (trimmed.offset + trimmed.text.length));
- return annotation.end >= trimmed.offset && annotation.start <= (trimmed.offset + trimmed.text.length);
- };
- trimmed.typography = data.typography.filter(annotationFilter);
- trimmed.semantics = data.semantics.filter(annotationFilter);
- test.html("");
- callback(trimmed);
- };
-
- fetch({
- text : "",
- offset : offset,
- typography : [],
- semantics : [],
- cachedHTML : null
- }, 1024);
-
- };
-
- /**
- * Calculate the offset of the current node relative to its parent, where the offset is the sum
- * of the lengths of all preceding text nodes not including the current one. This is needed
- * because we want to get the number of characters in text nodes between the selection end
- * points and the start of the child list of the parent to cope with the additional spans
- * inserted to mark semantic annotations.
- */
- var offsetInParent = function(currentNode) {
- var count = 0;
- var node = currentNode.previousSibling;
- while (node != null) {
- if (node.nodeType == 3) {
- count = count + node.length;
- }
- node = node.previousSibling;
- }
- return count;
- };
-
var TextView = Backbone.View.extend({
- render : function(event) {
- this.$el.html(layout).unbind("mouseup").bind("mouseup", this.defineSelection);
- var model = this.model;
- var presenter = this.presenter;
- if (model) {
- renderText($('#pageCanvas'), $('.pageText'), model, presenter);
-
- }
- $('.annotations').scroll(function(event) {
- renderLinks($('.pageText'), $('#linkCanvas'), model.get("semantics"), $('.annotations'));
- });
- },
-
initialize : function() {
_.bindAll(this);
- this.model = this.options.textModel;
- this.presenter = this.options.presenter;
+ var model = this.model = this.options.textModel;
+ var presenter = this.presenter = this.options.presenter;
this.selectionModel = this.options.selectionModel;
- var model = this.model;
+ this.$el.html(layout).unbind("mouseup").bind("mouseup", this.defineSelection);
+
+ var pageCanvas = $('#pageCanvas');
+ var linkCanvas = $('#linkCanvas');
+ var pageText = $('.pageText');
+ var annotations = $('.annotations');
+
+ var resizeTimer = null;
+
$(window).resize(function() {
- renderCanvas($('#pageCanvas'), $('.pageText'), model.get("semantics"));
- renderLinks($('.pageText'), $('#linkCanvas'), model.get("semantics"), $('.annotations'));
+ if (resizeTimer) {
+ clearTimeout(resizeTimer);
+ }
+ resizeTimer = setTimeout(presenter.requestTextFill, 300);
+ renderCanvas(pageCanvas, pageText, model.get("semantics"));
+ renderLinks(pageText, linkCanvas, model.get("semantics"), annotations);
+ });
+ annotations.scroll(function() {
+ renderLinks(pageText, linkCanvas, model.get("semantics"), annotations);
+ });
+ model.bind("change:text", function() {
+ model.set({
+ cachedHTML : textus.markupText(model.get("text"), model.get("offset"), model.get("typography"),
+ model.get("semantics"))
+ });
+ pageText.html(model.get("cachedHTML"));
+ });
+ model.bind("change:semantics", function() {
+ populateAnnotationContainer(model.get("semantics"), annotations);
+ renderCanvas(pageCanvas, pageText, model.get("semantics"));
+ renderLinks(pageText, linkCanvas, model.get("semantics"), annotations);
});
-
},
/**
- * Set the location, fetching the text, updating the model and re-rendering
+ * Populates the test container with the specified HTML and returns its height in pixels.
*/
- setTextLocation : function(offset) {
- var model = this.model;
- // fillContainer = function(width, height, offset, presenter, callback) {
- console.log($('.pageText').width() + "," + $('.pageText').height());
- console.log("fillContainer(" + $('.pageText').width() + "," + 400 + "," + offset + ")");
- fillContainer($('.pageText').width(), $('.pageText').height(), offset, this.presenter, true,
- function(data) {
- console.log("Received data from trim function");
- console.log(data);
- model.set({
- cachedHTML : data.cachedHTML,
- text : data.text,
- offset : data.offset,
- typography : data.typography,
- semantics : data.semantics
- });
- renderText($('#pageCanvas'), $('.pageText'), model);
- });
+ measure : function(html) {
+ var targetWidth = $('.pageText').width();
+ var testContainer = $('.pageTextMeasure');
+ testContainer.width(targetWidth);
+ testContainer.html(html);
+ var height = testContainer.height();
+ testContainer.html("");
+ return height;
+ },
+
+ pageHeight : function() {
+ return $('.pageText').height();
},
- // Attempt to get the selected text range, after
- // trimming any markup
+ /**
+ * Handle text selection, pulls the current selection out and calls the presenter with it if
+ * possible.
+ */
defineSelection : function() {
- var userSelection = "No selection defined!";
+ /*
+ * Calculate the offset of the current node relative to its parent, where the offset is
+ * the sum of the lengths of all preceding text nodes not including the current one.
+ * This is needed because we want to get the number of characters in text nodes between
+ * the selection end points and the start of the child list of the parent to cope with
+ * the additional spans inserted to mark semantic annotations.
+ */
+ var offsetInParent = function(currentNode) {
+ var count = 0;
+ var node = currentNode.previousSibling;
+ while (node != null) {
+ if (node.nodeType == 3) {
+ count = count + node.length;
+ }
+ node = node.previousSibling;
+ }
+ return count;
+ };
+ /*
+ * Only currently supporting the non-IE text range objects, this works fine for the
+ * browsers we actually support!
+ */
if (window.getSelection && this.presenter && this.model) {
- userSelection = window.getSelection();
+ var userSelection = window.getSelection();
var fromNode = userSelection.anchorNode;
var toNode = userSelection.focusNode;
if (fromNode != null && toNode != null) {
@@ -464,8 +316,7 @@ define([ 'jquery', 'underscore', 'backbone', 'textus', 'text!templates/textView.
+ this.model.get("offset"), this.model.get("text").substring(fromChar, toChar));
}
} else if (document.selection) {
- console.log("Fetching MS Text Range object (IE).");
- userSelection = document.selection.createRange();
+ console.log("MS Text Range not supported!");
}
}

0 comments on commit f5955b4

Please sign in to comment.