brat-client
Version:
Client from brat rapid annotation tool
1,207 lines (1,127 loc) • 113 kB
JavaScript
// -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; -*-
// vim:set ft=javascript ts=2 sw=2 sts=2 cindent:
var AnnotatorUI = (function($, window, undefined) {
var AnnotatorUI = function(dispatcher, svg) {
var that = this;
var arcDragOrigin = null;
var arcDragOriginBox = null;
var arcDragOriginGroup = null;
var arcDragArc = null;
var arcDragJustStarted = false;
var sourceData = null;
var data = null;
var searchConfig = null;
var spanOptions = null;
var lockOptions = null;
var rapidSpanOptions = null;
var arcOptions = null;
var spanKeymap = null;
var keymap = null;
var coll = null;
var doc = null;
var reselectedSpan = null;
var selectedFragment = null;
var editedSpan = null;
var editedFragment = null;
var repeatingArcTypes = [];
var spanTypes = null;
var entityAttributeTypes = null;
var eventAttributeTypes = null;
var relationTypesHash = null;
var showValidAttributes; // callback function
var showValidNormalizations; // callback function
var dragStartedAt = null;
var selRect = null;
var lastStartRec = null;
var lastEndRec = null;
var inForm = false;
var draggedArcHeight = 30;
var maxNormSearchHistory = 10;
// TODO: this is an ugly hack, remove (see comment with assignment)
var lastRapidAnnotationEvent = null;
// TODO: another avoidable global; try to work without
var rapidAnnotationDialogVisible = false;
// amount by which to lighten (adjust "L" in HSL space) span
// colors for type selection box BG display. 0=no lightening,
// 1=white BG (no color)
var spanBoxTextBgColorLighten = 0.4;
// for double-click selection simulation hack
var lastDoubleClickedChunkId = null;
// for normalization: URLs bases by norm DB name
var normDbUrlByDbName = {};
var normDbUrlBaseByDbName = {};
// for normalization: appropriate DBs per type
var normDbsByType = {};
// for normalization
var oldSpanNormIdValue = '';
var lastNormSearches = [];
that.user = null;
var svgElement = $(svg._svg);
var svgId = svgElement.parent().attr('id');
var arcTargets = [];
var arcTargetRects;
var stripNumericSuffix = function(s) {
// utility function, originally for stripping numerix suffixes
// from arc types (e.g. "Theme2" -> "Theme"). For values
// without suffixes (including non-strings), returns given value.
if (typeof(s) != "string") {
return s; // can't strip
}
var m = s.match(/^(.*?)(\d*)$/);
return m[1]; // always matches
}
var showForm = function() {
inForm = true;
};
var hideForm = function() {
inForm = false;
keymap = null;
rapidAnnotationDialogVisible = false;
};
var clearSelection = function() {
window.getSelection().removeAllRanges();
if (selRect != null) {
for(var s=0; s != selRect.length; s++) {
selRect[s].parentNode.removeChild(selRect[s]);
}
selRect = null;
lastStartRec = null;
lastEndRec = null;
}
};
var makeSelRect = function(rx, ry, rw, rh, col) {
var selRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
selRect.setAttributeNS(null, "width", rw);
selRect.setAttributeNS(null, "height", rh);
selRect.setAttributeNS(null, "x", rx);
selRect.setAttributeNS(null, "y", ry);
selRect.setAttributeNS(null, "fill", col == undefined ? "lightblue" : col);
return selRect;
};
var onKeyDown = function(evt) {
var code = evt.which;
if (code === $.ui.keyCode.ESCAPE) {
setTypeLock(false);
stopArcDrag();
hideForm();
if (reselectedSpan) {
$(reselectedSpan.rect).removeClass('reselect');
reselectedSpan = null;
svgElement.removeClass('reselect');
}
return;
}
// in rapid annotation mode, prioritize the keys 0..9 for the
// ordered choices in the quick annotation dialog.
if (Configuration.rapidModeOn && rapidAnnotationDialogVisible &&
"0".charCodeAt() <= code && code <= "9".charCodeAt()) {
var idx = String.fromCharCode(code);
var $input = $('#rapid_span_'+idx);
if ($input.length) {
$input.click();
}
}
if (!inForm && code == $.ui.keyCode.ENTER) {
evt.preventDefault();
tryToAnnotate(evt);
return;
}
if (!keymap) return;
// disable shortcuts when working with elements that you could
// conceivably type in
var target = evt.target;
var nodeName = target.nodeName.toLowerCase();
var nodeType = target.type && target.type.toLowerCase();
if (nodeName == 'input' && (nodeType == 'text' || nodeType == 'password')) return;
if (nodeName == 'textarea' || nodeName == 'select') return;
var prefix = '';
if (evt.altKey) {
prefix = "A-";
}
if (Util.isMac ? evt.metaKey : evt.ctrlKey) {
prefix = "C-";
}
if (evt.shiftKey) {
prefix = "S-";
}
var binding = keymap[prefix + code];
if (!binding) binding = keymap[prefix + String.fromCharCode(code)];
if (binding) {
var boundInput = $('#' + binding)[0];
if (boundInput && !boundInput.disabled) {
boundInput.click();
evt.preventDefault();
return false;
}
}
};
var onDblClick = function(evt) {
// must be logged in
if (that.user === null) return;
// must not be reselecting a span or an arc
if (reselectedSpan || arcDragOrigin) return;
var target = $(evt.target);
var id;
// do we edit an arc?
if (id = target.attr('data-arc-role')) {
// TODO
clearSelection();
var originSpanId = target.attr('data-arc-origin');
var targetSpanId = target.attr('data-arc-target');
var type = target.attr('data-arc-role');
var originSpan = data.spans[originSpanId];
var targetSpan = data.spans[targetSpanId];
arcOptions = {
action: 'createArc',
origin: originSpanId,
target: targetSpanId,
old_target: targetSpanId,
type: type,
old_type: type,
collection: coll,
'document': doc
};
var eventDescId = target.attr('data-arc-ed');
if (eventDescId) {
var eventDesc = data.eventDescs[eventDescId];
arcOptions.id = eventDescId;
arcOptions.comment = eventDesc.comment && eventDesc.comment.text;
if (eventDesc.equiv) {
arcOptions['left'] = eventDesc.leftSpans.join(',');
arcOptions['right'] = eventDesc.rightSpans.join(',');
}
} else {
arcOptions.id = originSpanId + "~" + type + "~" + targetSpanId;
}
$('#arc_origin').text(Util.spanDisplayForm(spanTypes, originSpan.type) + ' ("' + originSpan.text + '")');
$('#arc_target').text(Util.spanDisplayForm(spanTypes, targetSpan.type) + ' ("' + targetSpan.text + '")');
var arcId = eventDescId || [originSpanId, type, targetSpanId];
fillArcTypesAndDisplayForm(evt, originSpan.type, targetSpan.type, type, arcId);
// for precise timing, log dialog display to user.
dispatcher.post('logAction', ['arcEditSelected']);
// if not an arc, then do we edit a span?
} else if (id = target.attr('data-span-id')) {
clearSelection();
editedSpan = data.spans[id];
editedFragment = target.attr('data-fragment-id');
// XXX we went from collecting fragment offsets to copying
// them from data. Does anything break?
spanOptions = {
action: 'createSpan',
offsets: editedSpan.unsegmentedOffsets,
type: editedSpan.type,
id: id,
};
if (lockOptions) {
spanFormSubmit();
dispatcher.post('logAction', ['spanLockEditSubmitted']);
} else {
fillSpanTypesAndDisplayForm(evt, editedSpan.text, editedSpan);
// for precise timing, log annotation display to user.
dispatcher.post('logAction', ['spanEditSelected']);
}
}
// if not an arc or a span, is this a double-click on text?
else if (id = target.attr('data-chunk-id')) {
// remember what was clicked (this is in preparation for
// simulating double-click selection on browsers that do
// not support it.
lastDoubleClickedChunkId = id;
}
};
var startArcDrag = function(originId) {
if (reselectedSpan) return;
clearSelection();
svgPosition = svgElement.offset();
svgElement.addClass('unselectable');
arcDragOrigin = originId;
arcDragOriginGroup = $(data.spans[arcDragOrigin].group);
arcDragOriginGroup.addClass('highlight');
var headFragment = data.spans[arcDragOrigin].headFragment;
var chunk = headFragment.chunk;
var fragBox = headFragment.rectBox;
arcDragOriginBox = {
x: fragBox.x + chunk.translation.x,
y: fragBox.y + chunk.row.translation.y,
height: fragBox.height,
width: fragBox.width,
center: fragBox.x + chunk.translation.x + fragBox.width / 2,
};
arcDragJustStarted = true;
};
var getValidArcTypesForDrag = function(targetId, targetType) {
var arcType = stripNumericSuffix(arcOptions && arcOptions.type);
if (!arcDragOrigin || targetId == arcDragOrigin) return null;
var originType = data.spans[arcDragOrigin].type;
var spanType = spanTypes[originType];
var result = [];
if (spanType && spanType.arcs) {
$.each(spanType.arcs, function(arcNo, arc) {
if (arcType && arcType != arc.type) return;
if ($.inArray(targetType, arc.targets) != -1) {
result.push(arc.type);
}
});
}
return result;
};
var onMouseDown = function(evt) {
dragStartedAt = evt; // XXX do we really need the whole evt?
if (!that.user || arcDragOrigin) return;
var target = $(evt.target);
var id;
// is it arc drag start?
if (id = target.attr('data-span-id')) {
arcOptions = null;
startArcDrag(id);
evt.stopPropagation();
evt.preventDefault();
return false;
}
};
var onMouseMove = function(evt) {
if (arcDragOrigin) {
if (arcDragJustStarted) {
arcDragArc.setAttribute('visibility', 'visible');
// show the possible targets
var span = data.spans[arcDragOrigin] || {};
var spanDesc = spanTypes[span.type] || {};
// separate out possible numeric suffix from type for highight
// (instead of e.g. "Theme3", need to look for "Theme")
var noNumArcType = stripNumericSuffix(arcOptions && arcOptions.type);
var targetTypes = [];
$.each(spanDesc.arcs || [], function(possibleArcNo, possibleArc) {
if ((arcOptions && possibleArc.type == noNumArcType) || !(arcOptions && arcOptions.old_target)) {
$.each(possibleArc.targets || [], function(possibleTargetNo, possibleTarget) {
targetTypes.push(possibleTarget);
});
}
});
arcTargets = [];
arcTargetRects = [];
$.each(data.spans, function(spanNo, span) {
if (span.id == arcDragOrigin) return;
if (targetTypes.indexOf(span.type) != -1) {
arcTargets.push(span.id);
$.each(span.fragments, function(fragmentNo, fragment) {
arcTargetRects.push(fragment.rect);
});
}
});
$(arcTargetRects).addClass('reselectTarget');
}
clearSelection();
var mx = evt.pageX - svgPosition.left;
var my = evt.pageY - svgPosition.top + 5; // TODO FIXME why +5?!?
var y = Math.min(arcDragOriginBox.y, my) - draggedArcHeight;
var dx = (arcDragOriginBox.center - mx) / 4;
var path = svg.createPath().
move(arcDragOriginBox.center, arcDragOriginBox.y).
curveC(arcDragOriginBox.center - dx, y,
mx + dx, y,
mx, my);
arcDragArc.setAttribute('d', path.path());
} else {
// A. Scerri FireFox chunk
// if not, then is it span selection? (ctrl key cancels)
var sel = window.getSelection();
var chunkIndexFrom = sel.anchorNode && $(sel.anchorNode.parentNode).attr('data-chunk-id');
var chunkIndexTo = sel.focusNode && $(sel.focusNode.parentNode).attr('data-chunk-id');
// fallback for firefox (at least):
// it's unclear why, but for firefox the anchor and focus
// node parents are always undefined, the the anchor and
// focus nodes themselves do (often) have the necessary
// chunk ID. However, anchor offsets are almost always
// wrong, so we'll just make a guess at what the user might
// be interested in tagging instead of using what's given.
var anchorOffset = null;
var focusOffset = null;
if (chunkIndexFrom === undefined && chunkIndexTo === undefined &&
$(sel.anchorNode).attr('data-chunk-id') &&
$(sel.focusNode).attr('data-chunk-id')) {
// Lets take the actual selection range and work with that
// Note for visual line up and more accurate positions a vertical offset of 8 and horizontal of 2 has been used!
var range = sel.getRangeAt(0);
var svgOffset = $(svg._svg).offset();
var flip = false;
var tries = 0;
// First try and match the start offset with a position, if not try it against the other end
while (tries < 2) {
var sp = svg._svg.createSVGPoint();
sp.x = (flip ? evt.pageX : dragStartedAt.pageX) - svgOffset.left;
sp.y = (flip ? evt.pageY : dragStartedAt.pageY) - (svgOffset.top + 8);
var startsAt = range.startContainer;
anchorOffset = startsAt.getCharNumAtPosition(sp);
chunkIndexFrom = startsAt && $(startsAt).attr('data-chunk-id');
if (anchorOffset != -1) {
break;
}
flip = true;
tries++;
}
// Now grab the end offset
sp.x = (flip ? dragStartedAt.pageX : evt.pageX) - svgOffset.left;
sp.y = (flip ? dragStartedAt.pageY : evt.pageY) - (svgOffset.top + 8);
var endsAt = range.endContainer;
focusOffset = endsAt.getCharNumAtPosition(sp);
// If we cannot get a start and end offset stop here
if (anchorOffset == -1 || focusOffset == -1) {
return;
}
// If we are in the same container it does the selection back to front when dragged right to left, across different containers the start is the start and the end if the end!
if(range.startContainer == range.endContainer && anchorOffset > focusOffset) {
var t = anchorOffset;
anchorOffset = focusOffset;
focusOffset = t;
flip = false;
}
chunkIndexTo = endsAt && $(endsAt).attr('data-chunk-id');
// Now take the start and end character rectangles
startRec = startsAt.getExtentOfChar(anchorOffset);
startRec.y += 2;
endRec = endsAt.getExtentOfChar(focusOffset);
endRec.y += 2;
// If nothing has changed then stop here
if (lastStartRec != null && lastStartRec.x == startRec.x && lastStartRec.y == startRec.y && lastEndRec != null && lastEndRec.x == endRec.x && lastEndRec.y == endRec.y) {
return;
}
if (selRect == null) {
var rx = startRec.x;
var ry = startRec.y;
var rw = (endRec.x + endRec.width) - startRec.x;
if (rw < 0) {
rx += rw;
rw = -rw;
}
var rh = Math.max(startRec.height, endRec.height);
selRect = new Array();
var activeSelRect = makeSelRect(rx, ry, rw, rh);
selRect.push(activeSelRect);
startsAt.parentNode.parentNode.parentNode.insertBefore(activeSelRect, startsAt.parentNode.parentNode);
} else {
if (startRec.x != lastStartRec.x && endRec.x != lastEndRec.x && (startRec.y != lastStartRec.y || endRec.y != lastEndRec.y)) {
if (startRec.y < lastStartRec.y) {
selRect[0].setAttributeNS(null, "width", lastStartRec.width);
lastEndRec = lastStartRec;
} else if (endRec.y > lastEndRec.y) {
selRect[selRect.length - 1].setAttributeNS(null, "x",
parseFloat(selRect[selRect.length - 1].getAttributeNS(null, "x"))
+ parseFloat(selRect[selRect.length - 1].getAttributeNS(null, "width"))
- lastEndRec.width);
selRect[selRect.length - 1].setAttributeNS(null, "width", 0);
lastStartRec=lastEndRec;
}
}
// Start has moved
var flip = !(startRec.x == lastStartRec.x && startRec.y == lastStartRec.y);
// If the height of the start or end changed we need to check whether
// to remove multi line highlights no longer needed if the user went back towards their start line
// and whether to create new ones if we moved to a newline
if (((endRec.y != lastEndRec.y)) || ((startRec.y != lastStartRec.y))) {
// First check if we have to remove the first highlights because we are moving towards the end on a different line
var ss = 0;
for (; ss != selRect.length; ss++) {
if (startRec.y <= parseFloat(selRect[ss].getAttributeNS(null, "y"))) {
break;
}
}
// Next check for any end highlights if we are moving towards the start on a different line
var es = selRect.length - 1;
for (; es != -1; es--) {
if (endRec.y >= parseFloat(selRect[es].getAttributeNS(null, "y"))) {
break;
}
}
// TODO put this in loops above, for efficiency the array slicing could be done separate still in single call
var trunc = false;
if (ss < selRect.length) {
for (var s2 = 0; s2 != ss; s2++) {
selRect[s2].parentNode.removeChild(selRect[s2]);
es--;
trunc = true;
}
selRect = selRect.slice(ss);
}
if (es > -1) {
for (var s2 = selRect.length - 1; s2 != es; s2--) {
selRect[s2].parentNode.removeChild(selRect[s2]);
trunc = true;
}
selRect = selRect.slice(0, es + 1);
}
// If we have truncated the highlights we need to readjust the last one
if (trunc) {
var activeSelRect = flip ? selRect[0] : selRect[selRect.length - 1];
if (flip) {
var rw = 0;
if (startRec.y == endRec.y) {
rw = (endRec.x + endRec.width) - startRec.x;
} else {
rw = (parseFloat(activeSelRect.getAttributeNS(null, "x"))
+ parseFloat(activeSelRect.getAttributeNS(null, "width")))
- startRec.x;
}
activeSelRect.setAttributeNS(null, "x", startRec.x);
activeSelRect.setAttributeNS(null, "y", startRec.y);
activeSelRect.setAttributeNS(null, "width", rw);
} else {
var rw = (endRec.x + endRec.width) - parseFloat(activeSelRect.getAttributeNS(null, "x"));
activeSelRect.setAttributeNS(null, "width", rw);
}
} else {
// We didnt truncate anything but we have moved to a new line so we need to create a new highlight
var lastSel = flip ? selRect[0] : selRect[selRect.length - 1];
var startBox = startsAt.parentNode.getBBox();
var endBox = endsAt.parentNode.getBBox();
if (flip) {
lastSel.setAttributeNS(null, "width",
(parseFloat(lastSel.getAttributeNS(null, "x"))
+ parseFloat(lastSel.getAttributeNS(null, "width")))
- endBox.x);
lastSel.setAttributeNS(null, "x", endBox.x);
} else {
lastSel.setAttributeNS(null, "width",
(startBox.x + startBox.width)
- parseFloat(lastSel.getAttributeNS(null, "x")));
}
var rx = 0;
var ry = 0;
var rw = 0;
var rh = 0;
if (flip) {
rx = startRec.x;
ry = startRec.y;
rw = $(svg._svg).width() - startRec.x;
rh = startRec.height;
} else {
rx = endBox.x;
ry = endRec.y;
rw = (endRec.x + endRec.width) - endBox.x;
rh = endRec.height;
}
var newRect = makeSelRect(rx, ry, rw, rh);
if (flip) {
selRect.unshift(newRect);
} else {
selRect.push(newRect);
}
// Place new highlight in appropriate slot in SVG graph
startsAt.parentNode.parentNode.parentNode.insertBefore(newRect, startsAt.parentNode.parentNode);
}
} else {
// The user simply moved left or right along the same line so just adjust the current highlight
var activeSelRect = flip ? selRect[0] : selRect[selRect.length - 1];
// If the start moved shift the highlight and adjust width
if (flip) {
var rw = (parseFloat(activeSelRect.getAttributeNS(null, "x"))
+ parseFloat(activeSelRect.getAttributeNS(null, "width")))
- startRec.x;
activeSelRect.setAttributeNS(null, "x", startRec.x);
activeSelRect.setAttributeNS(null, "y", startRec.y);
activeSelRect.setAttributeNS(null, "width", rw);
} else {
// If the end moved then simple change the width
var rw = (endRec.x + endRec.width)
- parseFloat(activeSelRect.getAttributeNS(null, "x"));
activeSelRect.setAttributeNS(null, "width", rw);
}
}
}
lastStartRec = startRec;
lastEndRec = endRec;
}
}
arcDragJustStarted = false;
};
var adjustToCursor = function(evt, element, centerX, centerY) {
var screenHeight = $(window).height() - 8; // TODO HACK - no idea why -8 is needed
var screenWidth = $(window).width() - 8;
var elementHeight = element.height();
var elementWidth = element.width();
var cssSettings = {};
var eLeft;
var eTop;
if (centerX) {
eLeft = evt.clientX - elementWidth/2;
} else {
eLeft = evt.clientX;
}
if (centerY) {
eTop = evt.clientY - elementHeight/2;
} else {
eTop = evt.clientY;
}
// Try to make sure the element doesn't go off-screen.
// If this isn't possible (the element is larger than the screen),
// alight top-left corner of screen and dialog as a compromise.
if (screenWidth > elementWidth) {
eLeft = Math.min(Math.max(eLeft,0), screenWidth - elementWidth);
} else {
eLeft = 0;
}
if (screenHeight > elementHeight) {
eTop = Math.min(Math.max(eTop,0), screenHeight - elementHeight);
} else {
eTop = 0;
}
element.css({ top: eTop, left: eLeft });
};
var updateCheckbox = function($input) {
var $widget = $input.button('widget');
var $textspan = $widget.find('.ui-button-text');
$textspan.html(($input[0].checked ? '☑ ' : '☐ ') + $widget.attr('data-bare'));
};
var fillSpanTypesAndDisplayForm = function(evt, spanText, span) {
keymap = spanKeymap;
// Figure out whether we should show or hide one of the two
// main halves of the selection frame (entities / events).
// This depends on the type of the current span, if any, and
// the availability of types to select.
var hideFrame;
if (span) {
// existing span; only show relevant half
if (span.generalType == 'entity') {
hideFrame = 'event';
} else {
hideFrame = 'entity';
}
spanForm.dialog('option', { title: 'Edit Annotation' });
} else {
// new span; show everything that's available
if ($('#event_types').find('input').length == 0) {
hideFrame = 'event';
} else if ($('#entity_types').find('input').length == 0) {
hideFrame = 'entity';
} else {
hideFrame = 'none';
}
spanForm.dialog('option', { title: 'New Annotation' });
}
if (hideFrame == 'event') {
$('#span_event_section').hide()
$('#span_entity_section').show().
removeClass('wrapper_half_left').
addClass('wrapper_full_width');
} else if (hideFrame == 'entity') {
$('#span_entity_section').hide()
$('#span_event_section').show().
removeClass('wrapper_half_right').
addClass('wrapper_full_width');
} else {
// show both entity and event halves
$('#span_entity_section').show().
removeClass('wrapper_full_width').
addClass('wrapper_half_left');
$('#span_event_section').show().
removeClass('wrapper_full_width').
addClass('wrapper_half_right');
}
// only show "delete" button if there's an existing annotation to delete
if (span) {
$('#del_span_button').show();
} else {
$('#del_span_button').hide();
}
$('#span_selected').text(spanText);
var encodedText = encodeURIComponent(spanText);
$.each(searchConfig, function(searchNo, search) {
$('#span_'+search[0]).attr('href', search[1].replace('%s', encodedText));
});
// enable all inputs by default (see setSpanTypeSelectability)
$('#span_form input:not([unused])').removeAttr('disabled');
// close span types if there's over typeCollapseLimit
if ($('#entity_types .item').length > Configuration.typeCollapseLimit) {
$('#entity_types .open').removeClass('open');
}
if ($('#event_types .item').length > Configuration.typeCollapseLimit) {
$('#event_types .open').removeClass('open');
}
var showAllAttributes = false;
if (span) {
var linkHash = new URLHash(coll, doc, { focus: [[span.id]] }).getHash();
var el = $('#span_' + span.type);
if (el.length) {
el[0].checked = true;
} else {
$('#span_form input:radio:checked').each(function (radioNo, radio) {
radio.checked = false;
});
}
// open the span type
$('#span_' + span.type).parents('.collapsible').each(function() {
toggleCollapsible($(this).parent().prev(), true);
});
// count the repeating arc types
var arcTypeCount = {};
repeatingArcTypes = [];
$.each(span.outgoing, function(arcNo, arc) {
// parse out possible number suffixes to allow e.g. splitting
// on "Theme" for args ("Theme1", "Theme2").
var splitArcType = arc.type.match(/^(.*?)(\d*)$/);
var noNumArcType = splitArcType[1];
if ((arcTypeCount[noNumArcType] = (arcTypeCount[noNumArcType] || 0) + 1) == 2) {
repeatingArcTypes.push(noNumArcType);
}
});
if (repeatingArcTypes.length) {
$('#span_form_split').show();
} else {
$('#span_form_split').hide();
}
} else {
var offsets = spanOptions.offsets[0];
var linkHash = new URLHash(coll, doc, { focus: [[offsets[0], offsets[1]]] }).getHash();
var firstRadio = $('#span_form input:radio:not([unused]):first')[0];
if (firstRadio) {
firstRadio.checked = true;
} else {
dispatcher.post('hideForm');
dispatcher.post('messages', [[['No valid span types defined', 'error']]]);
return;
}
$('#span_form_split').hide();
$('#span_notes').val('');
showAllAttributes = true;
}
$('#span_highlight_link').attr('href', linkHash);
if (span && !reselectedSpan) {
$('#span_form_reselect, #span_form_delete, #span_form_add_fragment').show();
keymap[$.ui.keyCode.DELETE] = 'span_form_delete';
keymap[$.ui.keyCode.INSERT] = 'span_form_reselect';
keymap['S-' + $.ui.keyCode.ENTER] = 'span_form_add_fragment';
$('#span_notes').val(span.annotatorNotes || '');
} else {
$('#span_form_reselect, #span_form_delete, #span_form_add_fragment').hide();
keymap[$.ui.keyCode.DELETE] = null;
keymap[$.ui.keyCode.INSERT] = null;
keymap['S-' + $.ui.keyCode.ENTER] = null;
}
if (span && !reselectedSpan && span.offsets.length > 1) {
$('#span_form_reselect_fragment, #span_form_delete_fragment').show();
keymap['S-' + $.ui.keyCode.DELETE] = 'span_form_delete_fragment';
keymap['S-' + $.ui.keyCode.INSERT] = 'span_form_reselect_fragment';
} else {
$('#span_form_reselect_fragment, #span_form_delete_fragment').hide();
keymap['S-' + $.ui.keyCode.DELETE] = null;
keymap['S-' + $.ui.keyCode.INSERT] = null;
}
// TODO: lots of redundancy in the next two blocks, clean up
if (!span) {
// no existing annotation, reset attributes
var attrCategoryAndTypes = [['entity', entityAttributeTypes],
['event', eventAttributeTypes]];
$.each(attrCategoryAndTypes, function(ctNo, ct) {
var category = ct[0];
var attributeTypes = ct[1];
$.each(attributeTypes, function(attrNo, attr) {
$input = $('#'+category+'_attr_'+Util.escapeQuotes(attr.type));
if (attr.unused) {
$input.val('');
} else if (attr.default) {
if (attr.bool) {
// take any non-empty default value as "true"
$input[0].checked = true;
updateCheckbox($input);
$input.button('refresh');
} else {
$input.val(attr.default).change();
}
} else if (attr.bool) {
$input[0].checked = false;
updateCheckbox($input);
$input.button('refresh');
} else {
$input.val('').change();
}
});
});
} else if (!reselectedSpan) {
// existing annotation, fill attribute values from span
var attributeTypes;
var category;
if (span.generalType == 'entity') {
attributeTypes = entityAttributeTypes;
category = 'entity';
} else if (span.generalType == 'trigger') {
attributeTypes = eventAttributeTypes;
// TODO: unify category/generalType values ('trigger' vs. 'event')
category = 'event';
} else {
console.error('Unrecognized generalType:', span.generalType);
}
$.each(attributeTypes, function(attrNo, attr) {
$input = $('#'+category+'_attr_'+Util.escapeQuotes(attr.type));
var val = span.attributes[attr.type];
if (attr.unused) {
$input.val(val || '');
} else if (attr.bool) {
$input[0].checked = val;
updateCheckbox($input);
$input.button('refresh');
} else {
$input.val(val || '').change();
}
});
}
var showValidNormalizationsFor = function(type) {
// set DB selector to the first appropriate for the type.
// TODO: actually disable inappropriate ones.
// TODO: support specific IDs, not just DB specifiers
var firstDb = type && normDbsByType[type] ? normDbsByType[type][0] : null;
if (firstDb) {
$('#span_norm_db').val(firstDb);
}
}
showValidNormalizations = function() {
// set norm DB selector according to the first selected type
var firstSelected = $('#entity_and_event_wrapper input:radio:checked')[0];
var selectedType = firstSelected ? firstSelected.value : null;
showValidNormalizationsFor(selectedType);
}
// fill normalizations (if any)
if (!reselectedSpan) {
// clear first
clearNormalizationUI();
var $normDb = $('#span_norm_db');
var $normId = $('#span_norm_id');
var $normText = $('#span_norm_txt');
// fill if found (NOTE: only shows last on multiple)
var normFilled = false;
$.each(span ? span.normalizations : [], function(normNo, norm) {
var refDb = norm[0], refId = norm[1], refText = norm[2];
$normDb.val(refDb);
// could the DB selector be set? (i.e. is refDb configured?)
if ($normDb.val() == refDb) {
// DB is OK, set the rest also
$normId.val(refId);
oldSpanNormIdValue = refId;
$normText.val(refText);
// TODO: check if ID is valid
$normId.addClass('valid_value')
normFilled = true;
} else {
// can't set the DB selector; assume DB is not configured,
// warn and leave blank (will remove norm when dialog is OK'd)
dispatcher.post('messages', [[['Warning: '+refDb+' not configured, removing normalization.', 'warning']]]);
}
});
// if there is no existing normalization, show valid ones
if (!normFilled) {
showValidNormalizations();
}
// update links
updateNormalizationRefLink();
updateNormalizationDbLink();
}
var showAttributesFor = function(attrTypes, category, type) {
var validAttrs = type ? spanTypes[type].attributes : [];
var shownCount = 0;
$.each(attrTypes, function(attrNo, attr) {
var $input = $('#'+category+'_attr_'+Util.escapeQuotes(attr.type));
var showAttr = showAllAttributes || $.inArray(attr.type, validAttrs) != -1;
if (showAttr) {
// $input.button('widget').parent().show();
$input.closest('.attribute_type_label').show();
shownCount++;
} else {
// $input.button('widget').parent().hide();
$input.closest('.attribute_type_label').hide();
}
});
return shownCount;
}
showValidAttributes = function() {
var type = $('#span_form input:radio:checked').val();
showAllAttributes = false;
var entityAttrCount = showAttributesFor(entityAttributeTypes, 'entity', type);
var eventAttrCount = showAttributesFor(eventAttributeTypes, 'event', type);
// show attribute frames only if at least one attribute is
// shown, and set size classes appropriately
if (eventAttrCount > 0) {
$('#event_attributes').show();
$('#event_attribute_label').show();
$('#event_types').
removeClass('scroll_wrapper_full').
addClass('scroll_wrapper_upper');
} else {
$('#event_attributes').hide();
$('#event_attribute_label').hide();
$('#event_types').
removeClass('scroll_wrapper_upper').
addClass('scroll_wrapper_full');
}
if (entityAttrCount > 0) {
$('#entity_attributes').show();
$('#entity_attribute_label').show();
$('#entity_types').
removeClass('scroll_wrapper_full').
addClass('scroll_wrapper_upper');
} else {
$('#entity_attributes').hide();
$('#entity_attribute_label').hide();
$('#entity_types').
removeClass('scroll_wrapper_upper').
addClass('scroll_wrapper_full');
}
}
showValidAttributes();
// TODO XXX: if seemed quite unexpected/unintuitive that the
// form was re-displayed while the document still shows the
// annotation in its old location in the background (check it).
// The fix of skipping confirm is not really good either, though.
if (reselectedSpan) { // && !Configuration.confirmModeOn) {
submitReselect();
} else {
dispatcher.post('showForm', [spanForm, true]);
$('#span_form-ok').focus();
adjustToCursor(evt, spanForm.parent());
}
};
var submitReselect = function() {
$(reselectedSpan.rect).removeClass('reselect');
reselectedSpan = null;
spanForm.submit();
};
var rapidFillSpanTypesAndDisplayForm = function(start, end, text, types) {
// variant of fillSpanTypesAndDisplayForm for rapid annotation mode
keymap = spanKeymap;
$('#rapid_span_selected').text(text);
// fill types
var $spanTypeDiv = $('#rapid_span_types_div');
// remove previously filled, if any
$spanTypeDiv.empty();
$.each(types, function(typeNo, typeAndProb) {
// TODO: this duplicates a part of addSpanTypesToDivInner, unify
var type = typeAndProb[0];
var prob = typeAndProb[1];
var $numlabel = $('<span class="accesskey">'+(typeNo+1)+'</span><span>:</span>');
var $input = $('<input type="radio" name="rapid_span_type"/>').
attr('id', 'rapid_span_' + (typeNo+1)).
attr('value', type);
var spanBgColor = spanTypes[type] && spanTypes[type].bgColor || '#ffffff';
spanBgColor = Util.adjustColorLightness(spanBgColor, spanBoxTextBgColorLighten);
// use preferred label instead of type name if available
var name = spanTypes[type] && spanTypes[type].name || type;
var $label = $('<label class="span_type_label"/>').
attr('for', 'rapid_span_' + (typeNo+1)).
text(name+' (' + (100.0 * prob).toFixed(1) + '%)');
$label.css('background-color', spanBgColor);
// TODO: check for unnecessary extra wrapping here
var $content = $('<div class="item_content"/>').
append($numlabel).
append($input).
append($label);
$spanTypeDiv.append($content);
// highlight configured hotkey (if any) in text.
// NOTE: this bit doesn't actually set up the hotkey.
var hotkeyType = 'span_' + type;
// TODO: this is clumsy; there should be a better way
var typeHotkey = null;
$.each(keymap, function(key, keyType) {
if (keyType == hotkeyType) {
typeHotkey = key;
return false;
}
});
if (typeHotkey) {
var name = $label.html();
var replace = true;
name = name.replace(new RegExp("(&[^;]*?)?(" + typeHotkey + ")", 'gi'),
function(all, entity, letter) {
if (replace && !entity) {
replace = false;
var hotkey = typeHotkey.toLowerCase() == letter
? typeHotkey.toLowerCase()
: typeHotkey.toUpperCase();
return '<span class="accesskey">' + Util.escapeHTML(hotkey) + '</span>';
}
return all;
});
$label.html(name);
}
// Limit the number of suggestions to the number of numeric keys
if (typeNo >= 8) {
return false;
}
});
// fill in some space and the special "Other" option, with key "0" (zero)
$spanTypeDiv.append($('<div class="item_content"> </div>')); // non-breaking space
var $numlabel = $('<span class="accesskey">0</span><span>:</span>');
var $input = $('<input type="radio" name="rapid_span_type" id="rapid_span_0" value=""/>');
var $label = $('<label class="span_type_label" for="rapid_span_0" style="background-color:lightgray">Other...</label>');
var $content = $('<div class="item_content"/>').
append($numlabel).
append($input).
append($label);
$spanTypeDiv.append($content);
// set up click event handlers
rapidSpanForm.find('#rapid_span_types input:radio').click(rapidSpanFormSubmitRadio);
var firstRadio = $('#rapid_span_form input:radio:first')[0];
if (firstRadio) {
firstRadio.checked = true;
} else {
dispatcher.post('hideForm');
dispatcher.post('messages', [[['No valid span types defined', 'error']]]);
return;
}
dispatcher.post('showForm', [rapidSpanForm]);
rapidAnnotationDialogVisible = true;
$('#rapid_span_form-ok').focus();
// TODO: avoid using global for stored click event
// adjustToCursor(lastRapidAnnotationEvent, rapidSpanForm.parent(),
// true, true);
// TODO: avoid coordinate hack to position roughly at first
// available selection
lastRapidAnnotationEvent.clientX -= 55;
lastRapidAnnotationEvent.clientY -= 115;
adjustToCursor(lastRapidAnnotationEvent, rapidSpanForm.parent(),
false, false);
};
var clearArcNotes = function(evt) {
$('#arc_notes').val('');
}
$('#clear_arc_notes_button').button();
$('#clear_arc_notes_button').click(clearArcNotes);
var clearSpanNotes = function(evt) {
$('#span_notes').val('');
}
$('#clear_span_notes_button').button();
$('#clear_span_notes_button').click(clearSpanNotes);
var clearSpanNorm = function(evt) {
clearNormalizationUI();
}
$('#clear_norm_button').button();
$('#clear_norm_button').click(clearSpanNorm);
// invoked on response to ajax request for id lookup
var setSpanNormText = function(response) {
if (response.exception) {
// TODO: better response to failure
dispatcher.post('messages', [[['Lookup error', 'warning', -1]]]);
return false;
}
// set input style according to whether we have a valid value
var $idinput = $('#span_norm_id');
// TODO: make sure the key echo in the response matches the
// current value of the $idinput
$idinput.removeClass('valid_value').removeClass('invalid_value');
if (response.value === null) {
$idinput.addClass('invalid_value');
hideNormalizationRefLink();
} else {
$idinput.addClass('valid_value');
updateNormalizationRefLink();
}
$('#span_norm_txt').val(response.value);
}
// on any change to the normalization DB, clear everything and
// update link
var spanNormDbUpdate = function(evt) {
clearNormalizationUI();
updateNormalizationDbLink();
}
$('#span_norm_db').change(spanNormDbUpdate);
// on any change to the normalization ID, update the text of the
// reference
var spanNormIdUpdate = function(evt) {
var key = $(this).val();
var db = $('#span_norm_db').val();
if (key != oldSpanNormIdValue) {
if (key.match(/^\s*$/)) {
// don't query empties, just clear instead
clearNormalizationUI();
} else {
dispatcher.post('ajax', [ {
action: 'normGetName',
database: db,
key: key,
collection: coll}, 'normGetNameResult']);
}
oldSpanNormIdValue = key;
}
}
// see http://stackoverflow.com/questions/1948332/detect-all-changes-to-a-input-type-text-immediately-using-jquery
$('#span_norm_id').bind('propertychange keyup input paste', spanNormIdUpdate);
// nice-looking select for normalization
$('#span_norm_db').addClass('ui-widget ui-state-default ui-button-text');
var normSearchDialog = $('#norm_search_dialog');
initForm(normSearchDialog, {
width: 800,
width: 600,
resizable: true,
alsoResize: '#norm_search_result_select',
open: function(evt) {
keymap = {};
},
close: function(evt) {
// assume that we always want to return to the span dialog
// on normalization dialog close
dispatcher.post('showForm', [spanForm, true]);
},
});
$('#norm_search_query').autocomplete({
source: function(request, callback) {
var query = $.ui.autocomplete.escapeRegex(request.term);
var pattern = new RegExp('\\b' + query, 'i');
callback($.grep(lastNormSearches, function(search) {
return pattern.test(search.value) || pattern.test(search.id);
}));
},
minLength: 0,
select: function(evt, ui) {
evt.stopPropagation();
normSubmit(ui.item.id, ui.item.value);
},
focus: function(evt, ui) {
// do nothing
},
}).autocomplete('instance')._renderItem = function($ul, item) {
// XXX TODO TEST
return $('<li></li>').
data('item.autocomplete', item).
append('<a>' + Util.escapeHTML(item.value) + '<div class="autocomplete-id">' + Util.escapeHTML(item.id) + "</div></a>").
appendTo($ul);
};
var normSubmit = function(selectedId, selectedTxt) {
// we got a value; act if it was a submit
$('#span_norm_id').val(selectedId);
// don't forget to update this reference value
oldSpanNormIdValue = selectedId;
$('#span_norm_txt').val(selectedTxt);
updateNormalizationRefLink();
// update history
var nextLastNormSearches = [
{
value: selectedTxt,
id: selectedId,
},
];
$.each(lastNormSearches, function(searchNo, search) {
if (search.id != selectedId || search.value != selectedTxt) {
nextLastNormSearches.push(search);
}
});
lastNormSearches = nextLastNormSearches;
lastNormSearches.slice(0, maxNormSearchHistory);
// Switch dialogs. NOTE: assuming we closed the spanForm when
// bringing up the normSearchDialog.
normSearchDialog.dialog('close');
};
var normSearchSubmit = function(evt) {
if (normSearchSubmittable) {
var selectedId = $('#norm_search_id').val();
var selectedTxt = $('#norm_search_query').val();
normSubmit(selectedId, selectedTxt);
} else {
performNormSearch();
}
return false;
}
var normSearchSubmittable = false;
var setNormSearchSubmit = function(enable) {
$('#norm_search_dialog-ok').button(enable ? 'enable' : 'disable');
normSearchSubmittable = enable;
};
normSearchDialog.submit(normSearchSubmit);
var chooseNormId = function(evt) {
var $element = $(evt.target).closest('tr');
$('#norm_search_result_select tr').removeClass