var Changeset = require('./Changeset'); var ChangesetUtils = require('./ChangesetUtils'); var _ = require('./underscore'); var lineMarkerAttribute = 'lmkr'; // If one of these attributes are set to the first character of a // line it is considered as a line attribute marker i.e. attributes // set on this marker are applied to the whole line. // The list attribute is only maintained for compatibility reasons var lineAttributes = [lineMarkerAttribute,'list']; /* The Attribute manager builds changesets based on a document representation for setting and removing range or line-based attributes. @param rep the document representation to be used @param applyChangesetCallback this callback will be called once a changeset has been built. A document representation contains - an array `alines` containing 1 attributes string for each line - an Attribute pool `apool` - a SkipList `lines` containing the text lines of the document. */ var AttributeManager = function(rep, applyChangesetCallback) { this.rep = rep; this.applyChangesetCallback = applyChangesetCallback; this.author = ''; // If the first char in a line has one of the following attributes // it will be considered as a line marker }; AttributeManager.lineAttributes = lineAttributes; AttributeManager.prototype = _(AttributeManager.prototype).extend({ applyChangeset: function(changeset){ if(!this.applyChangesetCallback) return changeset; var cs = changeset.toString(); if (!Changeset.isIdentity(cs)) { this.applyChangesetCallback(cs); } return changeset; }, /* Sets attributes on a range @param start [row, col] tuple pointing to the start of the range @param end [row, col] tuple pointing to the end of the range @param attribs: an array of attributes */ setAttributesOnRange: function(start, end, attribs) { // instead of applying the attributes to the whole range at once, we need to apply them // line by line, to be able to disregard the "*" used as line marker. For more details, // see https://github.com/ether/etherpad-lite/issues/2772 var allChangesets; for(var row = start[0]; row <= end[0]; row++) { var rowRange = this._findRowRange(row, start, end); var startCol = rowRange[0]; var endCol = rowRange[1]; var rowChangeset = this._setAttributesOnRangeByLine(row, startCol, endCol, attribs); // compose changesets of all rows into a single changeset, as the range might not be continuous // due to the presence of line markers on the rows if (allChangesets) { allChangesets = Changeset.compose(allChangesets.toString(), rowChangeset.toString(), this.rep.apool); } else { allChangesets = rowChangeset; } } return this.applyChangeset(allChangesets); }, _findRowRange: function(row, start, end) { var startCol, endCol; var startLineOffset = this.rep.lines.offsetOfIndex(row); var endLineOffset = this.rep.lines.offsetOfIndex(row+1); var lineLength = endLineOffset - startLineOffset; // find column where range on this row starts if (row === start[0]) { // are we on the first row of range? startCol = start[1]; } else { startCol = this.lineHasMarker(row) ? 1 : 0; // remove "*" used as line marker } // find column where range on this row ends if (row === end[0]) { // are we on the last row of range? endCol = end[1]; // if so, get the end of range, not end of row } else { endCol = lineLength - 1; // remove "\n" } return [startCol, endCol]; }, /* Sets attributes on a range, by line @param row the row where range is @param startCol column where range starts @param endCol column where range ends @param attribs: an array of attributes */ _setAttributesOnRangeByLine: function(row, startCol, endCol, attribs) { var builder = Changeset.builder(this.rep.lines.totalWidth()); ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]); ChangesetUtils.buildKeepRange(this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool); return builder; }, /* Returns if the line already has a line marker @param lineNum: the number of the line */ lineHasMarker: function(lineNum){ var that = this; return _.find(lineAttributes, function(attribute){ return that.getAttributeOnLine(lineNum, attribute) != ''; }) !== undefined; }, /* Gets a specified attribute on a line @param lineNum: the number of the line to set the attribute for @param attributeKey: the name of the attribute to get, e.g. list */ getAttributeOnLine: function(lineNum, attributeName){ // get `attributeName` attribute of first char of line var aline = this.rep.alines[lineNum]; if (aline) { var opIter = Changeset.opIterator(aline); if (opIter.hasNext()) { return Changeset.opAttributeValue(opIter.next(), attributeName, this.rep.apool) || ''; } } return ''; }, /* Gets all attributes on a line @param lineNum: the number of the line to get the attribute for */ getAttributesOnLine: function(lineNum){ // get attributes of first char of line var aline = this.rep.alines[lineNum]; var attributes = [] if (aline) { var opIter = Changeset.opIterator(aline) , op if (opIter.hasNext()) { op = opIter.next() if(!op.attribs) return [] Changeset.eachAttribNumber(op.attribs, function(n) { attributes.push([this.rep.apool.getAttribKey(n), this.rep.apool.getAttribValue(n)]) }.bind(this)) return attributes; } } return []; }, /* Gets a given attribute on a selection @param attributeName @param prevChar returns true or false if an attribute is visible in range */ getAttributeOnSelection: function(attributeName, prevChar){ var rep = this.rep; if (!(rep.selStart && rep.selEnd)) return // If we're looking for the caret attribute not the selection // has the user already got a selection or is this purely a caret location? var isNotSelection = (rep.selStart[0] == rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]); if(isNotSelection){ if(prevChar){ // If it's not the start of the line if(rep.selStart[1] !== 0){ rep.selStart[1]--; } } } var withIt = Changeset.makeAttribsString('+', [ [attributeName, 'true'] ], rep.apool); var withItRegex = new RegExp(withIt.replace(/\*/g, '\\*') + "(\\*|$)"); function hasIt(attribs) { return withItRegex.test(attribs); } return rangeHasAttrib(rep.selStart, rep.selEnd) function rangeHasAttrib(selStart, selEnd) { // if range is collapsed -> no attribs in range if(selStart[1] == selEnd[1] && selStart[0] == selEnd[0]) return false if(selStart[0] != selEnd[0]) { // -> More than one line selected var hasAttrib = true // from selStart to the end of the first line hasAttrib = hasAttrib && rangeHasAttrib(selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]) // for all lines in between for(var n=selStart[0]+1; n < selEnd[0]; n++) { hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]) } // for the last, potentially partial, line hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]) return hasAttrib } // Logic tells us we now have a range on a single line var lineNum = selStart[0] , start = selStart[1] , end = selEnd[1] , hasAttrib = true // Iterate over attribs on this line var opIter = Changeset.opIterator(rep.alines[lineNum]) , indexIntoLine = 0 while (opIter.hasNext()) { var op = opIter.next(); var opStartInLine = indexIntoLine; var opEndInLine = opStartInLine + op.chars; if (!hasIt(op.attribs)) { // does op overlap selection? if (!(opEndInLine <= start || opStartInLine >= end)) { hasAttrib = false; // since it's overlapping but hasn't got the attrib -> range hasn't got it break; } } indexIntoLine = opEndInLine; } return hasAttrib } }, /* Gets all attributes at a position containing line number and column @param lineNumber starting with zero @param column starting with zero returns a list of attributes in the format [ ["key","value"], ["key","value"], ... ] */ getAttributesOnPosition: function(lineNumber, column){ // get all attributes of the line var aline = this.rep.alines[lineNumber]; if (!aline) { return []; } // iterate through all operations of a line var opIter = Changeset.opIterator(aline); // we need to sum up how much characters each operations take until the wanted position var currentPointer = 0; var attributes = []; var currentOperation; while (opIter.hasNext()) { currentOperation = opIter.next(); currentPointer = currentPointer + currentOperation.chars; if (currentPointer > column) { // we got the operation of the wanted position, now collect all its attributes Changeset.eachAttribNumber(currentOperation.attribs, function (n) { attributes.push([ this.rep.apool.getAttribKey(n), this.rep.apool.getAttribValue(n) ]); }.bind(this)); // skip the loop return attributes; } } return attributes; }, /* Gets all attributes at caret position if the user selected a range, the start of the selection is taken returns a list of attributes in the format [ ["key","value"], ["key","value"], ... ] */ getAttributesOnCaret: function(){ return this.getAttributesOnPosition(this.rep.selStart[0], this.rep.selStart[1]); }, /* Sets a specified attribute on a line @param lineNum: the number of the line to set the attribute for @param attributeKey: the name of the attribute to set, e.g. list @param attributeValue: an optional parameter to pass to the attribute (e.g. indention level) */ setAttributeOnLine: function(lineNum, attributeName, attributeValue){ var loc = [0,0]; var builder = Changeset.builder(this.rep.lines.totalWidth()); var hasMarker = this.lineHasMarker(lineNum); ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0])); if(hasMarker){ ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 1]), [ [attributeName, attributeValue] ], this.rep.apool); }else{ // add a line marker builder.insert('*', [ ['author', this.author], ['insertorder', 'first'], [lineMarkerAttribute, '1'], [attributeName, attributeValue] ], this.rep.apool); } return this.applyChangeset(builder); }, /** * Removes a specified attribute on a line * @param lineNum the number of the affected line * @param attributeName the name of the attribute to remove, e.g. list * @param attributeValue if given only attributes with equal value will be removed */ removeAttributeOnLine: function(lineNum, attributeName, attributeValue){ var builder = Changeset.builder(this.rep.lines.totalWidth()); var hasMarker = this.lineHasMarker(lineNum); var found = false; var attribs = _(this.getAttributesOnLine(lineNum)).map(function (attrib) { if (attrib[0] === attributeName && (!attributeValue || attrib[0] === attributeValue)){ found = true; return [attributeName, '']; }else if (attrib[0] === 'author'){ // update last author to make changes to line attributes on this line return [attributeName, this.author]; } return attrib; }); if (!found) { return; } ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]); var countAttribsWithMarker = _.chain(attribs).filter(function(a){return !!a[1];}) .map(function(a){return a[0];}).difference(['author', 'lmkr', 'insertorder', 'start']).size().value(); //if we have marker and any of attributes don't need to have marker. we need delete it if(hasMarker && !countAttribsWithMarker){ ChangesetUtils.buildRemoveRange(this.rep, builder, [lineNum, 0], [lineNum, 1]); }else{ ChangesetUtils.buildKeepRange(this.rep, builder, [lineNum, 0], [lineNum, 1], attribs, this.rep.apool); } return this.applyChangeset(builder); }, /* Toggles a line attribute for the specified line number If a line attribute with the specified name exists with any value it will be removed Otherwise it will be set to the given value @param lineNum: the number of the line to toggle the attribute for @param attributeKey: the name of the attribute to toggle, e.g. list @param attributeValue: the value to pass to the attribute (e.g. indention level) */ toggleAttributeOnLine: function(lineNum, attributeName, attributeValue) { return this.getAttributeOnLine(lineNum, attributeName) ? this.removeAttributeOnLine(lineNum, attributeName) : this.setAttributeOnLine(lineNum, attributeName, attributeValue); } }); module.exports = AttributeManager;