blob: ae10f5b0c3152dec021c05aa3d65f2fb5a105dd4 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Class which allows construction of annotated strings.
*/
goog.provide('MultiSpannable');
goog.provide('Spannable');
goog.scope(function() {
/**
* @constructor
* @param {string|!Spannable=} opt_string Initial value of the spannable.
* @param {*=} opt_annotation Initial annotation for the entire string.
*/
Spannable = function(opt_string, opt_annotation) {
/**
* Underlying string.
* @type {string}
* @private
*/
this.string_ = opt_string instanceof Spannable ? '' : opt_string || '';
/**
* Spans (annotations).
* @type {!Array<!SpanStruct>}
* @private
*/
this.spans_ = [];
// Append the initial spannable.
if (opt_string instanceof Spannable)
this.append(opt_string);
// Optionally annotate the entire string.
if (goog.isDef(opt_annotation)) {
var len = this.string_.length;
this.spans_.push({value: opt_annotation, start: 0, end: len});
}
};
Spannable.prototype = {
/** @override */
toString: function() {
return this.string_;
},
/** @return {number} The length of the string */
get length() {
return this.string_.length;
},
/**
* Adds a span to some region of the string.
* @param {*} value Annotation.
* @param {number} start Starting index (inclusive).
* @param {number} end Ending index (exclusive).
*/
setSpan: function(value, start, end) {
this.removeSpan(value);
this.setSpanInternal(value, start, end);
},
/**
* @param {*} value Annotation.
* @param {number} start Starting index (inclusive).
* @param {number} end Ending index (exclusive).
* @protected
*/
setSpanInternal: function(value, start, end) {
if (0 <= start && start <= end && end <= this.string_.length) {
// Zero-length spans are explicitly allowed, because it is possible to
// query for position by annotation as well as the reverse.
this.spans_.push({value: value, start: start, end: end});
this.spans_.sort(function(a, b) {
var ret = a.start - b.start;
if (ret == 0)
ret = a.end - b.end;
return ret;
});
} else {
throw new RangeError(
'span out of range (start=' + start + ', end=' + end +
', len=' + this.string_.length + ')');
}
},
/**
* Removes a span.
* @param {*} value Annotation.
*/
removeSpan: function(value) {
for (var i = this.spans_.length - 1; i >= 0; i--) {
if (this.spans_[i].value === value) {
this.spans_.splice(i, 1);
}
}
},
/**
* Appends another Spannable or string to this one.
* @param {string|!Spannable} other String or spannable to concatenate.
*/
append: function(other) {
if (other instanceof Spannable) {
var otherSpannable = /** @type {!Spannable} */ (other);
var originalLength = this.length;
this.string_ += otherSpannable.string_;
other.spans_.forEach(function(span) {
this.setSpan(
span.value, span.start + originalLength, span.end + originalLength);
}.bind(this));
} else if (typeof other === 'string') {
this.string_ += /** @type {string} */ (other);
}
},
/**
* Returns the first value matching a position.
* @param {number} position Position to query.
* @return {*} Value annotating that position, or undefined if none is found.
*/
getSpan: function(position) {
return valueOfSpan(this.spans_.find(spanCoversPosition(position)));
},
/**
* Returns the first span value which is an instance of a given constructor.
* @param {!Function} constructor Constructor.
* @return {*} Object if found; undefined otherwise.
*/
getSpanInstanceOf: function(constructor) {
return valueOfSpan(this.spans_.find(spanInstanceOf(constructor)));
},
/**
* Returns all span values which are an instance of a given constructor.
* Spans are returned in the order of their starting index and ending index
* for spans with equals tarting indices.
* @param {!Function} constructor Constructor.
* @return {!Array<Object>} Array of object.
*/
getSpansInstanceOf: function(constructor) {
return (this.spans_.filter(spanInstanceOf(constructor)).map(valueOfSpan));
},
/**
* Returns all spans matching a position.
* @param {number} position Position to query.
* @return {!Array} Values annotating that position.
*/
getSpans: function(position) {
return (this.spans_.filter(spanCoversPosition(position)).map(valueOfSpan));
},
/**
* Returns whether a span is contained in this object.
* @param {*} value Annotation.
* @return {boolean}
*/
hasSpan: function(value) {
return this.spans_.some(spanValueIs(value));
},
/**
* Returns the start of the requested span. Throws if the span doesn't exist
* in this object.
* @param {*} value Annotation.
* @return {number}
*/
getSpanStart: function(value) {
return this.getSpanByValueOrThrow_(value).start;
},
/**
* Returns the end of the requested span. Throws if the span doesn't exist
* in this object.
* @param {*} value Annotation.
* @return {number}
*/
getSpanEnd: function(value) {
return this.getSpanByValueOrThrow_(value).end;
},
/**
* @param {*} value Annotation.
* @return {!Array<{start: number, end: number}>}
*/
getSpanIntervals: function(value) {
return this.spans_.filter(function(s) { return s.value == value; })
.map(function(s) { return {start: s.start, end: s.end}; });
},
/**
* Returns the number of characters covered by the given span. Throws if
* the span is not in this object.
* @param {*} value
* @return {number}
*/
getSpanLength: function(value) {
var span = this.getSpanByValueOrThrow_(value);
return span.end - span.start;
},
/**
* Gets the internal object for a span or throws if the span doesn't exist.
* @param {*} value The annotation.
* @return {!SpanStruct}
* @private
*/
getSpanByValueOrThrow_: function(value) {
var span = this.spans_.find(spanValueIs(value));
if (span)
return span;
throw new Error('Span ' + value + ' doesn\'t exist in spannable');
},
/**
* Returns a substring of this spannable.
* Note that while similar to String#substring, this function is much less
* permissive about its arguments. It does not accept arguments in the wrong
* order or out of bounds.
*
* @param {number} start Start index, inclusive.
* @param {number=} opt_end End index, exclusive.
* If excluded, the length of the string is used instead.
* @return {!Spannable} Substring requested.
*/
substring: function(start, opt_end) {
var end = goog.isDef(opt_end) ? opt_end : this.string_.length;
if (start < 0 || end > this.string_.length || start > end) {
throw new RangeError('substring indices out of range');
}
var result = new Spannable(this.string_.substring(start, end));
this.spans_.forEach(function(span) {
if (span.start <= end && span.end >= start) {
var newStart = Math.max(0, span.start - start);
var newEnd = Math.min(end - start, span.end - start);
result.spans_.push({value: span.value, start: newStart, end: newEnd});
}
});
return result;
},
/**
* Trims whitespace from the beginning.
* @return {!Spannable} String with whitespace removed.
*/
trimLeft: function() {
return this.trim_(true, false);
},
/**
* Trims whitespace from the end.
* @return {!Spannable} String with whitespace removed.
*/
trimRight: function() {
return this.trim_(false, true);
},
/**
* Trims whitespace from the beginning and end.
* @return {!Spannable} String with whitespace removed.
*/
trim: function() {
return this.trim_(true, true);
},
/**
* Trims whitespace from either the beginning and end or both.
* @param {boolean} trimStart Trims whitespace from the start of a string.
* @param {boolean} trimEnd Trims whitespace from the end of a string.
* @return {!Spannable} String with whitespace removed.
* @private
*/
trim_: function(trimStart, trimEnd) {
if (!trimStart && !trimEnd) {
return this;
}
// Special-case whitespace-only strings, including the empty string.
// As an arbitrary decision, we treat this as trimming the whitespace off
// the end, rather than the beginning, of the string.
// This choice affects which spans are kept.
if (/^\s*$/.test(this.string_)) {
return this.substring(0, 0);
}
// Otherwise, we have at least one non-whitespace character to use as an
// anchor when trimming.
var trimmedStart = trimStart ? this.string_.match(/^\s*/)[0].length : 0;
var trimmedEnd =
trimEnd ? this.string_.match(/\s*$/).index : this.string_.length;
return this.substring(trimmedStart, trimmedEnd);
},
/**
* Returns this spannable to a json serializable form, including the text and
* span objects whose types have been registered with registerSerializableSpan
* or registerStatelessSerializableSpan.
* @return {!SerializedSpannable} the json serializable form.
*/
toJson: function() {
var result = {};
result.string = this.string_;
result.spans = [];
this.spans_.forEach(function(span) {
var serializeInfo =
serializableSpansByConstructor.get(span.value.constructor);
if (serializeInfo) {
var spanObj = {
type: serializeInfo.name,
start: span.start,
end: span.end
};
if (serializeInfo.toJson) {
spanObj.value = serializeInfo.toJson.apply(span.value);
}
result.spans.push(spanObj);
}
});
return result;
}
};
/**
* A spannable that allows a span value to annotate discontinuous regions of the
* string. In effect, a span value can be set multiple times.
* Note that most methods that assume a span value is unique such as
* |getSpanStart| will use the first span value.
* @constructor
* @param {string|!Spannable=} opt_string Initial value of the spannable.
* @param {*=} opt_annotation Initial annotation for the entire string.
* @extends {Spannable}
*/
MultiSpannable = function(opt_string, opt_annotation) {
Spannable.call(this, opt_string, opt_annotation);
};
MultiSpannable.prototype = {
__proto__: Spannable.prototype,
/** @override */
setSpan: function(value, start, end) {
this.setSpanInternal(value, start, end);
},
/** @override */
substring: function(start, opt_end) {
var ret = Spannable.prototype.substring.call(this, start, opt_end);
return new MultiSpannable(ret);
},
};
/**
* Creates a spannable from a json serializable representation.
* @param {!SerializedSpannable} obj object containing the serializable
* representation.
* @return {!Spannable}
*/
Spannable.fromJson = function(obj) {
if (typeof obj.string !== 'string') {
throw new Error('Invalid spannable json object: string field not a string');
}
if (!(obj.spans instanceof Array)) {
throw new Error('Invalid spannable json object: no spans array');
}
var result = new Spannable(obj.string);
result.spans_ = obj.spans.map(function(span) {
if (typeof span.type !== 'string') {
throw new Error(
'Invalid span in spannable json object: type not a string');
}
if (typeof span.start !== 'number' || typeof span.end !== 'number') {
throw new Error(
'Invalid span in spannable json object: start or end not a number');
}
var serializeInfo = serializableSpansByName.get(span.type);
var value = serializeInfo.fromJson(span.value);
return {value: value, start: span.start, end: span.end};
});
return result;
};
/**
* Registers a type that can be converted to a json serializable format.
* @param {!Function} constructor The type of object that can be converted.
* @param {string} name String identifier used in the serializable format.
* @param {function(!Object): !Object} fromJson A function that converts
* the serializable object to an actual object of this type.
* @param {function(): !Object} toJson A function that converts this object to
* a json serializable object. The function will be called with |this| set
* to the object to convert.
*/
Spannable.registerSerializableSpan = function(
constructor, name, fromJson, toJson) {
var obj = {name: name, fromJson: fromJson, toJson: toJson};
serializableSpansByName.set(name, obj);
serializableSpansByConstructor.set(constructor, obj);
};
/**
* Registers an object type that can be converted to/from a json serializable
* form. Objects of this type carry no state that will be preserved
* when serialized.
* @param {!Function} constructor The type of the object that can be converted.
* This constructor will be called with no arguments to construct
* new objects.
* @param {string} name Name of the type used in the serializable object.
*/
Spannable.registerStatelessSerializableSpan = function(constructor, name) {
var obj = {name: name, toJson: undefined};
/**
* @param {!Object} obj
* @return {!Object}
*/
obj.fromJson = function(obj) {
return new constructor();
};
serializableSpansByName.set(name, obj);
serializableSpansByConstructor.set(constructor, obj);
};
/**
* An annotation with its start and end points.
* @typedef {{value: *, start: number, end: number}}
*/
var SpanStruct;
/**
* Describes how to convert a span type to/from serializable json.
* @typedef {{name: string,
* fromJson: function(!Object): !Object,
* toJson: ((function(): !Object)|undefined)}}
*/
var SerializeInfo;
/**
* The serialized format of a spannable.
* @typedef {{string: string, spans: Array<SerializedSpan>}}
* @private
*/
var SerializedSpannable;
/**
* The format of a single annotation in a serialized spannable.
* @typedef {{type: string, value: !Object, start: number, end: number}}
*/
var SerializedSpan;
/**
* Maps type names to serialization info objects.
* @type {Map<string, SerializeInfo>}
*/
var serializableSpansByName = new Map();
/**
* Maps constructors to serialization info objects.
* @type {Map<Function, SerializeInfo>}
*/
var serializableSpansByConstructor = new Map();
// Helpers for implementing the various |get*| methods of |Spannable|.
/**
* @param {Function} constructor
* @return {function(SpanStruct): boolean}
*/
function spanInstanceOf(constructor) {
return function(span) {
return span.value instanceof constructor;
};
}
/**
* @param {number} position
* @return {function(SpanStruct): boolean}
*/
function spanCoversPosition(position) {
return function(span) {
return span.start <= position && position < span.end;
};
}
/**
* @param {*} value
* @return {function(SpanStruct): boolean}
*/
function spanValueIs(value) {
return function(span) {
return span.value === value;
};
}
/**
* @param {!SpanStruct|undefined} span
* @return {*}
*/
function valueOfSpan(span) {
return span ? span.value : undefined;
}
});