Move all files to esm

This commit is contained in:
SamTV12345 2023-06-25 00:05:01 +02:00
parent 237f7242ec
commit 76a6f665a4
No known key found for this signature in database
GPG key ID: E63EEC7466038043
87 changed files with 23693 additions and 30732 deletions

1
node_modules/ep_etherpad-lite generated vendored
View file

@ -1 +0,0 @@
../src

View file

@ -4,7 +4,15 @@
*/ */
import AttributeMap from '../../static/js/AttributeMap'; import AttributeMap from '../../static/js/AttributeMap';
import {applyToAText, copyAText, makeAText} from '../../static/js/Changeset'; import {
applyToAText, checkRep,
copyAText, deserializeOps,
makeAText,
makeSplice,
opsFromAText,
pack,
smartOpAssembler, unpack
} from '../../static/js/Changeset';
import ChatMessage from '../../static/js/ChatMessage'; import ChatMessage from '../../static/js/ChatMessage';
import {AttributePool} from '../../static/js/AttributePool'; import {AttributePool} from '../../static/js/AttributePool';
import {Stream} from '../utils/Stream'; import {Stream} from '../utils/Stream';
@ -266,7 +274,7 @@ export class Pad {
(!ins && start > 0 && orig[start - 1] === '\n'); (!ins && start > 0 && orig[start - 1] === '\n');
if (!willEndWithNewline) ins += '\n'; if (!willEndWithNewline) ins += '\n';
if (ndel === 0 && ins.length === 0) return; if (ndel === 0 && ins.length === 0) return;
const changeset = Changeset.makeSplice(orig, start, ndel, ins); const changeset = makeSplice(orig, start, ndel, ins);
await this.appendRevision(changeset, authorId); await this.appendRevision(changeset, authorId);
} }
@ -366,7 +374,7 @@ export class Pad {
if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`); if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);
text = cleanText(context.content); text = cleanText(context.content);
} }
const firstChangeset = Changeset.makeSplice('\n', 0, 0, text); const firstChangeset = makeSplice('\n', 0, 0, text);
await this.appendRevision(firstChangeset, authorId); await this.appendRevision(firstChangeset, authorId);
} }
await aCallAll('padLoad', {pad: this}); await aCallAll('padLoad', {pad: this});
@ -490,8 +498,8 @@ export class Pad {
const oldAText = this.atext; const oldAText = this.atext;
// based on Changeset.makeSplice // based on Changeset.makeSplice
const assem = Changeset.smartOpAssembler(); const assem = smartOpAssembler();
for (const op of Changeset.opsFromAText(oldAText)) assem.append(op); for (const op of opsFromAText(oldAText)) assem.append(op);
assem.endDocument(); assem.endDocument();
// although we have instantiated the dstPad with '\n', an additional '\n' is // although we have instantiated the dstPad with '\n', an additional '\n' is
@ -503,7 +511,7 @@ export class Pad {
// create a changeset that removes the previous text and add the newText with // create a changeset that removes the previous text and add the newText with
// all atributes present on the source pad // all atributes present on the source pad
const changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText); const changeset = pack(oldLength, newLength, assem.toString(), newText);
dstPad.appendRevision(changeset, authorId); dstPad.appendRevision(changeset, authorId);
await aCallAll('padCopy', { await aCallAll('padCopy', {
@ -677,7 +685,7 @@ export class Pad {
} }
}) })
.batch(100).buffer(99); .batch(100).buffer(99);
let atext = Changeset.makeAText('\n'); let atext = makeAText('\n');
for await (const [r, changeset, authorId, timestamp, isKeyRev, keyAText] of revs) { for await (const [r, changeset, authorId, timestamp, isKeyRev, keyAText] of revs) {
try { try {
assert(authorId != null); assert(authorId != null);
@ -688,10 +696,10 @@ export class Pad {
assert(timestamp > 0); assert(timestamp > 0);
assert(changeset != null); assert(changeset != null);
assert.equal(typeof changeset, 'string'); assert.equal(typeof changeset, 'string');
Changeset.checkRep(changeset); checkRep(changeset);
const unpacked = Changeset.unpack(changeset); const unpacked = unpack(changeset);
let text = atext.text; let text = atext.text;
for (const op of Changeset.deserializeOps(unpacked.ops)) { for (const op of deserializeOps(unpacked.ops)) {
if (['=', '-'].includes(op.opcode)) { if (['=', '-'].includes(op.opcode)) {
assert(text.length >= op.chars); assert(text.length >= op.chars);
const consumed = text.slice(0, op.chars); const consumed = text.slice(0, op.chars);
@ -702,7 +710,7 @@ export class Pad {
} }
assert.equal(op.attribs, AttributeMap.fromString(op.attribs, pool).toString()); assert.equal(op.attribs, AttributeMap.fromString(op.attribs, pool).toString());
} }
atext = Changeset.applyToAText(changeset, atext, pool); atext = applyToAText(changeset, atext, pool);
if (isKeyRev) assert.deepEqual(keyAText, atext); if (isKeyRev) assert.deepEqual(keyAText, atext);
} catch (err) { } catch (err) {
err.message = `(pad ${this.id} revision ${r}) ${err.message}`; err.message = `(pad ${this.id} revision ${r}) ${err.message}`;

1553
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,18 @@
import AttributeMap from "./AttributeMap.js";
import * as Changeset from "./Changeset.js";
import * as ChangesetUtils from "./ChangesetUtils.js";
import * as attributes from "./attributes.js";
import * as _ from "./underscore.js";
'use strict'; 'use strict';
const AttributeMap = require('./AttributeMap');
const Changeset = require('./Changeset');
const ChangesetUtils = require('./ChangesetUtils');
const attributes = require('./attributes');
const _ = require('./underscore');
const lineMarkerAttribute = 'lmkr'; const lineMarkerAttribute = 'lmkr';
// Some of these attributes are kept for compatibility purposes. // Some of these attributes are kept for compatibility purposes.
// Not sure if we need all of them // Not sure if we need all of them
const DEFAULT_LINE_ATTRIBUTES = ['author', 'lmkr', 'insertorder', 'start']; const DEFAULT_LINE_ATTRIBUTES = ['author', 'lmkr', 'insertorder', 'start'];
// If one of these attributes are set to the first character of a // 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 // line it is considered as a line attribute marker i.e. attributes
// set on this marker are applied to the whole line. // set on this marker are applied to the whole line.
// The list attribute is only maintained for compatibility reasons // The list attribute is only maintained for compatibility reasons
const lineAttributes = [lineMarkerAttribute, 'list']; const lineAttributes = [lineMarkerAttribute, 'list'];
/* /*
The Attribute manager builds changesets based on a document The Attribute manager builds changesets based on a document
representation for setting and removing range or line-based attributes. representation for setting and removing range or line-based attributes.
@ -32,351 +27,318 @@ const lineAttributes = [lineMarkerAttribute, 'list'];
- an Attribute pool `apool` - an Attribute pool `apool`
- a SkipList `lines` containing the text lines of the document. - a SkipList `lines` containing the text lines of the document.
*/ */
const AttributeManager = function (rep, applyChangesetCallback) { const AttributeManager = function (rep, applyChangesetCallback) {
this.rep = rep; this.rep = rep;
this.applyChangesetCallback = applyChangesetCallback; this.applyChangesetCallback = applyChangesetCallback;
this.author = ''; this.author = '';
// If the first char in a line has one of the following attributes
// If the first char in a line has one of the following attributes // it will be considered as a line marker
// it will be considered as a line marker
}; };
AttributeManager.DEFAULT_LINE_ATTRIBUTES = DEFAULT_LINE_ATTRIBUTES; AttributeManager.DEFAULT_LINE_ATTRIBUTES = DEFAULT_LINE_ATTRIBUTES;
AttributeManager.lineAttributes = lineAttributes; AttributeManager.lineAttributes = lineAttributes;
AttributeManager.prototype = _(AttributeManager.prototype).extend({ AttributeManager.prototype = _(AttributeManager.prototype).extend({
applyChangeset(changeset) {
applyChangeset(changeset) { if (!this.applyChangesetCallback)
if (!this.applyChangesetCallback) return changeset; return changeset;
const cs = changeset.toString();
const cs = changeset.toString(); if (!Changeset.isIdentity(cs)) {
if (!Changeset.isIdentity(cs)) { this.applyChangesetCallback(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(start, end, attribs) {
if (start[0] < 0) throw new RangeError('selection start line number is negative');
if (start[1] < 0) throw new RangeError('selection start column number is negative');
if (end[0] < 0) throw new RangeError('selection end line number is negative');
if (end[1] < 0) throw new RangeError('selection end column number is negative');
if (start[0] > end[0] || (start[0] === end[0] && start[1] > end[1])) {
throw new RangeError('selection ends before it starts');
}
// 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
let allChangesets;
for (let row = start[0]; row <= end[0]; row++) {
const [startCol, endCol] = this._findRowRange(row, start, end);
const 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(row, start, end) {
if (row < start[0] || row > end[0]) throw new RangeError(`line ${row} not in selection`);
if (row >= this.rep.lines.length()) throw new RangeError(`selected line ${row} does not exist`);
// Subtract 1 for the end-of-line '\n' (it is never selected).
const lineLength =
this.rep.lines.offsetOfIndex(row + 1) - this.rep.lines.offsetOfIndex(row) - 1;
const markerWidth = this.lineHasMarker(row) ? 1 : 0;
if (lineLength - markerWidth < 0) throw new Error(`line ${row} has negative length`);
if (start[1] < 0) throw new RangeError('selection starts at negative column');
const startCol = Math.max(markerWidth, row === start[0] ? start[1] : 0);
if (startCol > lineLength) throw new RangeError('selection starts after line end');
if (end[1] < 0) throw new RangeError('selection ends at negative column');
const endCol = Math.max(markerWidth, row === end[0] ? end[1] : lineLength);
if (endCol > lineLength) throw new RangeError('selection ends after line end');
if (startCol > endCol) throw new RangeError('selection ends before it starts');
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 (one past the last selected column)
* @param attribs an array of attributes
*/
_setAttributesOnRangeByLine(row, startCol, endCol, attribs) {
const 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(lineNum) {
return lineAttributes.find(
(attribute) => this.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(lineNum, attributeName) {
// get `attributeName` attribute of first char of line
const aline = this.rep.alines[lineNum];
if (!aline) return '';
const [op] = Changeset.deserializeOps(aline);
if (op == null) return '';
return AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || '';
},
/*
Gets all attributes on a line
@param lineNum: the number of the line to get the attribute for
*/
getAttributesOnLine(lineNum) {
// get attributes of first char of line
const aline = this.rep.alines[lineNum];
if (!aline) return [];
const [op] = Changeset.deserializeOps(aline);
if (op == null) return [];
return [...attributes.attribsFromString(op.attribs, this.rep.apool)];
},
/*
Gets a given attribute on a selection
@param attributeName
@param prevChar
returns true or false if an attribute is visible in range
*/
getAttributeOnSelection(attributeName, prevChar) {
const 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?
const 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]--;
} }
} return changeset;
} },
/*
const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); Sets attributes on a range
const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); @param start [row, col] tuple pointing to the start of the range
const hasIt = (attribs) => withItRegex.test(attribs); @param end [row, col] tuple pointing to the end of the range
@param attribs: an array of attributes
const rangeHasAttrib = (selStart, selEnd) => { */
// if range is collapsed -> no attribs in range setAttributesOnRange(start, end, attribs) {
if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false; if (start[0] < 0)
throw new RangeError('selection start line number is negative');
if (selStart[0] !== selEnd[0]) { // -> More than one line selected if (start[1] < 0)
// from selStart to the end of the first line throw new RangeError('selection start column number is negative');
let hasAttrib = rangeHasAttrib( if (end[0] < 0)
selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]); throw new RangeError('selection end line number is negative');
if (end[1] < 0)
// for all lines in between throw new RangeError('selection end column number is negative');
for (let n = selStart[0] + 1; n < selEnd[0]; n++) { if (start[0] > end[0] || (start[0] === end[0] && start[1] > end[1])) {
hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]); throw new RangeError('selection ends before it starts');
}
// 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
let allChangesets;
for (let row = start[0]; row <= end[0]; row++) {
const [startCol, endCol] = this._findRowRange(row, start, end);
const 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(row, start, end) {
if (row < start[0] || row > end[0])
throw new RangeError(`line ${row} not in selection`);
if (row >= this.rep.lines.length())
throw new RangeError(`selected line ${row} does not exist`);
// Subtract 1 for the end-of-line '\n' (it is never selected).
const lineLength = this.rep.lines.offsetOfIndex(row + 1) - this.rep.lines.offsetOfIndex(row) - 1;
const markerWidth = this.lineHasMarker(row) ? 1 : 0;
if (lineLength - markerWidth < 0)
throw new Error(`line ${row} has negative length`);
if (start[1] < 0)
throw new RangeError('selection starts at negative column');
const startCol = Math.max(markerWidth, row === start[0] ? start[1] : 0);
if (startCol > lineLength)
throw new RangeError('selection starts after line end');
if (end[1] < 0)
throw new RangeError('selection ends at negative column');
const endCol = Math.max(markerWidth, row === end[0] ? end[1] : lineLength);
if (endCol > lineLength)
throw new RangeError('selection ends after line end');
if (startCol > endCol)
throw new RangeError('selection ends before it starts');
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 (one past the last selected column)
* @param attribs an array of attributes
*/
_setAttributesOnRangeByLine(row, startCol, endCol, attribs) {
const 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(lineNum) {
return lineAttributes.find((attribute) => this.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(lineNum, attributeName) {
// get `attributeName` attribute of first char of line
const aline = this.rep.alines[lineNum];
if (!aline)
return '';
const [op] = Changeset.deserializeOps(aline);
if (op == null)
return '';
return AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || '';
},
/*
Gets all attributes on a line
@param lineNum: the number of the line to get the attribute for
*/
getAttributesOnLine(lineNum) {
// get attributes of first char of line
const aline = this.rep.alines[lineNum];
if (!aline)
return [];
const [op] = Changeset.deserializeOps(aline);
if (op == null)
return [];
return [...attributes.attribsFromString(op.attribs, this.rep.apool)];
},
/*
Gets a given attribute on a selection
@param attributeName
@param prevChar
returns true or false if an attribute is visible in range
*/
getAttributeOnSelection(attributeName, prevChar) {
const 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?
const 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]--;
}
}
}
const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString();
const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`);
const hasIt = (attribs) => withItRegex.test(attribs);
const 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
// from selStart to the end of the first line
let hasAttrib = rangeHasAttrib(selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]);
// for all lines in between
for (let 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
const lineNum = selStart[0];
const start = selStart[1];
const end = selEnd[1];
let hasAttrib = true;
let indexIntoLine = 0;
for (const op of Changeset.deserializeOps(rep.alines[lineNum])) {
const opStartInLine = indexIntoLine;
const opEndInLine = opStartInLine + op.chars;
if (!hasIt(op.attribs)) {
// does op overlap selection?
if (!(opEndInLine <= start || opStartInLine >= end)) {
// since it's overlapping but hasn't got the attrib -> range hasn't got it
hasAttrib = false;
break;
}
}
indexIntoLine = opEndInLine;
}
return hasAttrib;
};
return rangeHasAttrib(rep.selStart, rep.selEnd);
},
/*
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(lineNumber, column) {
// get all attributes of the line
const aline = this.rep.alines[lineNumber];
if (!aline) {
return [];
}
// we need to sum up how much characters each operations take until the wanted position
let currentPointer = 0;
for (const currentOperation of Changeset.deserializeOps(aline)) {
currentPointer += currentOperation.chars;
if (currentPointer <= column)
continue;
return [...attributes.attribsFromString(currentOperation.attribs, this.rep.apool)];
}
return [];
},
/*
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() {
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(lineNum, attributeName, attributeValue) {
let loc = [0, 0];
const builder = Changeset.builder(this.rep.lines.totalWidth());
const 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(lineNum, attributeName, attributeValue) {
const builder = Changeset.builder(this.rep.lines.totalWidth());
const hasMarker = this.lineHasMarker(lineNum);
let found = false;
const attribs = this.getAttributesOnLine(lineNum).map((attrib) => {
if (attrib[0] === attributeName && (!attributeValue || attrib[0] === attributeValue)) {
found = true;
return [attrib[0], ''];
}
else if (attrib[0] === 'author') {
// update last author to make changes to line attributes on this line
return [attrib[0], this.author];
}
return attrib;
});
if (!found) {
return;
}
ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]);
const countAttribsWithMarker = _.chain(attribs).filter((a) => !!a[1])
.map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).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(lineNum, attributeName, attributeValue) {
return this.getAttributeOnLine(lineNum, attributeName)
? this.removeAttributeOnLine(lineNum, attributeName)
: this.setAttributeOnLine(lineNum, attributeName, attributeValue);
},
hasAttributeOnSelectionOrCaretPosition(attributeName) {
const hasSelection = ((this.rep.selStart[0] !== this.rep.selEnd[0]) || (this.rep.selEnd[1] !== this.rep.selStart[1]));
let hasAttrib;
if (hasSelection) {
hasAttrib = this.getAttributeOnSelection(attributeName);
}
else {
const attributesOnCaretPosition = this.getAttributesOnCaret();
const allAttribs = [].concat(...attributesOnCaretPosition); // flatten
hasAttrib = allAttribs.includes(attributeName);
} }
// for the last, potentially partial, line
hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]);
return hasAttrib; return hasAttrib;
} },
// Logic tells us we now have a range on a single line
const lineNum = selStart[0];
const start = selStart[1];
const end = selEnd[1];
let hasAttrib = true;
let indexIntoLine = 0;
for (const op of Changeset.deserializeOps(rep.alines[lineNum])) {
const opStartInLine = indexIntoLine;
const opEndInLine = opStartInLine + op.chars;
if (!hasIt(op.attribs)) {
// does op overlap selection?
if (!(opEndInLine <= start || opStartInLine >= end)) {
// since it's overlapping but hasn't got the attrib -> range hasn't got it
hasAttrib = false;
break;
}
}
indexIntoLine = opEndInLine;
}
return hasAttrib;
};
return rangeHasAttrib(rep.selStart, rep.selEnd);
},
/*
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(lineNumber, column) {
// get all attributes of the line
const aline = this.rep.alines[lineNumber];
if (!aline) {
return [];
}
// we need to sum up how much characters each operations take until the wanted position
let currentPointer = 0;
for (const currentOperation of Changeset.deserializeOps(aline)) {
currentPointer += currentOperation.chars;
if (currentPointer <= column) continue;
return [...attributes.attribsFromString(currentOperation.attribs, this.rep.apool)];
}
return [];
},
/*
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() {
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(lineNum, attributeName, attributeValue) {
let loc = [0, 0];
const builder = Changeset.builder(this.rep.lines.totalWidth());
const 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(lineNum, attributeName, attributeValue) {
const builder = Changeset.builder(this.rep.lines.totalWidth());
const hasMarker = this.lineHasMarker(lineNum);
let found = false;
const attribs = this.getAttributesOnLine(lineNum).map((attrib) => {
if (attrib[0] === attributeName && (!attributeValue || attrib[0] === attributeValue)) {
found = true;
return [attrib[0], ''];
} else if (attrib[0] === 'author') {
// update last author to make changes to line attributes on this line
return [attrib[0], this.author];
}
return attrib;
});
if (!found) {
return;
}
ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]);
const countAttribsWithMarker = _.chain(attribs).filter((a) => !!a[1])
.map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).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(lineNum, attributeName, attributeValue) {
return this.getAttributeOnLine(lineNum, attributeName)
? this.removeAttributeOnLine(lineNum, attributeName)
: this.setAttributeOnLine(lineNum, attributeName, attributeValue);
},
hasAttributeOnSelectionOrCaretPosition(attributeName) {
const hasSelection = (
(this.rep.selStart[0] !== this.rep.selEnd[0]) || (this.rep.selEnd[1] !== this.rep.selStart[1])
);
let hasAttrib;
if (hasSelection) {
hasAttrib = this.getAttributeOnSelection(attributeName);
} else {
const attributesOnCaretPosition = this.getAttributesOnCaret();
const allAttribs = [].concat(...attributesOnCaretPosition); // flatten
hasAttrib = allAttribs.includes(attributeName);
}
return hasAttrib;
},
}); });
export default AttributeManager;
module.exports = AttributeManager;

View file

@ -1,13 +1,10 @@
import * as attributes from "./attributes.js";
'use strict'; 'use strict';
const attributes = require('./attributes');
/** /**
* A `[key, value]` pair of strings describing a text attribute. * A `[key, value]` pair of strings describing a text attribute.
* *
* @typedef {[string, string]} Attribute * @typedef {[string, string]} Attribute
*/ */
/** /**
* A concatenated sequence of zero or more attribute identifiers, each one represented by an * A concatenated sequence of zero or more attribute identifiers, each one represented by an
* asterisk followed by a base-36 encoded attribute number. * asterisk followed by a base-36 encoded attribute number.
@ -16,76 +13,70 @@ const attributes = require('./attributes');
* *
* @typedef {string} AttributeString * @typedef {string} AttributeString
*/ */
/** /**
* Convenience class to convert an Op's attribute string to/from a Map of key, value pairs. * Convenience class to convert an Op's attribute string to/from a Map of key, value pairs.
*/ */
class AttributeMap extends Map { class AttributeMap extends Map {
/** /**
* Converts an attribute string into an AttributeMap. * Converts an attribute string into an AttributeMap.
* *
* @param {AttributeString} str - The attribute string to convert into an AttributeMap. * @param {AttributeString} str - The attribute string to convert into an AttributeMap.
* @param {AttributePool} pool - Attribute pool. * @param {AttributePool} pool - Attribute pool.
* @returns {AttributeMap} * @returns {AttributeMap}
*/ */
static fromString(str, pool) { static fromString(str, pool) {
return new AttributeMap(pool).updateFromString(str); return new AttributeMap(pool).updateFromString(str);
} }
/**
/** * @param {AttributePool} pool - Attribute pool.
* @param {AttributePool} pool - Attribute pool. */
*/ constructor(pool) {
constructor(pool) { super();
super(); /** @public */
/** @public */ this.pool = pool;
this.pool = pool; }
} /**
* @param {string} k - Attribute name.
/** * @param {string} v - Attribute value.
* @param {string} k - Attribute name. * @returns {AttributeMap} `this` (for chaining).
* @param {string} v - Attribute value. */
* @returns {AttributeMap} `this` (for chaining). set(k, v) {
*/ k = k == null ? '' : String(k);
set(k, v) { v = v == null ? '' : String(v);
k = k == null ? '' : String(k); this.pool.putAttrib([k, v]);
v = v == null ? '' : String(v); return super.set(k, v);
this.pool.putAttrib([k, v]); }
return super.set(k, v); toString() {
} return attributes.attribsToString(attributes.sort([...this]), this.pool);
}
toString() { /**
return attributes.attribsToString(attributes.sort([...this]), this.pool); * @param {Iterable<Attribute>} entries - [key, value] pairs to insert into this map.
} * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the
* key is removed from this map (if present).
/** * @returns {AttributeMap} `this` (for chaining).
* @param {Iterable<Attribute>} entries - [key, value] pairs to insert into this map. */
* @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the update(entries, emptyValueIsDelete = false) {
* key is removed from this map (if present). for (let [k, v] of entries) {
* @returns {AttributeMap} `this` (for chaining). k = k == null ? '' : String(k);
*/ v = v == null ? '' : String(v);
update(entries, emptyValueIsDelete = false) { if (!v && emptyValueIsDelete) {
for (let [k, v] of entries) { this.delete(k);
k = k == null ? '' : String(k); }
v = v == null ? '' : String(v); else {
if (!v && emptyValueIsDelete) { this.set(k, v);
this.delete(k); }
} else { }
this.set(k, v); return this;
} }
/**
* @param {AttributeString} str - The attribute string identifying the attributes to insert into
* this map.
* @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the
* key is removed from this map (if present).
* @returns {AttributeMap} `this` (for chaining).
*/
updateFromString(str, emptyValueIsDelete = false) {
return this.update(attributes.attribsFromString(str, this.pool), emptyValueIsDelete);
} }
return this;
}
/**
* @param {AttributeString} str - The attribute string identifying the attributes to insert into
* this map.
* @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the
* key is removed from this map (if present).
* @returns {AttributeMap} `this` (for chaining).
*/
updateFromString(str, emptyValueIsDelete = false) {
return this.update(attributes.attribsFromString(str, this.pool), emptyValueIsDelete);
}
} }
export default AttributeMap;
module.exports = AttributeMap;

File diff suppressed because it is too large Load diff

View file

@ -1,52 +1,28 @@
'use strict'; 'use strict';
export const buildRemoveRange = (rep, builder, start, end) => {
/** const startLineOffset = rep.lines.offsetOfIndex(start[0]);
* This module contains several helper Functions to build Changesets const endLineOffset = rep.lines.offsetOfIndex(end[0]);
* based on a SkipList if (end[0] > start[0]) {
*/ builder.remove(endLineOffset - startLineOffset - start[1], end[0] - start[0]);
builder.remove(end[1]);
/** }
* Copyright 2009 Google Inc. else {
* builder.remove(end[1] - start[1]);
* Licensed under the Apache License, Version 2.0 (the "License"); }
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
exports.buildRemoveRange = (rep, builder, start, end) => {
const startLineOffset = rep.lines.offsetOfIndex(start[0]);
const endLineOffset = rep.lines.offsetOfIndex(end[0]);
if (end[0] > start[0]) {
builder.remove(endLineOffset - startLineOffset - start[1], end[0] - start[0]);
builder.remove(end[1]);
} else {
builder.remove(end[1] - start[1]);
}
}; };
export const buildKeepRange = (rep, builder, start, end, attribs, pool) => {
exports.buildKeepRange = (rep, builder, start, end, attribs, pool) => { const startLineOffset = rep.lines.offsetOfIndex(start[0]);
const startLineOffset = rep.lines.offsetOfIndex(start[0]); const endLineOffset = rep.lines.offsetOfIndex(end[0]);
const endLineOffset = rep.lines.offsetOfIndex(end[0]); if (end[0] > start[0]) {
builder.keep(endLineOffset - startLineOffset - start[1], end[0] - start[0], attribs, pool);
if (end[0] > start[0]) { builder.keep(end[1], 0, attribs, pool);
builder.keep(endLineOffset - startLineOffset - start[1], end[0] - start[0], attribs, pool); }
builder.keep(end[1], 0, attribs, pool); else {
} else { builder.keep(end[1] - start[1], 0, attribs, pool);
builder.keep(end[1] - start[1], 0, attribs, pool); }
}
}; };
export const buildKeepToStartOfRange = (rep, builder, start) => {
exports.buildKeepToStartOfRange = (rep, builder, start) => { const startLineOffset = rep.lines.offsetOfIndex(start[0]);
const startLineOffset = rep.lines.offsetOfIndex(start[0]); builder.keep(startLineOffset, start[0]);
builder.keep(start[1]);
builder.keep(startLineOffset, start[0]);
builder.keep(start[1]);
}; };

View file

@ -1,7 +1,6 @@
import { padutils } from "./pad_utils.js";
'use strict'; 'use strict';
const { padutils: { warnDeprecated } } = { padutils };
const {padutils: {warnDeprecated}} = require('./pad_utils');
/** /**
* Represents a chat message stored in the database and transmitted among users. Plugins can extend * Represents a chat message stored in the database and transmitted among users. Plugins can extend
* the object with additional properties. * the object with additional properties.
@ -9,90 +8,84 @@ const {padutils: {warnDeprecated}} = require('./pad_utils');
* Supports serialization to JSON. * Supports serialization to JSON.
*/ */
class ChatMessage { class ChatMessage {
static fromObject(obj) { static fromObject(obj) {
// The userId property was renamed to authorId, and userName was renamed to displayName. Accept // The userId property was renamed to authorId, and userName was renamed to displayName. Accept
// the old names in case the db record was written by an older version of Etherpad. // the old names in case the db record was written by an older version of Etherpad.
obj = Object.assign({}, obj); // Don't mutate the caller's object. obj = Object.assign({}, obj); // Don't mutate the caller's object.
if ('userId' in obj && !('authorId' in obj)) obj.authorId = obj.userId; if ('userId' in obj && !('authorId' in obj))
delete obj.userId; obj.authorId = obj.userId;
if ('userName' in obj && !('displayName' in obj)) obj.displayName = obj.userName; delete obj.userId;
delete obj.userName; if ('userName' in obj && !('displayName' in obj))
return Object.assign(new ChatMessage(), obj); obj.displayName = obj.userName;
} delete obj.userName;
return Object.assign(new ChatMessage(), obj);
/** }
* @param {?string} [text] - Initial value of the `text` property.
* @param {?string} [authorId] - Initial value of the `authorId` property.
* @param {?number} [time] - Initial value of the `time` property.
*/
constructor(text = null, authorId = null, time = null) {
/** /**
* The raw text of the user's chat message (before any rendering or processing). * @param {?string} [text] - Initial value of the `text` property.
* * @param {?string} [authorId] - Initial value of the `authorId` property.
* @type {?string} * @param {?number} [time] - Initial value of the `time` property.
*/ */
this.text = text; constructor(text = null, authorId = null, time = null) {
/**
* The raw text of the user's chat message (before any rendering or processing).
*
* @type {?string}
*/
this.text = text;
/**
* The user's author ID.
*
* @type {?string}
*/
this.authorId = authorId;
/**
* The message's timestamp, as milliseconds since epoch.
*
* @type {?number}
*/
this.time = time;
/**
* The user's display name.
*
* @type {?string}
*/
this.displayName = null;
}
/** /**
* The user's author ID. * Alias of `authorId`, for compatibility with old plugins.
* *
* @type {?string} * @deprecated Use `authorId` instead.
* @type {string}
*/ */
this.authorId = authorId; get userId() {
warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead');
return this.authorId;
}
set userId(val) {
warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead');
this.authorId = val;
}
/** /**
* The message's timestamp, as milliseconds since epoch. * Alias of `displayName`, for compatibility with old plugins.
* *
* @type {?number} * @deprecated Use `displayName` instead.
* @type {string}
*/ */
this.time = time; get userName() {
warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead');
/** return this.displayName;
* The user's display name. }
* set userName(val) {
* @type {?string} warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead');
*/ this.displayName = val;
this.displayName = null; }
} // TODO: Delete this method once users are unlikely to roll back to a version of Etherpad that
// doesn't support authorId and displayName.
/** toJSON() {
* Alias of `authorId`, for compatibility with old plugins. const { authorId, displayName, ...obj } = this;
* obj.userId = authorId;
* @deprecated Use `authorId` instead. obj.userName = displayName;
* @type {string} return obj;
*/ }
get userId() {
warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead');
return this.authorId;
}
set userId(val) {
warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead');
this.authorId = val;
}
/**
* Alias of `displayName`, for compatibility with old plugins.
*
* @deprecated Use `displayName` instead.
* @type {string}
*/
get userName() {
warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead');
return this.displayName;
}
set userName(val) {
warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead');
this.displayName = val;
}
// TODO: Delete this method once users are unlikely to roll back to a version of Etherpad that
// doesn't support authorId and displayName.
toJSON() {
const {authorId, displayName, ...obj} = this;
obj.userId = authorId;
obj.userName = displayName;
return obj;
}
} }
export default ChatMessage;
module.exports = ChatMessage;

File diff suppressed because it is too large Load diff

View file

@ -1,180 +1,139 @@
// Autosize 1.13 - jQuery plugin for textareas // Autosize 1.13 - jQuery plugin for textareas
// (c) 2012 Jack Moore - jacklmoore.com // (c) 2012 Jack Moore - jacklmoore.com
// license: www.opensource.org/licenses/mit-license.php // license: www.opensource.org/licenses/mit-license.php
(function ($) { (function ($) {
var var defaults = {
defaults = { className: 'autosizejs',
className: 'autosizejs', append: "",
append: "", callback: false
callback: false }, hidden = 'hidden', borderBox = 'border-box', lineHeight = 'lineHeight', copy = '<textarea tabindex="-1" style="position:absolute; top:-9999px; left:-9999px; right:auto; bottom:auto; -moz-box-sizing:content-box; -webkit-box-sizing:content-box; box-sizing:content-box; word-wrap:break-word; height:0 !important; min-height:0 !important; overflow:hidden;"/>',
}, // line-height is omitted because IE7/IE8 doesn't return the correct value.
hidden = 'hidden', copyStyle = [
borderBox = 'border-box', 'fontFamily',
lineHeight = 'lineHeight', 'fontSize',
copy = '<textarea tabindex="-1" style="position:absolute; top:-9999px; left:-9999px; right:auto; bottom:auto; -moz-box-sizing:content-box; -webkit-box-sizing:content-box; box-sizing:content-box; word-wrap:break-word; height:0 !important; min-height:0 !important; overflow:hidden;"/>', 'fontWeight',
// line-height is omitted because IE7/IE8 doesn't return the correct value. 'fontStyle',
copyStyle = [ 'letterSpacing',
'fontFamily', 'textTransform',
'fontSize', 'wordSpacing',
'fontWeight', 'textIndent'
'fontStyle', ], oninput = 'oninput', onpropertychange = 'onpropertychange', test = $(copy)[0];
'letterSpacing', // For testing support in old FireFox
'textTransform', test.setAttribute(oninput, "return");
'wordSpacing', if ($.isFunction(test[oninput]) || onpropertychange in test) {
'textIndent' // test that line-height can be accurately copied to avoid
], // incorrect value reporting in old IE and old Opera
oninput = 'oninput', $(test).css(lineHeight, '99px');
onpropertychange = 'onpropertychange', if ($(test).css(lineHeight) === '99px') {
test = $(copy)[0]; copyStyle.push(lineHeight);
}
// For testing support in old FireFox $.fn.autosize = function (options) {
test.setAttribute(oninput, "return"); options = $.extend({}, defaults, options || {});
return this.each(function () {
if ($.isFunction(test[oninput]) || onpropertychange in test) { var ta = this, $ta = $(ta), mirror, minHeight = $ta.height(), maxHeight = parseInt($ta.css('maxHeight'), 10), active, i = copyStyle.length, resize, boxOffset = 0, value = ta.value, callback = $.isFunction(options.callback);
if ($ta.css('box-sizing') === borderBox || $ta.css('-moz-box-sizing') === borderBox || $ta.css('-webkit-box-sizing') === borderBox) {
// test that line-height can be accurately copied to avoid boxOffset = $ta.outerHeight() - $ta.height();
// incorrect value reporting in old IE and old Opera }
$(test).css(lineHeight, '99px'); if ($ta.data('mirror') || $ta.data('ismirror')) {
if ($(test).css(lineHeight) === '99px') { // if autosize has already been applied, exit.
copyStyle.push(lineHeight); // if autosize is being applied to a mirror element, exit.
} return;
}
$.fn.autosize = function (options) { else {
options = $.extend({}, defaults, options || {}); mirror = $(copy).data('ismirror', true).addClass(options.className)[0];
resize = $ta.css('resize') === 'none' ? 'none' : 'horizontal';
return this.each(function () { $ta.data('mirror', $(mirror)).css({
var overflow: hidden,
ta = this, overflowY: hidden,
$ta = $(ta), wordWrap: 'break-word',
mirror, resize: resize
minHeight = $ta.height(), });
maxHeight = parseInt($ta.css('maxHeight'), 10), }
active, // Opera returns '-1px' when max-height is set to 'none'.
i = copyStyle.length, maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4;
resize, // Using mainly bare JS in this function because it is going
boxOffset = 0, // to fire very often while typing, and needs to very efficient.
value = ta.value, function adjust() {
callback = $.isFunction(options.callback); var height, overflow, original;
// the active flag keeps IE from tripping all over itself. Otherwise
if ($ta.css('box-sizing') === borderBox || $ta.css('-moz-box-sizing') === borderBox || $ta.css('-webkit-box-sizing') === borderBox){ // actions in the adjust function will cause IE to call adjust again.
boxOffset = $ta.outerHeight() - $ta.height(); if (!active) {
} active = true;
mirror.value = ta.value + options.append;
if ($ta.data('mirror') || $ta.data('ismirror')) { mirror.style.overflowY = ta.style.overflowY;
// if autosize has already been applied, exit. original = parseInt(ta.style.height, 10);
// if autosize is being applied to a mirror element, exit. // Update the width in case the original textarea width has changed
return; mirror.style.width = $ta.css('width');
} else { // Needed for IE to reliably return the correct scrollHeight
mirror = $(copy).data('ismirror', true).addClass(options.className)[0]; mirror.scrollTop = 0;
// Set a very high value for scrollTop to be sure the
resize = $ta.css('resize') === 'none' ? 'none' : 'horizontal'; // mirror is scrolled all the way to the bottom.
mirror.scrollTop = 9e4;
$ta.data('mirror', $(mirror)).css({ height = mirror.scrollTop;
overflow: hidden, overflow = hidden;
overflowY: hidden, if (height > maxHeight) {
wordWrap: 'break-word', height = maxHeight;
resize: resize overflow = 'scroll';
}); }
} else if (height < minHeight) {
height = minHeight;
// Opera returns '-1px' when max-height is set to 'none'. }
maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4; height += boxOffset;
ta.style.overflowY = overflow;
// Using mainly bare JS in this function because it is going if (original !== height) {
// to fire very often while typing, and needs to very efficient. ta.style.height = height + 'px';
function adjust() { if (callback) {
var height, overflow, original; options.callback.call(ta);
}
// the active flag keeps IE from tripping all over itself. Otherwise }
// actions in the adjust function will cause IE to call adjust again. // This small timeout gives IE a chance to draw it's scrollbar
if (!active) { // before adjust can be run again (prevents an infinite loop).
active = true; setTimeout(function () {
mirror.value = ta.value + options.append; active = false;
mirror.style.overflowY = ta.style.overflowY; }, 1);
original = parseInt(ta.style.height,10); }
}
// Update the width in case the original textarea width has changed // mirror is a duplicate textarea located off-screen that
mirror.style.width = $ta.css('width'); // is automatically updated to contain the same text as the
// original textarea. mirror always has a height of 0.
// Needed for IE to reliably return the correct scrollHeight // This gives a cross-browser supported way getting the actual
mirror.scrollTop = 0; // height of the text, through the scrollTop property.
while (i--) {
// Set a very high value for scrollTop to be sure the mirror.style[copyStyle[i]] = $ta.css(copyStyle[i]);
// mirror is scrolled all the way to the bottom. }
mirror.scrollTop = 9e4; $('body').append(mirror);
if (onpropertychange in ta) {
height = mirror.scrollTop; if (oninput in ta) {
overflow = hidden; // Detects IE9. IE9 does not fire onpropertychange or oninput for deletions,
if (height > maxHeight) { // so binding to onkeyup to catch most of those occassions. There is no way that I
height = maxHeight; // know of to detect something like 'cut' in IE9.
overflow = 'scroll'; ta[oninput] = ta.onkeyup = adjust;
} else if (height < minHeight) { }
height = minHeight; else {
} // IE7 / IE8
height += boxOffset; ta[onpropertychange] = adjust;
ta.style.overflowY = overflow; }
}
if (original !== height) { else {
ta.style.height = height + 'px'; // Modern Browsers
if (callback) { ta[oninput] = adjust;
options.callback.call(ta); // The textarea overflow is now hidden. But Chrome doesn't reflow the text after the scrollbars are removed.
} // This is a hack to get Chrome to reflow it's text.
} ta.value = '';
ta.value = value;
// This small timeout gives IE a chance to draw it's scrollbar }
// before adjust can be run again (prevents an infinite loop). $(window).resize(adjust);
setTimeout(function () { // Allow for manual triggering if needed.
active = false; $ta.bind('autosize', adjust);
}, 1); // Call adjust in case the textarea already contains text.
} adjust();
} });
};
// mirror is a duplicate textarea located off-screen that }
// is automatically updated to contain the same text as the else {
// original textarea. mirror always has a height of 0. // Makes no changes for older browsers (FireFox3- and Safari4-)
// This gives a cross-browser supported way getting the actual $.fn.autosize = function (callback) {
// height of the text, through the scrollTop property. return this;
while (i--) { };
mirror.style[copyStyle[i]] = $ta.css(copyStyle[i]); }
} }(jQuery));
$('body').append(mirror);
if (onpropertychange in ta) {
if (oninput in ta) {
// Detects IE9. IE9 does not fire onpropertychange or oninput for deletions,
// so binding to onkeyup to catch most of those occassions. There is no way that I
// know of to detect something like 'cut' in IE9.
ta[oninput] = ta.onkeyup = adjust;
} else {
// IE7 / IE8
ta[onpropertychange] = adjust;
}
} else {
// Modern Browsers
ta[oninput] = adjust;
// The textarea overflow is now hidden. But Chrome doesn't reflow the text after the scrollbars are removed.
// This is a hack to get Chrome to reflow it's text.
ta.value = '';
ta.value = value;
}
$(window).resize(adjust);
// Allow for manual triggering if needed.
$ta.bind('autosize', adjust);
// Call adjust in case the textarea already contains text.
adjust();
});
};
} else {
// Makes no changes for older browsers (FireFox3- and Safari4-)
$.fn.autosize = function (callback) {
return this;
};
}
}(jQuery));

View file

@ -1,61 +1,50 @@
/*! JSON.minify() /*! JSON.minify()
v0.1 (c) Kyle Simpson v0.1 (c) Kyle Simpson
MIT License MIT License
*/ */
(function (global) {
(function(global){ if (typeof global.JSON == "undefined" || !global.JSON) {
if (typeof global.JSON == "undefined" || !global.JSON) { global.JSON = {};
global.JSON = {}; }
} global.JSON.minify = function (json) {
var tokenizer = /"|(\/\*)|(\*\/)|(\/\/)|\n|\r/g, in_string = false, in_multiline_comment = false, in_singleline_comment = false, tmp, tmp2, new_str = [], ns = 0, from = 0, lc, rc;
global.JSON.minify = function(json) { tokenizer.lastIndex = 0;
while (tmp = tokenizer.exec(json)) {
var tokenizer = /"|(\/\*)|(\*\/)|(\/\/)|\n|\r/g, lc = RegExp.leftContext;
in_string = false, rc = RegExp.rightContext;
in_multiline_comment = false, if (!in_multiline_comment && !in_singleline_comment) {
in_singleline_comment = false, tmp2 = lc.substring(from);
tmp, tmp2, new_str = [], ns = 0, from = 0, lc, rc if (!in_string) {
; tmp2 = tmp2.replace(/(\n|\r|\s)*/g, "");
}
tokenizer.lastIndex = 0; new_str[ns++] = tmp2;
}
while (tmp = tokenizer.exec(json)) { from = tokenizer.lastIndex;
lc = RegExp.leftContext; if (tmp[0] == "\"" && !in_multiline_comment && !in_singleline_comment) {
rc = RegExp.rightContext; tmp2 = lc.match(/(\\)*$/);
if (!in_multiline_comment && !in_singleline_comment) { if (!in_string || !tmp2 || (tmp2[0].length % 2) == 0) { // start of string with ", or unescaped " character found to end string
tmp2 = lc.substring(from); in_string = !in_string;
if (!in_string) { }
tmp2 = tmp2.replace(/(\n|\r|\s)*/g,""); from--; // include " character in next catch
} rc = json.substring(from);
new_str[ns++] = tmp2; }
} else if (tmp[0] == "/*" && !in_string && !in_multiline_comment && !in_singleline_comment) {
from = tokenizer.lastIndex; in_multiline_comment = true;
}
if (tmp[0] == "\"" && !in_multiline_comment && !in_singleline_comment) { else if (tmp[0] == "*/" && !in_string && in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.match(/(\\)*$/); in_multiline_comment = false;
if (!in_string || !tmp2 || (tmp2[0].length % 2) == 0) { // start of string with ", or unescaped " character found to end string }
in_string = !in_string; else if (tmp[0] == "//" && !in_string && !in_multiline_comment && !in_singleline_comment) {
} in_singleline_comment = true;
from--; // include " character in next catch }
rc = json.substring(from); else if ((tmp[0] == "\n" || tmp[0] == "\r") && !in_string && !in_multiline_comment && in_singleline_comment) {
} in_singleline_comment = false;
else if (tmp[0] == "/*" && !in_string && !in_multiline_comment && !in_singleline_comment) { }
in_multiline_comment = true; else if (!in_multiline_comment && !in_singleline_comment && !(/\n|\r|\s/.test(tmp[0]))) {
} new_str[ns++] = tmp[0];
else if (tmp[0] == "*/" && !in_string && in_multiline_comment && !in_singleline_comment) { }
in_multiline_comment = false; }
} new_str[ns++] = rc;
else if (tmp[0] == "//" && !in_string && !in_multiline_comment && !in_singleline_comment) { return new_str.join("");
in_singleline_comment = true; };
}
else if ((tmp[0] == "\n" || tmp[0] == "\r") && !in_string && !in_multiline_comment && in_singleline_comment) {
in_singleline_comment = false;
}
else if (!in_multiline_comment && !in_singleline_comment && !(/\n|\r|\s/.test(tmp[0]))) {
new_str[ns++] = tmp[0];
}
}
new_str[ns++] = rc;
return new_str.join("");
};
})(this); })(this);

View file

@ -1,273 +1,247 @@
'use strict'; 'use strict';
/* global socketio */ /* global socketio */
$(document).ready(() => { $(document).ready(() => {
const socket = socketio.connect('..', '/pluginfw/installer'); const socket = socketio.connect('..', '/pluginfw/installer');
socket.on('disconnect', (reason) => { socket.on('disconnect', (reason) => {
// The socket.io client will automatically try to reconnect for all reasons other than "io // The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect". // server disconnect".
if (reason === 'io server disconnect') socket.connect(); if (reason === 'io server disconnect')
}); socket.connect();
const search = (searchTerm, limit) => {
if (search.searchTerm !== searchTerm) {
search.offset = 0;
search.results = [];
search.end = false;
}
limit = limit ? limit : search.limit;
search.searchTerm = searchTerm;
socket.emit('search', {
searchTerm,
offset: search.offset,
limit,
sortBy: search.sortBy,
sortDir: search.sortDir,
}); });
search.offset += limit; const search = (searchTerm, limit) => {
if (search.searchTerm !== searchTerm) {
$('#search-progress').show(); search.offset = 0;
search.messages.show('fetching'); search.results = [];
search.searching = true; search.end = false;
};
search.searching = false;
search.offset = 0;
search.limit = 999;
search.results = [];
search.sortBy = 'name';
search.sortDir = /* DESC?*/true;
search.end = true;// have we received all results already?
search.messages = {
show: (msg) => {
// $('.search-results .messages').show()
$(`.search-results .messages .${msg}`).show();
$(`.search-results .messages .${msg} *`).show();
},
hide: (msg) => {
$('.search-results .messages').hide();
$(`.search-results .messages .${msg}`).hide();
$(`.search-results .messages .${msg} *`).hide();
},
};
const installed = {
progress: {
show: (plugin, msg) => {
$(`.installed-results .${plugin} .progress`).show();
$(`.installed-results .${plugin} .progress .message`).text(msg);
if ($(window).scrollTop() > $(`.${plugin}`).offset().top) {
$(window).scrollTop($(`.${plugin}`).offset().top - 100);
} }
}, limit = limit ? limit : search.limit;
hide: (plugin) => { search.searchTerm = searchTerm;
$(`.installed-results .${plugin} .progress`).hide(); socket.emit('search', {
$(`.installed-results .${plugin} .progress .message`).text(''); searchTerm,
}, offset: search.offset,
}, limit,
messages: { sortBy: search.sortBy,
show: (msg) => { sortDir: search.sortDir,
$('.installed-results .messages').show(); });
$(`.installed-results .messages .${msg}`).show(); search.offset += limit;
}, $('#search-progress').show();
hide: (msg) => { search.messages.show('fetching');
$('.installed-results .messages').hide(); search.searching = true;
$(`.installed-results .messages .${msg}`).hide(); };
},
},
list: [],
};
const displayPluginList = (plugins, container, template) => {
plugins.forEach((plugin) => {
const row = template.clone();
for (const attr in plugin) {
if (attr === 'name') { // Hack to rewrite URLS into name
const link = $('<a>')
.attr('href', `https://npmjs.org/package/${plugin.name}`)
.attr('plugin', 'Plugin details')
.attr('rel', 'noopener noreferrer')
.attr('target', '_blank')
.text(plugin.name.substr(3));
row.find('.name').append(link);
} else {
row.find(`.${attr}`).text(plugin[attr]);
}
}
row.find('.version').text(plugin.version);
row.addClass(plugin.name);
row.data('plugin', plugin.name);
container.append(row);
});
updateHandlers();
};
const sortPluginList = (plugins, property, /* ASC?*/dir) => plugins.sort((a, b) => {
if (a[property] < b[property]) return dir ? -1 : 1;
if (a[property] > b[property]) return dir ? 1 : -1;
// a must be equal to b
return 0;
});
const updateHandlers = () => {
// Search
$('#search-query').unbind('keyup').keyup(() => {
search($('#search-query').val());
});
// Prevent form submit
$('#search-query').parent().bind('submit', () => false);
// update & install
$('.do-install, .do-update').unbind('click').click(function (e) {
const $row = $(e.target).closest('tr');
const plugin = $row.data('plugin');
if ($(this).hasClass('do-install')) {
$row.remove().appendTo('#installed-plugins');
installed.progress.show(plugin, 'Installing');
} else {
installed.progress.show(plugin, 'Updating');
}
socket.emit('install', plugin);
installed.messages.hide('nothing-installed');
});
// uninstall
$('.do-uninstall').unbind('click').click((e) => {
const $row = $(e.target).closest('tr');
const pluginName = $row.data('plugin');
socket.emit('uninstall', pluginName);
installed.progress.show(pluginName, 'Uninstalling');
installed.list = installed.list.filter((plugin) => plugin.name !== pluginName);
});
// Sort
$('.sort.up').unbind('click').click(function () {
search.sortBy = $(this).attr('data-label').toLowerCase();
search.sortDir = false;
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
$('.sort.down, .sort.none').unbind('click').click(function () {
search.sortBy = $(this).attr('data-label').toLowerCase();
search.sortDir = true;
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
};
socket.on('results:search', (data) => {
if (!data.results.length) search.end = true;
if (data.query.offset === 0) search.results = [];
search.messages.hide('nothing-found');
search.messages.hide('fetching');
$('#search-query').removeAttr('disabled');
console.log('got search results', data);
// add to results
search.results = search.results.concat(data.results);
// Update sorting head
$('.sort')
.removeClass('up down')
.addClass('none');
$(`.search-results thead th[data-label=${data.query.sortBy}]`)
.removeClass('none')
.addClass(data.query.sortDir ? 'up' : 'down');
// re-render search results
const searchWidget = $('.search-results');
searchWidget.find('.results *').remove();
if (search.results.length > 0) {
displayPluginList(
search.results, searchWidget.find('.results'), searchWidget.find('.template tr'));
} else {
search.messages.show('nothing-found');
}
search.messages.hide('fetching');
$('#search-progress').hide();
search.searching = false; search.searching = false;
});
socket.on('results:installed', (data) => {
installed.messages.hide('fetching');
installed.messages.hide('nothing-installed');
installed.list = data.installed;
sortPluginList(installed.list, 'name', /* ASC?*/true);
// filter out epl
installed.list = installed.list.filter((plugin) => plugin.name !== 'ep_etherpad-lite');
// remove all installed plugins (leave plugins that are still being installed)
installed.list.forEach((plugin) => {
$(`#installed-plugins .${plugin.name}`).remove();
});
if (installed.list.length > 0) {
displayPluginList(installed.list, $('#installed-plugins'), $('#installed-plugin-template'));
socket.emit('checkUpdates');
} else {
installed.messages.show('nothing-installed');
}
});
socket.on('results:updatable', (data) => {
data.updatable.forEach((pluginName) => {
const actions = $(`#installed-plugins > tr.${pluginName} .actions`);
actions.find('.do-update').remove();
actions.append(
$('<input>').addClass('do-update').attr('type', 'button').attr('value', 'Update'));
});
updateHandlers();
});
socket.on('finished:install', (data) => {
if (data.error) {
if (data.code === 'EPEERINVALID') {
alert("This plugin requires that you update Etherpad so it can operate in it's true glory");
}
alert(`An error occurred while installing ${data.plugin} \n${data.error}`);
$(`#installed-plugins .${data.plugin}`).remove();
}
socket.emit('getInstalled');
// update search results
search.offset = 0; search.offset = 0;
search(search.searchTerm, search.results.length); search.limit = 999;
search.results = []; search.results = [];
}); search.sortBy = 'name';
search.sortDir = /* DESC?*/ true;
socket.on('finished:uninstall', (data) => { search.end = true; // have we received all results already?
if (data.error) { search.messages = {
alert(`An error occurred while uninstalling the ${data.plugin} \n${data.error}`); show: (msg) => {
} // $('.search-results .messages').show()
$(`.search-results .messages .${msg}`).show();
// remove plugin from installed list $(`.search-results .messages .${msg} *`).show();
$(`#installed-plugins .${data.plugin}`).remove(); },
hide: (msg) => {
socket.emit('getInstalled'); $('.search-results .messages').hide();
$(`.search-results .messages .${msg}`).hide();
// update search results $(`.search-results .messages .${msg} *`).hide();
search.offset = 0; },
search(search.searchTerm, search.results.length); };
search.results = []; const installed = {
}); progress: {
show: (plugin, msg) => {
socket.on('connect', () => { $(`.installed-results .${plugin} .progress`).show();
updateHandlers(); $(`.installed-results .${plugin} .progress .message`).text(msg);
socket.emit('getInstalled'); if ($(window).scrollTop() > $(`.${plugin}`).offset().top) {
search.searchTerm = null; $(window).scrollTop($(`.${plugin}`).offset().top - 100);
search($('#search-query').val()); }
}); },
hide: (plugin) => {
// check for updates every 5mins $(`.installed-results .${plugin} .progress`).hide();
setInterval(() => { $(`.installed-results .${plugin} .progress .message`).text('');
socket.emit('checkUpdates'); },
}, 1000 * 60 * 5); },
messages: {
show: (msg) => {
$('.installed-results .messages').show();
$(`.installed-results .messages .${msg}`).show();
},
hide: (msg) => {
$('.installed-results .messages').hide();
$(`.installed-results .messages .${msg}`).hide();
},
},
list: [],
};
const displayPluginList = (plugins, container, template) => {
plugins.forEach((plugin) => {
const row = template.clone();
for (const attr in plugin) {
if (attr === 'name') { // Hack to rewrite URLS into name
const link = $('<a>')
.attr('href', `https://npmjs.org/package/${plugin.name}`)
.attr('plugin', 'Plugin details')
.attr('rel', 'noopener noreferrer')
.attr('target', '_blank')
.text(plugin.name.substr(3));
row.find('.name').append(link);
}
else {
row.find(`.${attr}`).text(plugin[attr]);
}
}
row.find('.version').text(plugin.version);
row.addClass(plugin.name);
row.data('plugin', plugin.name);
container.append(row);
});
updateHandlers();
};
const sortPluginList = (plugins, property, /* ASC?*/ dir) => plugins.sort((a, b) => {
if (a[property] < b[property])
return dir ? -1 : 1;
if (a[property] > b[property])
return dir ? 1 : -1;
// a must be equal to b
return 0;
});
const updateHandlers = () => {
// Search
$('#search-query').unbind('keyup').keyup(() => {
search($('#search-query').val());
});
// Prevent form submit
$('#search-query').parent().bind('submit', () => false);
// update & install
$('.do-install, .do-update').unbind('click').click(function (e) {
const $row = $(e.target).closest('tr');
const plugin = $row.data('plugin');
if ($(this).hasClass('do-install')) {
$row.remove().appendTo('#installed-plugins');
installed.progress.show(plugin, 'Installing');
}
else {
installed.progress.show(plugin, 'Updating');
}
socket.emit('install', plugin);
installed.messages.hide('nothing-installed');
});
// uninstall
$('.do-uninstall').unbind('click').click((e) => {
const $row = $(e.target).closest('tr');
const pluginName = $row.data('plugin');
socket.emit('uninstall', pluginName);
installed.progress.show(pluginName, 'Uninstalling');
installed.list = installed.list.filter((plugin) => plugin.name !== pluginName);
});
// Sort
$('.sort.up').unbind('click').click(function () {
search.sortBy = $(this).attr('data-label').toLowerCase();
search.sortDir = false;
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
$('.sort.down, .sort.none').unbind('click').click(function () {
search.sortBy = $(this).attr('data-label').toLowerCase();
search.sortDir = true;
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
};
socket.on('results:search', (data) => {
if (!data.results.length)
search.end = true;
if (data.query.offset === 0)
search.results = [];
search.messages.hide('nothing-found');
search.messages.hide('fetching');
$('#search-query').removeAttr('disabled');
console.log('got search results', data);
// add to results
search.results = search.results.concat(data.results);
// Update sorting head
$('.sort')
.removeClass('up down')
.addClass('none');
$(`.search-results thead th[data-label=${data.query.sortBy}]`)
.removeClass('none')
.addClass(data.query.sortDir ? 'up' : 'down');
// re-render search results
const searchWidget = $('.search-results');
searchWidget.find('.results *').remove();
if (search.results.length > 0) {
displayPluginList(search.results, searchWidget.find('.results'), searchWidget.find('.template tr'));
}
else {
search.messages.show('nothing-found');
}
search.messages.hide('fetching');
$('#search-progress').hide();
search.searching = false;
});
socket.on('results:installed', (data) => {
installed.messages.hide('fetching');
installed.messages.hide('nothing-installed');
installed.list = data.installed;
sortPluginList(installed.list, 'name', /* ASC?*/ true);
// filter out epl
installed.list = installed.list.filter((plugin) => plugin.name !== 'ep_etherpad-lite');
// remove all installed plugins (leave plugins that are still being installed)
installed.list.forEach((plugin) => {
$(`#installed-plugins .${plugin.name}`).remove();
});
if (installed.list.length > 0) {
displayPluginList(installed.list, $('#installed-plugins'), $('#installed-plugin-template'));
socket.emit('checkUpdates');
}
else {
installed.messages.show('nothing-installed');
}
});
socket.on('results:updatable', (data) => {
data.updatable.forEach((pluginName) => {
const actions = $(`#installed-plugins > tr.${pluginName} .actions`);
actions.find('.do-update').remove();
actions.append($('<input>').addClass('do-update').attr('type', 'button').attr('value', 'Update'));
});
updateHandlers();
});
socket.on('finished:install', (data) => {
if (data.error) {
if (data.code === 'EPEERINVALID') {
alert("This plugin requires that you update Etherpad so it can operate in it's true glory");
}
alert(`An error occurred while installing ${data.plugin} \n${data.error}`);
$(`#installed-plugins .${data.plugin}`).remove();
}
socket.emit('getInstalled');
// update search results
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
socket.on('finished:uninstall', (data) => {
if (data.error) {
alert(`An error occurred while uninstalling the ${data.plugin} \n${data.error}`);
}
// remove plugin from installed list
$(`#installed-plugins .${data.plugin}`).remove();
socket.emit('getInstalled');
// update search results
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
socket.on('connect', () => {
updateHandlers();
socket.emit('getInstalled');
search.searchTerm = null;
search($('#search-query').val());
});
// check for updates every 5mins
setInterval(() => {
socket.emit('checkUpdates');
}, 1000 * 60 * 5);
}); });

View file

@ -1,69 +1,63 @@
'use strict'; 'use strict';
$(document).ready(() => { $(document).ready(() => {
const socket = window.socketio.connect('..', '/settings'); const socket = window.socketio.connect('..', '/settings');
socket.on('connect', () => {
socket.on('connect', () => { socket.emit('load');
socket.emit('load'); });
}); socket.on('disconnect', (reason) => {
// The socket.io client will automatically try to reconnect for all reasons other than "io
socket.on('disconnect', (reason) => { // server disconnect".
// The socket.io client will automatically try to reconnect for all reasons other than "io if (reason === 'io server disconnect')
// server disconnect". socket.connect();
if (reason === 'io server disconnect') socket.connect(); });
}); socket.on('settings', (settings) => {
/* Check whether the settings.json is authorized to be viewed */
socket.on('settings', (settings) => { if (settings.results === 'NOT_ALLOWED') {
/* Check whether the settings.json is authorized to be viewed */ $('.innerwrapper').hide();
if (settings.results === 'NOT_ALLOWED') { $('.innerwrapper-err').show();
$('.innerwrapper').hide(); $('.err-message').html('Settings json is not authorized to be viewed in Admin page!!');
$('.innerwrapper-err').show(); return;
$('.err-message').html('Settings json is not authorized to be viewed in Admin page!!'); }
return; /* Check to make sure the JSON is clean before proceeding */
} if (isJSONClean(settings.results)) {
$('.settings').append(settings.results);
/* Check to make sure the JSON is clean before proceeding */ $('.settings').focus();
if (isJSONClean(settings.results)) { $('.settings').autosize();
$('.settings').append(settings.results); }
$('.settings').focus(); else {
$('.settings').autosize(); alert('Invalid JSON');
} else { }
alert('Invalid JSON'); });
} /* When the admin clicks save Settings check the JSON then send the JSON back to the server */
}); $('#saveSettings').on('click', () => {
const editedSettings = $('.settings').val();
/* When the admin clicks save Settings check the JSON then send the JSON back to the server */ if (isJSONClean(editedSettings)) {
$('#saveSettings').on('click', () => { // JSON is clean so emit it to the server
const editedSettings = $('.settings').val(); socket.emit('saveSettings', $('.settings').val());
if (isJSONClean(editedSettings)) { }
// JSON is clean so emit it to the server else {
socket.emit('saveSettings', $('.settings').val()); alert('Invalid JSON');
} else { $('.settings').focus();
alert('Invalid JSON'); }
$('.settings').focus(); });
} /* Tell Etherpad Server to restart */
}); $('#restartEtherpad').on('click', () => {
socket.emit('restartServer');
/* Tell Etherpad Server to restart */ });
$('#restartEtherpad').on('click', () => { socket.on('saveprogress', (progress) => {
socket.emit('restartServer'); $('#response').show();
}); $('#response').text(progress);
$('#response').fadeOut('slow');
socket.on('saveprogress', (progress) => { });
$('#response').show();
$('#response').text(progress);
$('#response').fadeOut('slow');
});
}); });
const isJSONClean = (data) => { const isJSONClean = (data) => {
let cleanSettings = JSON.minify(data); let cleanSettings = JSON.minify(data);
// this is a bit naive. In theory some key/value might contain the sequences ',]' or ',}' // this is a bit naive. In theory some key/value might contain the sequences ',]' or ',}'
cleanSettings = cleanSettings.replace(',]', ']').replace(',}', '}'); cleanSettings = cleanSettings.replace(',]', ']').replace(',}', '}');
try { try {
return typeof jQuery.parseJSON(cleanSettings) === 'object'; return typeof jQuery.parseJSON(cleanSettings) === 'object';
} catch (e) { }
return false; // the JSON failed to be parsed catch (e) {
} return false; // the JSON failed to be parsed
}
}; };

View file

@ -1,130 +1,45 @@
'use strict'; 'use strict';
// Low-level utilities for manipulating attribute strings. For a high-level API, see AttributeMap.
/**
* A `[key, value]` pair of strings describing a text attribute.
*
* @typedef {[string, string]} Attribute
*/
/**
* A concatenated sequence of zero or more attribute identifiers, each one represented by an
* asterisk followed by a base-36 encoded attribute number.
*
* Examples: '', '*0', '*3*j*z*1q'
*
* @typedef {string} AttributeString
*/
/**
* Converts an attribute string into a sequence of attribute identifier numbers.
*
* WARNING: This only works on attribute strings. It does NOT work on serialized operations or
* changesets.
*
* @param {AttributeString} str - Attribute string.
* @yields {number} The attribute numbers (to look up in the associated pool), in the order they
* appear in `str`.
* @returns {Generator<number>}
*/
exports.decodeAttribString = function* (str) {
const re = /\*([0-9a-z]+)|./gy;
let match;
while ((match = re.exec(str)) != null) {
const [m, n] = match;
if (n == null) throw new Error(`invalid character in attribute string: ${m}`);
yield Number.parseInt(n, 36);
}
};
const checkAttribNum = (n) => { const checkAttribNum = (n) => {
if (typeof n !== 'number') throw new TypeError(`not a number: ${n}`); if (typeof n !== 'number')
if (n < 0) throw new Error(`attribute number is negative: ${n}`); throw new TypeError(`not a number: ${n}`);
if (n !== Math.trunc(n)) throw new Error(`attribute number is not an integer: ${n}`); if (n < 0)
throw new Error(`attribute number is negative: ${n}`);
if (n !== Math.trunc(n))
throw new Error(`attribute number is not an integer: ${n}`);
}; };
export const decodeAttribString = function* (str) {
/** const re = /\*([0-9a-z]+)|./gy;
* Inverse of `decodeAttribString`. let match;
* while ((match = re.exec(str)) != null) {
* @param {Iterable<number>} attribNums - Sequence of attribute numbers. const [m, n] = match;
* @returns {AttributeString} if (n == null)
*/ throw new Error(`invalid character in attribute string: ${m}`);
exports.encodeAttribString = (attribNums) => { yield Number.parseInt(n, 36);
let str = ''; }
for (const n of attribNums) {
checkAttribNum(n);
str += `*${n.toString(36).toLowerCase()}`;
}
return str;
}; };
export const encodeAttribString = (attribNums) => {
/** let str = '';
* Converts a sequence of attribute numbers into a sequence of attributes. for (const n of attribNums) {
* checkAttribNum(n);
* @param {Iterable<number>} attribNums - Attribute numbers to look up in the pool. str += `*${n.toString(36).toLowerCase()}`;
* @param {AttributePool} pool - Attribute pool. }
* @yields {Attribute} The identified attributes, in the same order as `attribNums`. return str;
* @returns {Generator<Attribute>}
*/
exports.attribsFromNums = function* (attribNums, pool) {
for (const n of attribNums) {
checkAttribNum(n);
const attrib = pool.getAttrib(n);
if (attrib == null) throw new Error(`attribute ${n} does not exist in pool`);
yield attrib;
}
}; };
export const attribsFromNums = function* (attribNums, pool) {
/** for (const n of attribNums) {
* Inverse of `attribsFromNums`. checkAttribNum(n);
* const attrib = pool.getAttrib(n);
* @param {Iterable<Attribute>} attribs - Attributes. Any attributes not already in `pool` are if (attrib == null)
* inserted into `pool`. No checking is performed to ensure that the attributes are in the throw new Error(`attribute ${n} does not exist in pool`);
* canonical order and that there are no duplicate keys. (Use an AttributeMap and/or `sort()` if yield attrib;
* required.) }
* @param {AttributePool} pool - Attribute pool.
* @yields {number} The attribute number of each attribute in `attribs`, in order.
* @returns {Generator<number>}
*/
exports.attribsToNums = function* (attribs, pool) {
for (const attrib of attribs) yield pool.putAttrib(attrib);
}; };
export const attribsToNums = function* (attribs, pool) {
/** for (const attrib of attribs)
* Convenience function that is equivalent to `attribsFromNums(decodeAttribString(str), pool)`. yield pool.putAttrib(attrib);
*
* WARNING: This only works on attribute strings. It does NOT work on serialized operations or
* changesets.
*
* @param {AttributeString} str - Attribute string.
* @param {AttributePool} pool - Attribute pool.
* @yields {Attribute} The attributes identified in `str`, in order.
* @returns {Generator<Attribute>}
*/
exports.attribsFromString = function* (str, pool) {
yield* exports.attribsFromNums(exports.decodeAttribString(str), pool);
}; };
export const attribsFromString = function* (str, pool) {
/** yield* exports.attribsFromNums(exports.decodeAttribString(str), pool);
* Inverse of `attribsFromString`. };
* export const attribsToString = (attribs, pool) => exports.encodeAttribString(exports.attribsToNums(attribs, pool));
* @param {Iterable<Attribute>} attribs - Attributes. The attributes to insert into the pool (if export const sort = (attribs) => attribs.sort(([keyA], [keyB]) => (keyA > keyB ? 1 : 0) - (keyA < keyB ? 1 : 0));
* necessary) and encode. No checking is performed to ensure that the attributes are in the
* canonical order and that there are no duplicate keys. (Use an AttributeMap and/or `sort()` if
* required.)
* @param {AttributePool} pool - Attribute pool.
* @returns {AttributeString}
*/
exports.attribsToString =
(attribs, pool) => exports.encodeAttribString(exports.attribsToNums(attribs, pool));
/**
* Sorts the attributes in canonical order. The order of entries with the same attribute name is
* unspecified.
*
* @param {Attribute[]} attribs - Attributes to sort in place.
* @returns {Attribute[]} `attribs` (for chaining).
*/
exports.sort =
(attribs) => attribs.sort(([keyA], [keyB]) => (keyA > keyB ? 1 : 0) - (keyA < keyB ? 1 : 0));

View file

@ -1,48 +1,40 @@
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0 // @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0
/* Copyright 2021 Richard Hansen <rhansen@rhansen.org> */ /* Copyright 2021 Richard Hansen <rhansen@rhansen.org> */
'use strict'; 'use strict';
// Set up an error handler to display errors that happen during page load. This handler will be // Set up an error handler to display errors that happen during page load. This handler will be
// overridden with a nicer handler by setupGlobalExceptionHandler() in pad_utils.js. // overridden with a nicer handler by setupGlobalExceptionHandler() in pad_utils.js.
(() => { (() => {
const originalHandler = window.onerror; const originalHandler = window.onerror;
window.onerror = (...args) => { window.onerror = (...args) => {
const [msg, url, line, col, err] = args; const [msg, url, line, col, err] = args;
// Purge the existing HTML and styles for a consistent view.
// Purge the existing HTML and styles for a consistent view. document.body.textContent = '';
document.body.textContent = ''; for (const el of document.querySelectorAll('head style, head link[rel="stylesheet"]')) {
for (const el of document.querySelectorAll('head style, head link[rel="stylesheet"]')) { el.remove();
el.remove(); }
} const box = document.body;
box.textContent = '';
const box = document.body; const summary = document.createElement('p');
box.textContent = ''; box.appendChild(summary);
const summary = document.createElement('p'); summary.appendChild(document.createTextNode('An error occurred while loading the page:'));
box.appendChild(summary); const msgBlock = document.createElement('blockquote');
summary.appendChild(document.createTextNode('An error occurred while loading the page:')); box.appendChild(msgBlock);
const msgBlock = document.createElement('blockquote'); msgBlock.style.fontWeight = 'bold';
box.appendChild(msgBlock); msgBlock.appendChild(document.createTextNode(msg));
msgBlock.style.fontWeight = 'bold'; const loc = document.createElement('p');
msgBlock.appendChild(document.createTextNode(msg)); box.appendChild(loc);
const loc = document.createElement('p'); loc.appendChild(document.createTextNode(`in ${url}`));
box.appendChild(loc); loc.appendChild(document.createElement('br'));
loc.appendChild(document.createTextNode(`in ${url}`)); loc.appendChild(document.createTextNode(`at line ${line}:${col}`));
loc.appendChild(document.createElement('br')); const stackSummary = document.createElement('p');
loc.appendChild(document.createTextNode(`at line ${line}:${col}`)); box.appendChild(stackSummary);
const stackSummary = document.createElement('p'); stackSummary.appendChild(document.createTextNode('Stack trace:'));
box.appendChild(stackSummary); const stackBlock = document.createElement('blockquote');
stackSummary.appendChild(document.createTextNode('Stack trace:')); box.appendChild(stackBlock);
const stackBlock = document.createElement('blockquote'); const stack = document.createElement('pre');
box.appendChild(stackBlock); stackBlock.appendChild(stack);
const stack = document.createElement('pre'); stack.appendChild(document.createTextNode(err.stack || err.toString()));
stackBlock.appendChild(stack); if (typeof originalHandler === 'function')
stack.appendChild(document.createTextNode(err.stack || err.toString())); originalHandler(...args);
};
if (typeof originalHandler === 'function') originalHandler(...args);
};
})(); })();
// @license-end

View file

@ -1,11 +1,18 @@
import { makeCSSManager as makeCSSManager$0 } from "./cssmanager.js";
import { domline as domline$0 } from "./domline.js";
import AttribPool from "./AttributePool.js";
import * as Changeset from "./Changeset.js";
import * as attributes from "./attributes.js";
import { linestylefilter as linestylefilter$0 } from "./linestylefilter.js";
import { colorutils as colorutils$0 } from "./colorutils.js";
import * as _ from "./underscore.js";
import * as hooks from "./pluginfw/hooks.js";
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it. * This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -21,467 +28,410 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const makeCSSManager = { makeCSSManager: makeCSSManager$0 }.makeCSSManager;
const makeCSSManager = require('./cssmanager').makeCSSManager; const domline = { domline: domline$0 }.domline;
const domline = require('./domline').domline; const linestylefilter = { linestylefilter: linestylefilter$0 }.linestylefilter;
const AttribPool = require('./AttributePool'); const colorutils = { colorutils: colorutils$0 }.colorutils;
const Changeset = require('./Changeset');
const attributes = require('./attributes');
const linestylefilter = require('./linestylefilter').linestylefilter;
const colorutils = require('./colorutils').colorutils;
const _ = require('./underscore');
const hooks = require('./pluginfw/hooks');
// These parameters were global, now they are injected. A reference to the // These parameters were global, now they are injected. A reference to the
// Timeslider controller would probably be more appropriate. // Timeslider controller would probably be more appropriate.
const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider) => { const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider) => {
let goToRevisionIfEnabledCount = 0; let goToRevisionIfEnabledCount = 0;
let changesetLoader = undefined; let changesetLoader = undefined;
const debugLog = (...args) => {
const debugLog = (...args) => { try {
try { if (window.console)
if (window.console) console.log(...args); console.log(...args);
} catch (e) {
if (window.console) console.log('error printing: ', e);
}
};
const padContents = {
currentRevision: clientVars.collab_client_vars.rev,
currentTime: clientVars.collab_client_vars.time,
currentLines:
Changeset.splitTextLines(clientVars.collab_client_vars.initialAttributedText.text),
currentDivs: null,
// to be filled in once the dom loads
apool: (new AttribPool()).fromJsonable(clientVars.collab_client_vars.apool),
alines: Changeset.splitAttributionLines(
clientVars.collab_client_vars.initialAttributedText.attribs,
clientVars.collab_client_vars.initialAttributedText.text),
// generates a jquery element containing HTML for a line
lineToElement(line, aline) {
const element = document.createElement('div');
const emptyLine = (line === '\n');
const domInfo = domline.createDomLine(!emptyLine, true);
linestylefilter.populateDomLine(line, aline, this.apool, domInfo);
domInfo.prepareForAdd();
element.className = domInfo.node.className;
element.innerHTML = domInfo.node.innerHTML;
element.id = Math.random();
return $(element);
},
// splice the lines
splice(start, numRemoved, ...newLines) {
// remove spliced-out lines from DOM
for (let i = start; i < start + numRemoved && i < this.currentDivs.length; i++) {
this.currentDivs[i].remove();
}
// remove spliced-out line divs from currentDivs array
this.currentDivs.splice(start, numRemoved);
const newDivs = [];
for (let i = 0; i < newLines.length; i++) {
newDivs.push(this.lineToElement(newLines[i], this.alines[start + i]));
}
// grab the div just before the first one
let startDiv = this.currentDivs[start - 1] || null;
// insert the div elements into the correct place, in the correct order
for (let i = 0; i < newDivs.length; i++) {
if (startDiv) {
startDiv.after(newDivs[i]);
} else {
$('#innerdocbody').prepend(newDivs[i]);
} }
startDiv = newDivs[i]; catch (e) {
} if (window.console)
console.log('error printing: ', e);
// insert new divs into currentDivs array
this.currentDivs.splice(start, 0, ...newDivs);
// call currentLines.splice, to keep the currentLines array up to date
this.currentLines.splice(start, numRemoved, ...newLines);
},
// returns the contents of the specified line I
get(i) {
return this.currentLines[i];
},
// returns the number of lines in the document
length() {
return this.currentLines.length;
},
getActiveAuthors() {
const authorIds = new Set();
for (const aline of this.alines) {
for (const op of Changeset.deserializeOps(aline)) {
for (const [k, v] of attributes.attribsFromString(op.attribs, this.apool)) {
if (k !== 'author') continue;
if (v) authorIds.add(v);
}
} }
} };
return [...authorIds].sort(); const padContents = {
}, currentRevision: clientVars.collab_client_vars.rev,
}; currentTime: clientVars.collab_client_vars.time,
currentLines: Changeset.splitTextLines(clientVars.collab_client_vars.initialAttributedText.text),
const applyChangeset = (changeset, revision, preventSliderMovement, timeDelta) => { currentDivs: null,
// disable the next 'gotorevision' call handled by a timeslider update // to be filled in once the dom loads
if (!preventSliderMovement) { apool: (new AttribPool()).fromJsonable(clientVars.collab_client_vars.apool),
goToRevisionIfEnabledCount++; alines: Changeset.splitAttributionLines(clientVars.collab_client_vars.initialAttributedText.attribs, clientVars.collab_client_vars.initialAttributedText.text),
BroadcastSlider.setSliderPosition(revision); // generates a jquery element containing HTML for a line
} lineToElement(line, aline) {
const element = document.createElement('div');
const oldAlines = padContents.alines.slice(); const emptyLine = (line === '\n');
try { const domInfo = domline.createDomLine(!emptyLine, true);
// must mutate attribution lines before text lines linestylefilter.populateDomLine(line, aline, this.apool, domInfo);
Changeset.mutateAttributionLines(changeset, padContents.alines, padContents.apool); domInfo.prepareForAdd();
} catch (e) { element.className = domInfo.node.className;
debugLog(e); element.innerHTML = domInfo.node.innerHTML;
} element.id = Math.random();
return $(element);
// scroll to the area that is changed before the lines are mutated },
if ($('#options-followContents').is(':checked') || // splice the lines
$('#options-followContents').prop('checked')) { splice(start, numRemoved, ...newLines) {
// get the index of the first line that has mutated attributes // remove spliced-out lines from DOM
// the last line in `oldAlines` should always equal to "|1+1", ie newline without attributes for (let i = start; i < start + numRemoved && i < this.currentDivs.length; i++) {
// so it should be safe to assume this line has changed attributes when inserting content at this.currentDivs[i].remove();
// the bottom of a pad }
let lineChanged; // remove spliced-out line divs from currentDivs array
_.some(oldAlines, (line, index) => { this.currentDivs.splice(start, numRemoved);
if (line !== padContents.alines[index]) { const newDivs = [];
lineChanged = index; for (let i = 0; i < newLines.length; i++) {
return true; // break newDivs.push(this.lineToElement(newLines[i], this.alines[start + i]));
}
// grab the div just before the first one
let startDiv = this.currentDivs[start - 1] || null;
// insert the div elements into the correct place, in the correct order
for (let i = 0; i < newDivs.length; i++) {
if (startDiv) {
startDiv.after(newDivs[i]);
}
else {
$('#innerdocbody').prepend(newDivs[i]);
}
startDiv = newDivs[i];
}
// insert new divs into currentDivs array
this.currentDivs.splice(start, 0, ...newDivs);
// call currentLines.splice, to keep the currentLines array up to date
this.currentLines.splice(start, numRemoved, ...newLines);
},
// returns the contents of the specified line I
get(i) {
return this.currentLines[i];
},
// returns the number of lines in the document
length() {
return this.currentLines.length;
},
getActiveAuthors() {
const authorIds = new Set();
for (const aline of this.alines) {
for (const op of Changeset.deserializeOps(aline)) {
for (const [k, v] of attributes.attribsFromString(op.attribs, this.apool)) {
if (k !== 'author')
continue;
if (v)
authorIds.add(v);
}
}
}
return [...authorIds].sort();
},
};
const applyChangeset = (changeset, revision, preventSliderMovement, timeDelta) => {
// disable the next 'gotorevision' call handled by a timeslider update
if (!preventSliderMovement) {
goToRevisionIfEnabledCount++;
BroadcastSlider.setSliderPosition(revision);
} }
}); const oldAlines = padContents.alines.slice();
// some chars are replaced (no attributes change and no length change) try {
// test if there are keep ops at the start of the cs // must mutate attribution lines before text lines
if (lineChanged === undefined) { Changeset.mutateAttributionLines(changeset, padContents.alines, padContents.apool);
const [op] = Changeset.deserializeOps(Changeset.unpack(changeset).ops);
lineChanged = op != null && op.opcode === '=' ? op.lines : 0;
}
const goToLineNumber = (lineNumber) => {
// Sets the Y scrolling of the browser to go to this line
const line = $('#innerdocbody').find(`div:nth-child(${lineNumber + 1})`);
const newY = $(line)[0].offsetTop;
const ecb = document.getElementById('editorcontainerbox');
// Chrome 55 - 59 bugfix
if (ecb.scrollTo) {
ecb.scrollTo({top: newY, behavior: 'auto'});
} else {
$('#editorcontainerbox').scrollTop(newY);
} }
}; catch (e) {
debugLog(e);
goToLineNumber(lineChanged); }
} // scroll to the area that is changed before the lines are mutated
if ($('#options-followContents').is(':checked') ||
Changeset.mutateTextLines(changeset, padContents); $('#options-followContents').prop('checked')) {
padContents.currentRevision = revision; // get the index of the first line that has mutated attributes
padContents.currentTime += timeDelta * 1000; // the last line in `oldAlines` should always equal to "|1+1", ie newline without attributes
// so it should be safe to assume this line has changed attributes when inserting content at
// the bottom of a pad
let lineChanged;
_.some(oldAlines, (line, index) => {
if (line !== padContents.alines[index]) {
lineChanged = index;
return true; // break
}
});
// some chars are replaced (no attributes change and no length change)
// test if there are keep ops at the start of the cs
if (lineChanged === undefined) {
const [op] = Changeset.deserializeOps(Changeset.unpack(changeset).ops);
lineChanged = op != null && op.opcode === '=' ? op.lines : 0;
}
const goToLineNumber = (lineNumber) => {
// Sets the Y scrolling of the browser to go to this line
const line = $('#innerdocbody').find(`div:nth-child(${lineNumber + 1})`);
const newY = $(line)[0].offsetTop;
const ecb = document.getElementById('editorcontainerbox');
// Chrome 55 - 59 bugfix
if (ecb.scrollTo) {
ecb.scrollTo({ top: newY, behavior: 'auto' });
}
else {
$('#editorcontainerbox').scrollTop(newY);
}
};
goToLineNumber(lineChanged);
}
Changeset.mutateTextLines(changeset, padContents);
padContents.currentRevision = revision;
padContents.currentTime += timeDelta * 1000;
updateTimer();
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
BroadcastSlider.setAuthors(authors);
};
const loadedNewChangeset = (changesetForward, changesetBackward, revision, timeDelta) => {
const revisionInfo = window.revisionInfo;
const broadcasting = (BroadcastSlider.getSliderPosition() === revisionInfo.latest);
revisionInfo.addChangeset(revision, revision + 1, changesetForward, changesetBackward, timeDelta);
BroadcastSlider.setSliderLength(revisionInfo.latest);
if (broadcasting)
applyChangeset(changesetForward, revision + 1, false, timeDelta);
};
/*
At this point, we must be certain that the changeset really does map from
the current revision to the specified revision. Any mistakes here will
cause the whole slider to get out of sync.
*/
const updateTimer = () => {
const zpad = (str, length) => {
str = `${str}`;
while (str.length < length)
str = `0${str}`;
return str;
};
const date = new Date(padContents.currentTime);
const dateFormat = () => {
const month = zpad(date.getMonth() + 1, 2);
const day = zpad(date.getDate(), 2);
const year = (date.getFullYear());
const hours = zpad(date.getHours(), 2);
const minutes = zpad(date.getMinutes(), 2);
const seconds = zpad(date.getSeconds(), 2);
return (html10n.get('timeslider.dateformat', {
day,
month,
year,
hours,
minutes,
seconds,
}));
};
$('#timer').html(dateFormat());
const revisionDate = html10n.get('timeslider.saved', {
day: date.getDate(),
month: [
html10n.get('timeslider.month.january'),
html10n.get('timeslider.month.february'),
html10n.get('timeslider.month.march'),
html10n.get('timeslider.month.april'),
html10n.get('timeslider.month.may'),
html10n.get('timeslider.month.june'),
html10n.get('timeslider.month.july'),
html10n.get('timeslider.month.august'),
html10n.get('timeslider.month.september'),
html10n.get('timeslider.month.october'),
html10n.get('timeslider.month.november'),
html10n.get('timeslider.month.december'),
][date.getMonth()],
year: date.getFullYear(),
});
$('#revision_date').html(revisionDate);
};
updateTimer(); updateTimer();
const goToRevision = (newRevision) => {
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]); padContents.targetRevision = newRevision;
const path = window.revisionInfo.getPath(padContents.currentRevision, newRevision);
BroadcastSlider.setAuthors(authors); hooks.aCallAll('goToRevisionEvent', {
}; rev: newRevision,
});
const loadedNewChangeset = (changesetForward, changesetBackward, revision, timeDelta) => { if (path.status === 'complete') {
const revisionInfo = window.revisionInfo; const cs = path.changesets;
const broadcasting = (BroadcastSlider.getSliderPosition() === revisionInfo.latest); let changeset = cs[0];
revisionInfo.addChangeset( let timeDelta = path.times[0];
revision, revision + 1, changesetForward, changesetBackward, timeDelta); for (let i = 1; i < cs.length; i++) {
BroadcastSlider.setSliderLength(revisionInfo.latest); changeset = Changeset.compose(changeset, cs[i], padContents.apool);
if (broadcasting) applyChangeset(changesetForward, revision + 1, false, timeDelta); timeDelta += path.times[i];
}; }
if (changeset)
/* applyChangeset(changeset, path.rev, true, timeDelta);
At this point, we must be certain that the changeset really does map from
the current revision to the specified revision. Any mistakes here will
cause the whole slider to get out of sync.
*/
const updateTimer = () => {
const zpad = (str, length) => {
str = `${str}`;
while (str.length < length) str = `0${str}`;
return str;
};
const date = new Date(padContents.currentTime);
const dateFormat = () => {
const month = zpad(date.getMonth() + 1, 2);
const day = zpad(date.getDate(), 2);
const year = (date.getFullYear());
const hours = zpad(date.getHours(), 2);
const minutes = zpad(date.getMinutes(), 2);
const seconds = zpad(date.getSeconds(), 2);
return (html10n.get('timeslider.dateformat', {
day,
month,
year,
hours,
minutes,
seconds,
}));
};
$('#timer').html(dateFormat());
const revisionDate = html10n.get('timeslider.saved', {
day: date.getDate(),
month: [
html10n.get('timeslider.month.january'),
html10n.get('timeslider.month.february'),
html10n.get('timeslider.month.march'),
html10n.get('timeslider.month.april'),
html10n.get('timeslider.month.may'),
html10n.get('timeslider.month.june'),
html10n.get('timeslider.month.july'),
html10n.get('timeslider.month.august'),
html10n.get('timeslider.month.september'),
html10n.get('timeslider.month.october'),
html10n.get('timeslider.month.november'),
html10n.get('timeslider.month.december'),
][date.getMonth()],
year: date.getFullYear(),
});
$('#revision_date').html(revisionDate);
};
updateTimer();
const goToRevision = (newRevision) => {
padContents.targetRevision = newRevision;
const path = window.revisionInfo.getPath(padContents.currentRevision, newRevision);
hooks.aCallAll('goToRevisionEvent', {
rev: newRevision,
});
if (path.status === 'complete') {
const cs = path.changesets;
let changeset = cs[0];
let timeDelta = path.times[0];
for (let i = 1; i < cs.length; i++) {
changeset = Changeset.compose(changeset, cs[i], padContents.apool);
timeDelta += path.times[i];
}
if (changeset) applyChangeset(changeset, path.rev, true, timeDelta);
} else if (path.status === 'partial') {
// callback is called after changeset information is pulled from server
// this may never get called, if the changeset has already been loaded
const update = (start, end) => {
// if we've called goToRevision in the time since, don't goToRevision
goToRevision(padContents.targetRevision);
};
// do our best with what we have...
const cs = path.changesets;
let changeset = cs[0];
let timeDelta = path.times[0];
for (let i = 1; i < cs.length; i++) {
changeset = Changeset.compose(changeset, cs[i], padContents.apool);
timeDelta += path.times[i];
}
if (changeset) applyChangeset(changeset, path.rev, true, timeDelta);
// Loading changeset history for new revision
loadChangesetsForRevision(newRevision, update);
// Loading changeset history for old revision (to make diff between old and new revision)
loadChangesetsForRevision(padContents.currentRevision - 1);
}
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
BroadcastSlider.setAuthors(authors);
};
const loadChangesetsForRevision = (revision, callback) => {
if (BroadcastSlider.getSliderLength() > 10000) {
const start = (Math.floor((revision) / 10000) * 10000); // revision 0 to 10
changesetLoader.queueUp(start, 100);
}
if (BroadcastSlider.getSliderLength() > 1000) {
const start = (Math.floor((revision) / 1000) * 1000); // (start from -1, go to 19) + 1
changesetLoader.queueUp(start, 10);
}
const start = (Math.floor((revision) / 100) * 100);
changesetLoader.queueUp(start, 1, callback);
};
changesetLoader = {
running: false,
resolved: [],
requestQueue1: [],
requestQueue2: [],
requestQueue3: [],
reqCallbacks: [],
queueUp(revision, width, callback) {
if (revision < 0) revision = 0;
// if(this.requestQueue.indexOf(revision) != -1)
// return; // already in the queue.
if (this.resolved.indexOf(`${revision}_${width}`) !== -1) {
// already loaded from the server
return;
}
this.resolved.push(`${revision}_${width}`);
const requestQueue =
width === 1 ? this.requestQueue3
: width === 10 ? this.requestQueue2
: this.requestQueue1;
requestQueue.push(
{
rev: revision,
res: width,
callback,
});
if (!this.running) {
this.running = true;
setTimeout(() => this.loadFromQueue(), 10);
}
},
loadFromQueue() {
const requestQueue =
this.requestQueue1.length > 0 ? this.requestQueue1
: this.requestQueue2.length > 0 ? this.requestQueue2
: this.requestQueue3.length > 0 ? this.requestQueue3
: null;
if (!requestQueue) {
this.running = false;
return;
}
const request = requestQueue.pop();
const granularity = request.res;
const callback = request.callback;
const start = request.rev;
const requestID = Math.floor(Math.random() * 100000);
sendSocketMsg('CHANGESET_REQ', {
start,
granularity,
requestID,
});
this.reqCallbacks[requestID] = callback;
},
handleSocketResponse(message) {
const start = message.data.start;
const granularity = message.data.granularity;
const callback = this.reqCallbacks[message.data.requestID];
delete this.reqCallbacks[message.data.requestID];
this.handleResponse(message.data, start, granularity, callback);
setTimeout(() => this.loadFromQueue(), 10);
},
handleResponse: (data, start, granularity, callback) => {
const pool = (new AttribPool()).fromJsonable(data.apool);
for (let i = 0; i < data.forwardsChangesets.length; i++) {
const astart = start + i * granularity - 1; // rev -1 is a blank single line
let aend = start + (i + 1) * granularity - 1; // totalRevs is the most recent revision
if (aend > data.actualEndNum - 1) aend = data.actualEndNum - 1;
// debugLog("adding changeset:", astart, aend);
const forwardcs =
Changeset.moveOpsToNewPool(data.forwardsChangesets[i], pool, padContents.apool);
const backwardcs =
Changeset.moveOpsToNewPool(data.backwardsChangesets[i], pool, padContents.apool);
window.revisionInfo.addChangeset(astart, aend, forwardcs, backwardcs, data.timeDeltas[i]);
}
if (callback) callback(start - 1, start + data.forwardsChangesets.length * granularity - 1);
},
handleMessageFromServer(obj) {
if (obj.type === 'COLLABROOM') {
obj = obj.data;
if (obj.type === 'NEW_CHANGES') {
const changeset = Changeset.moveOpsToNewPool(
obj.changeset, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
let changesetBack = Changeset.inverse(
obj.changeset, padContents.currentLines, padContents.alines, padContents.apool);
changesetBack = Changeset.moveOpsToNewPool(
changesetBack, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
loadedNewChangeset(changeset, changesetBack, obj.newRev - 1, obj.timeDelta);
} else if (obj.type === 'NEW_AUTHORDATA') {
const authorMap = {};
authorMap[obj.author] = obj.data;
receiveAuthorData(authorMap);
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
BroadcastSlider.setAuthors(authors);
} else if (obj.type === 'NEW_SAVEDREV') {
const savedRev = obj.savedRev;
BroadcastSlider.addSavedRevision(savedRev.revNum, savedRev);
} }
hooks.callAll(`handleClientTimesliderMessage_${obj.type}`, {payload: obj}); else if (path.status === 'partial') {
} else if (obj.type === 'CHANGESET_REQ') { // callback is called after changeset information is pulled from server
this.handleSocketResponse(obj); // this may never get called, if the changeset has already been loaded
} else { const update = (start, end) => {
debugLog(`Unknown message type: ${obj.type}`); // if we've called goToRevision in the time since, don't goToRevision
} goToRevision(padContents.targetRevision);
}, };
}; // do our best with what we have...
const cs = path.changesets;
// to start upon window load, just push a function onto this array let changeset = cs[0];
// window['onloadFuncts'].push(setUpSocket); let timeDelta = path.times[0];
// window['onloadFuncts'].push(function () for (let i = 1; i < cs.length; i++) {
fireWhenAllScriptsAreLoaded.push(() => { changeset = Changeset.compose(changeset, cs[i], padContents.apool);
// set up the currentDivs and DOM timeDelta += path.times[i];
padContents.currentDivs = []; }
$('#innerdocbody').html(''); if (changeset)
for (let i = 0; i < padContents.currentLines.length; i++) { applyChangeset(changeset, path.rev, true, timeDelta);
const div = padContents.lineToElement(padContents.currentLines[i], padContents.alines[i]); // Loading changeset history for new revision
padContents.currentDivs.push(div); loadChangesetsForRevision(newRevision, update);
$('#innerdocbody').append(div); // Loading changeset history for old revision (to make diff between old and new revision)
} loadChangesetsForRevision(padContents.currentRevision - 1);
}); }
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
// this is necessary to keep infinite loops of events firing, BroadcastSlider.setAuthors(authors);
// since goToRevision changes the slider position };
const goToRevisionIfEnabled = (...args) => { const loadChangesetsForRevision = (revision, callback) => {
if (goToRevisionIfEnabledCount > 0) { if (BroadcastSlider.getSliderLength() > 10000) {
goToRevisionIfEnabledCount--; const start = (Math.floor((revision) / 10000) * 10000); // revision 0 to 10
} else { changesetLoader.queueUp(start, 100);
goToRevision(...args); }
} if (BroadcastSlider.getSliderLength() > 1000) {
}; const start = (Math.floor((revision) / 1000) * 1000); // (start from -1, go to 19) + 1
changesetLoader.queueUp(start, 10);
BroadcastSlider.onSlider(goToRevisionIfEnabled); }
const start = (Math.floor((revision) / 100) * 100);
const dynamicCSS = makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet); changesetLoader.queueUp(start, 1, callback);
const authorData = {}; };
changesetLoader = {
const receiveAuthorData = (newAuthorData) => { running: false,
for (const [author, data] of Object.entries(newAuthorData)) { resolved: [],
const bgcolor = typeof data.colorId === 'number' requestQueue1: [],
? clientVars.colorPalette[data.colorId] : data.colorId; requestQueue2: [],
if (bgcolor) { requestQueue3: [],
const selector = dynamicCSS.selectorStyle(`.${linestylefilter.getAuthorClassName(author)}`); reqCallbacks: [],
selector.backgroundColor = bgcolor; queueUp(revision, width, callback) {
selector.color = (colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5) if (revision < 0)
? '#ffffff' : '#000000'; // see ace2_inner.js for the other part revision = 0;
} // if(this.requestQueue.indexOf(revision) != -1)
authorData[author] = data; // return; // already in the queue.
} if (this.resolved.indexOf(`${revision}_${width}`) !== -1) {
}; // already loaded from the server
return;
receiveAuthorData(clientVars.collab_client_vars.historicalAuthorData); }
this.resolved.push(`${revision}_${width}`);
return changesetLoader; const requestQueue = width === 1 ? this.requestQueue3
: width === 10 ? this.requestQueue2
: this.requestQueue1;
requestQueue.push({
rev: revision,
res: width,
callback,
});
if (!this.running) {
this.running = true;
setTimeout(() => this.loadFromQueue(), 10);
}
},
loadFromQueue() {
const requestQueue = this.requestQueue1.length > 0 ? this.requestQueue1
: this.requestQueue2.length > 0 ? this.requestQueue2
: this.requestQueue3.length > 0 ? this.requestQueue3
: null;
if (!requestQueue) {
this.running = false;
return;
}
const request = requestQueue.pop();
const granularity = request.res;
const callback = request.callback;
const start = request.rev;
const requestID = Math.floor(Math.random() * 100000);
sendSocketMsg('CHANGESET_REQ', {
start,
granularity,
requestID,
});
this.reqCallbacks[requestID] = callback;
},
handleSocketResponse(message) {
const start = message.data.start;
const granularity = message.data.granularity;
const callback = this.reqCallbacks[message.data.requestID];
delete this.reqCallbacks[message.data.requestID];
this.handleResponse(message.data, start, granularity, callback);
setTimeout(() => this.loadFromQueue(), 10);
},
handleResponse: (data, start, granularity, callback) => {
const pool = (new AttribPool()).fromJsonable(data.apool);
for (let i = 0; i < data.forwardsChangesets.length; i++) {
const astart = start + i * granularity - 1; // rev -1 is a blank single line
let aend = start + (i + 1) * granularity - 1; // totalRevs is the most recent revision
if (aend > data.actualEndNum - 1)
aend = data.actualEndNum - 1;
// debugLog("adding changeset:", astart, aend);
const forwardcs = Changeset.moveOpsToNewPool(data.forwardsChangesets[i], pool, padContents.apool);
const backwardcs = Changeset.moveOpsToNewPool(data.backwardsChangesets[i], pool, padContents.apool);
window.revisionInfo.addChangeset(astart, aend, forwardcs, backwardcs, data.timeDeltas[i]);
}
if (callback)
callback(start - 1, start + data.forwardsChangesets.length * granularity - 1);
},
handleMessageFromServer(obj) {
if (obj.type === 'COLLABROOM') {
obj = obj.data;
if (obj.type === 'NEW_CHANGES') {
const changeset = Changeset.moveOpsToNewPool(obj.changeset, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
let changesetBack = Changeset.inverse(obj.changeset, padContents.currentLines, padContents.alines, padContents.apool);
changesetBack = Changeset.moveOpsToNewPool(changesetBack, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
loadedNewChangeset(changeset, changesetBack, obj.newRev - 1, obj.timeDelta);
}
else if (obj.type === 'NEW_AUTHORDATA') {
const authorMap = {};
authorMap[obj.author] = obj.data;
receiveAuthorData(authorMap);
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
BroadcastSlider.setAuthors(authors);
}
else if (obj.type === 'NEW_SAVEDREV') {
const savedRev = obj.savedRev;
BroadcastSlider.addSavedRevision(savedRev.revNum, savedRev);
}
hooks.callAll(`handleClientTimesliderMessage_${obj.type}`, { payload: obj });
}
else if (obj.type === 'CHANGESET_REQ') {
this.handleSocketResponse(obj);
}
else {
debugLog(`Unknown message type: ${obj.type}`);
}
},
};
// to start upon window load, just push a function onto this array
// window['onloadFuncts'].push(setUpSocket);
// window['onloadFuncts'].push(function ()
fireWhenAllScriptsAreLoaded.push(() => {
// set up the currentDivs and DOM
padContents.currentDivs = [];
$('#innerdocbody').html('');
for (let i = 0; i < padContents.currentLines.length; i++) {
const div = padContents.lineToElement(padContents.currentLines[i], padContents.alines[i]);
padContents.currentDivs.push(div);
$('#innerdocbody').append(div);
}
});
// this is necessary to keep infinite loops of events firing,
// since goToRevision changes the slider position
const goToRevisionIfEnabled = (...args) => {
if (goToRevisionIfEnabledCount > 0) {
goToRevisionIfEnabledCount--;
}
else {
goToRevision(...args);
}
};
BroadcastSlider.onSlider(goToRevisionIfEnabled);
const dynamicCSS = makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet);
const authorData = {};
const receiveAuthorData = (newAuthorData) => {
for (const [author, data] of Object.entries(newAuthorData)) {
const bgcolor = typeof data.colorId === 'number'
? clientVars.colorPalette[data.colorId] : data.colorId;
if (bgcolor) {
const selector = dynamicCSS.selectorStyle(`.${linestylefilter.getAuthorClassName(author)}`);
selector.backgroundColor = bgcolor;
selector.color = (colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5)
? '#ffffff' : '#000000'; // see ace2_inner.js for the other part
}
authorData[author] = data;
}
};
receiveAuthorData(clientVars.collab_client_vars.historicalAuthorData);
return changesetLoader;
}; };
export { loadBroadcastJS };
exports.loadBroadcastJS = loadBroadcastJS;

View file

@ -1,11 +1,9 @@
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it. * This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -24,92 +22,79 @@
// revision info is a skip list whos entries represent a particular revision // revision info is a skip list whos entries represent a particular revision
// of the document. These revisions are connected together by various // of the document. These revisions are connected together by various
// changesets, or deltas, between any two revisions. // changesets, or deltas, between any two revisions.
const loadBroadcastRevisionsJS = () => { const loadBroadcastRevisionsJS = () => {
function Revision(revNum) { function Revision(revNum) {
this.rev = revNum; this.rev = revNum;
this.changesets = []; this.changesets = [];
}
Revision.prototype.addChangeset = function (destIndex, changeset, timeDelta) {
const changesetWrapper = {
deltaRev: destIndex - this.rev,
deltaTime: timeDelta,
getValue: () => changeset,
};
this.changesets.push(changesetWrapper);
this.changesets.sort((a, b) => (b.deltaRev - a.deltaRev));
};
const revisionInfo = {};
revisionInfo.addChangeset = function (fromIndex, toIndex, changeset, backChangeset, timeDelta) {
const startRevision = this[fromIndex] || this.createNew(fromIndex);
const endRevision = this[toIndex] || this.createNew(toIndex);
startRevision.addChangeset(toIndex, changeset, timeDelta);
endRevision.addChangeset(fromIndex, backChangeset, -1 * timeDelta);
};
revisionInfo.latest = clientVars.collab_client_vars.rev || -1;
revisionInfo.createNew = function (index) {
this[index] = new Revision(index);
if (index > this.latest) {
this.latest = index;
} }
Revision.prototype.addChangeset = function (destIndex, changeset, timeDelta) {
return this[index]; const changesetWrapper = {
}; deltaRev: destIndex - this.rev,
deltaTime: timeDelta,
// assuming that there is a path from fromIndex to toIndex, and that the links getValue: () => changeset,
// are laid out in a skip-list format };
revisionInfo.getPath = function (fromIndex, toIndex) { this.changesets.push(changesetWrapper);
const changesets = []; this.changesets.sort((a, b) => (b.deltaRev - a.deltaRev));
const spans = []; };
const times = []; const revisionInfo = {};
let elem = this[fromIndex] || this.createNew(fromIndex); revisionInfo.addChangeset = function (fromIndex, toIndex, changeset, backChangeset, timeDelta) {
if (elem.changesets.length !== 0 && fromIndex !== toIndex) { const startRevision = this[fromIndex] || this.createNew(fromIndex);
const reverse = !(fromIndex < toIndex); const endRevision = this[toIndex] || this.createNew(toIndex);
while (((elem.rev < toIndex) && !reverse) || ((elem.rev > toIndex) && reverse)) { startRevision.addChangeset(toIndex, changeset, timeDelta);
let couldNotContinue = false; endRevision.addChangeset(fromIndex, backChangeset, -1 * timeDelta);
const oldRev = elem.rev; };
revisionInfo.latest = clientVars.collab_client_vars.rev || -1;
for (let i = reverse ? elem.changesets.length - 1 : 0; revisionInfo.createNew = function (index) {
reverse ? i >= 0 : i < elem.changesets.length; this[index] = new Revision(index);
i += reverse ? -1 : 1) { if (index > this.latest) {
if (((elem.changesets[i].deltaRev < 0) && !reverse) || this.latest = index;
((elem.changesets[i].deltaRev > 0) && reverse)) {
couldNotContinue = true;
break;
}
if (((elem.rev + elem.changesets[i].deltaRev <= toIndex) && !reverse) ||
((elem.rev + elem.changesets[i].deltaRev >= toIndex) && reverse)) {
const topush = elem.changesets[i];
changesets.push(topush.getValue());
spans.push(elem.changesets[i].deltaRev);
times.push(topush.deltaTime);
elem = this[elem.rev + elem.changesets[i].deltaRev];
break;
}
} }
return this[index];
if (couldNotContinue || oldRev === elem.rev) break;
}
}
let status = 'partial';
if (elem.rev === toIndex) status = 'complete';
return {
fromRev: fromIndex,
rev: elem.rev,
status,
changesets,
spans,
times,
}; };
}; // assuming that there is a path from fromIndex to toIndex, and that the links
window.revisionInfo = revisionInfo; // are laid out in a skip-list format
revisionInfo.getPath = function (fromIndex, toIndex) {
const changesets = [];
const spans = [];
const times = [];
let elem = this[fromIndex] || this.createNew(fromIndex);
if (elem.changesets.length !== 0 && fromIndex !== toIndex) {
const reverse = !(fromIndex < toIndex);
while (((elem.rev < toIndex) && !reverse) || ((elem.rev > toIndex) && reverse)) {
let couldNotContinue = false;
const oldRev = elem.rev;
for (let i = reverse ? elem.changesets.length - 1 : 0; reverse ? i >= 0 : i < elem.changesets.length; i += reverse ? -1 : 1) {
if (((elem.changesets[i].deltaRev < 0) && !reverse) ||
((elem.changesets[i].deltaRev > 0) && reverse)) {
couldNotContinue = true;
break;
}
if (((elem.rev + elem.changesets[i].deltaRev <= toIndex) && !reverse) ||
((elem.rev + elem.changesets[i].deltaRev >= toIndex) && reverse)) {
const topush = elem.changesets[i];
changesets.push(topush.getValue());
spans.push(elem.changesets[i].deltaRev);
times.push(topush.deltaTime);
elem = this[elem.rev + elem.changesets[i].deltaRev];
break;
}
}
if (couldNotContinue || oldRev === elem.rev)
break;
}
}
let status = 'partial';
if (elem.rev === toIndex)
status = 'complete';
return {
fromRev: fromIndex,
rev: elem.rev,
status,
changesets,
spans,
times,
};
};
window.revisionInfo = revisionInfo;
}; };
export { loadBroadcastRevisionsJS };
exports.loadBroadcastRevisionsJS = loadBroadcastRevisionsJS;

View file

@ -1,342 +1,293 @@
import * as _ from "./underscore.js";
import { padmodals as padmodals$0 } from "./pad_modals.js";
import { colorutils as colorutils$0 } from "./colorutils.js";
'use strict'; 'use strict';
/** const padmodals = { padmodals: padmodals$0 }.padmodals;
* This code is mostly from the old Etherpad. Please help us to comment this code. const colorutils = { colorutils: colorutils$0 }.colorutils;
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// These parameters were global, now they are injected. A reference to the
// Timeslider controller would probably be more appropriate.
const _ = require('./underscore');
const padmodals = require('./pad_modals').padmodals;
const colorutils = require('./colorutils').colorutils;
const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => { const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
let BroadcastSlider; let BroadcastSlider;
// Hack to ensure timeslider i18n values are in
// Hack to ensure timeslider i18n values are in $("[data-key='timeslider_returnToPad'] > a > span").html(html10n.get('timeslider.toolbar.returnbutton'));
$("[data-key='timeslider_returnToPad'] > a > span").html( (() => {
html10n.get('timeslider.toolbar.returnbutton')); let sliderLength = 1000;
let sliderPos = 0;
(() => { // wrap this code in its own namespace let sliderActive = false;
let sliderLength = 1000; const slidercallbacks = [];
let sliderPos = 0; const savedRevisions = [];
let sliderActive = false; let sliderPlaying = false;
const slidercallbacks = []; const _callSliderCallbacks = (newval) => {
const savedRevisions = []; sliderPos = newval;
let sliderPlaying = false; for (let i = 0; i < slidercallbacks.length; i++) {
slidercallbacks[i](newval);
const _callSliderCallbacks = (newval) => {
sliderPos = newval;
for (let i = 0; i < slidercallbacks.length; i++) {
slidercallbacks[i](newval);
}
};
const updateSliderElements = () => {
for (let i = 0; i < savedRevisions.length; i++) {
const position = parseInt(savedRevisions[i].attr('pos'));
savedRevisions[i].css(
'left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
}
$('#ui-slider-handle').css(
'left', sliderPos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));
};
const addSavedRevision = (position, info) => {
const newSavedRevision = $('<div></div>');
newSavedRevision.addClass('star');
newSavedRevision.attr('pos', position);
newSavedRevision.css(
'left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
$('#ui-slider-bar').append(newSavedRevision);
newSavedRevision.mouseup((evt) => {
BroadcastSlider.setSliderPosition(position);
});
savedRevisions.push(newSavedRevision);
};
/* Begin small 'API' */
const onSlider = (callback) => {
slidercallbacks.push(callback);
};
const getSliderPosition = () => sliderPos;
const setSliderPosition = (newpos) => {
newpos = Number(newpos);
if (newpos < 0 || newpos > sliderLength) return;
if (!newpos) {
newpos = 0; // stops it from displaying NaN if newpos isn't set
}
window.location.hash = `#${newpos}`;
$('#ui-slider-handle').css(
'left', newpos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));
$('a.tlink').map(function () {
$(this).attr('href', $(this).attr('thref').replace('%revision%', newpos));
});
$('#revision_label').html(html10n.get('timeslider.version', {version: newpos}));
$('#leftstar, #leftstep').toggleClass('disabled', newpos === 0);
$('#rightstar, #rightstep').toggleClass('disabled', newpos === sliderLength);
sliderPos = newpos;
_callSliderCallbacks(newpos);
};
const getSliderLength = () => sliderLength;
const setSliderLength = (newlength) => {
sliderLength = newlength;
updateSliderElements();
};
// just take over the whole slider screen with a reconnect message
const showReconnectUI = () => {
padmodals.showModal('disconnected');
};
const setAuthors = (authors) => {
const authorsList = $('#authorsList');
authorsList.empty();
let numAnonymous = 0;
let numNamed = 0;
const colorsAnonymous = [];
_.each(authors, (author) => {
if (author) {
const authorColor = clientVars.colorPalette[author.colorId] || author.colorId;
if (author.name) {
if (numNamed !== 0) authorsList.append(', ');
const textColor =
colorutils.textColorFromBackgroundColor(authorColor, clientVars.skinName);
$('<span />')
.text(author.name || 'unnamed')
.css('background-color', authorColor)
.css('color', textColor)
.addClass('author')
.appendTo(authorsList);
numNamed++;
} else {
numAnonymous++;
if (authorColor) colorsAnonymous.push(authorColor);
}
}
});
if (numAnonymous > 0) {
const anonymousAuthorString = html10n.get('timeslider.unnamedauthors', {num: numAnonymous});
if (numNamed !== 0) {
authorsList.append(` + ${anonymousAuthorString}`);
} else {
authorsList.append(anonymousAuthorString);
}
if (colorsAnonymous.length > 0) {
authorsList.append(' (');
_.each(colorsAnonymous, (color, i) => {
if (i > 0) authorsList.append(' ');
$('<span>&nbsp;</span>')
.css('background-color', color)
.addClass('author author-anonymous')
.appendTo(authorsList);
});
authorsList.append(')');
}
}
if (authors.length === 0) {
authorsList.append(html10n.get('timeslider.toolbar.authorsList'));
}
};
const playButtonUpdater = () => {
if (sliderPlaying) {
if (getSliderPosition() + 1 > sliderLength) {
$('#playpause_button_icon').toggleClass('pause');
sliderPlaying = false;
return;
}
setSliderPosition(getSliderPosition() + 1);
setTimeout(playButtonUpdater, 100);
}
};
const playpause = () => {
$('#playpause_button_icon').toggleClass('pause');
if (!sliderPlaying) {
if (getSliderPosition() === sliderLength) setSliderPosition(0);
sliderPlaying = true;
playButtonUpdater();
} else {
sliderPlaying = false;
}
};
BroadcastSlider = {
onSlider,
getSliderPosition,
setSliderPosition,
getSliderLength,
setSliderLength,
isSliderActive: () => sliderActive,
playpause,
addSavedRevision,
showReconnectUI,
setAuthors,
};
// assign event handlers to html UI elements after page load
fireWhenAllScriptsAreLoaded.push(() => {
$(document).keyup((e) => {
if (!e) e = window.event;
const code = e.keyCode || e.which;
if (code === 37) { // left
if (e.shiftKey) {
$('#leftstar').click();
} else {
$('#leftstep').click();
}
} else if (code === 39) { // right
if (e.shiftKey) {
$('#rightstar').click();
} else {
$('#rightstep').click();
}
} else if (code === 32) { // spacebar
$('#playpause_button_icon').trigger('click');
}
});
// Resize
$(window).resize(() => {
updateSliderElements();
});
// Slider click
$('#ui-slider-bar').mousedown((evt) => {
$('#ui-slider-handle').css('left', (evt.clientX - $('#ui-slider-bar').offset().left));
$('#ui-slider-handle').trigger(evt);
});
// Slider dragging
$('#ui-slider-handle').mousedown(function (evt) {
this.startLoc = evt.clientX;
this.currentLoc = parseInt($(this).css('left'));
sliderActive = true;
$(document).mousemove((evt2) => {
$(this).css('pointer', 'move');
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
if (newloc < 0) newloc = 0;
const maxPos = $('#ui-slider-bar').width() - 2;
if (newloc > maxPos) newloc = maxPos;
const version = Math.floor(newloc * sliderLength / maxPos);
$('#revision_label').html(html10n.get('timeslider.version', {version}));
$(this).css('left', newloc);
if (getSliderPosition() !== version) _callSliderCallbacks(version);
});
$(document).mouseup((evt2) => {
$(document).unbind('mousemove');
$(document).unbind('mouseup');
sliderActive = false;
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
if (newloc < 0) newloc = 0;
const maxPos = $('#ui-slider-bar').width() - 2;
if (newloc > maxPos) newloc = maxPos;
$(this).css('left', newloc);
setSliderPosition(Math.floor(newloc * sliderLength / maxPos));
if (parseInt($(this).css('left')) < 2) {
$(this).css('left', '2px');
} else {
this.currentLoc = parseInt($(this).css('left'));
}
});
});
// play/pause toggling
$('#playpause_button_icon').click((evt) => {
BroadcastSlider.playpause();
});
// next/prev saved revision and changeset
$('.stepper').click(function (evt) {
switch ($(this).attr('id')) {
case 'leftstep':
setSliderPosition(getSliderPosition() - 1);
break;
case 'rightstep':
setSliderPosition(getSliderPosition() + 1);
break;
case 'leftstar': {
let nextStar = 0; // default to first revision in document
for (let i = 0; i < savedRevisions.length; i++) {
const pos = parseInt(savedRevisions[i].attr('pos'));
if (pos < getSliderPosition() && nextStar < pos) nextStar = pos;
} }
setSliderPosition(nextStar); };
break; const updateSliderElements = () => {
}
case 'rightstar': {
let nextStar = sliderLength; // default to last revision in document
for (let i = 0; i < savedRevisions.length; i++) { for (let i = 0; i < savedRevisions.length; i++) {
const pos = parseInt(savedRevisions[i].attr('pos')); const position = parseInt(savedRevisions[i].attr('pos'));
if (pos > getSliderPosition() && nextStar > pos) nextStar = pos; savedRevisions[i].css('left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
}
$('#ui-slider-handle').css('left', sliderPos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));
};
const addSavedRevision = (position, info) => {
const newSavedRevision = $('<div></div>');
newSavedRevision.addClass('star');
newSavedRevision.attr('pos', position);
newSavedRevision.css('left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
$('#ui-slider-bar').append(newSavedRevision);
newSavedRevision.mouseup((evt) => {
BroadcastSlider.setSliderPosition(position);
});
savedRevisions.push(newSavedRevision);
};
/* Begin small 'API' */
const onSlider = (callback) => {
slidercallbacks.push(callback);
};
const getSliderPosition = () => sliderPos;
const setSliderPosition = (newpos) => {
newpos = Number(newpos);
if (newpos < 0 || newpos > sliderLength)
return;
if (!newpos) {
newpos = 0; // stops it from displaying NaN if newpos isn't set
}
window.location.hash = `#${newpos}`;
$('#ui-slider-handle').css('left', newpos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));
$('a.tlink').map(function () {
$(this).attr('href', $(this).attr('thref').replace('%revision%', newpos));
});
$('#revision_label').html(html10n.get('timeslider.version', { version: newpos }));
$('#leftstar, #leftstep').toggleClass('disabled', newpos === 0);
$('#rightstar, #rightstep').toggleClass('disabled', newpos === sliderLength);
sliderPos = newpos;
_callSliderCallbacks(newpos);
};
const getSliderLength = () => sliderLength;
const setSliderLength = (newlength) => {
sliderLength = newlength;
updateSliderElements();
};
// just take over the whole slider screen with a reconnect message
const showReconnectUI = () => {
padmodals.showModal('disconnected');
};
const setAuthors = (authors) => {
const authorsList = $('#authorsList');
authorsList.empty();
let numAnonymous = 0;
let numNamed = 0;
const colorsAnonymous = [];
_.each(authors, (author) => {
if (author) {
const authorColor = clientVars.colorPalette[author.colorId] || author.colorId;
if (author.name) {
if (numNamed !== 0)
authorsList.append(', ');
const textColor = colorutils.textColorFromBackgroundColor(authorColor, clientVars.skinName);
$('<span />')
.text(author.name || 'unnamed')
.css('background-color', authorColor)
.css('color', textColor)
.addClass('author')
.appendTo(authorsList);
numNamed++;
}
else {
numAnonymous++;
if (authorColor)
colorsAnonymous.push(authorColor);
}
}
});
if (numAnonymous > 0) {
const anonymousAuthorString = html10n.get('timeslider.unnamedauthors', { num: numAnonymous });
if (numNamed !== 0) {
authorsList.append(` + ${anonymousAuthorString}`);
}
else {
authorsList.append(anonymousAuthorString);
}
if (colorsAnonymous.length > 0) {
authorsList.append(' (');
_.each(colorsAnonymous, (color, i) => {
if (i > 0)
authorsList.append(' ');
$('<span>&nbsp;</span>')
.css('background-color', color)
.addClass('author author-anonymous')
.appendTo(authorsList);
});
authorsList.append(')');
}
}
if (authors.length === 0) {
authorsList.append(html10n.get('timeslider.toolbar.authorsList'));
}
};
const playButtonUpdater = () => {
if (sliderPlaying) {
if (getSliderPosition() + 1 > sliderLength) {
$('#playpause_button_icon').toggleClass('pause');
sliderPlaying = false;
return;
}
setSliderPosition(getSliderPosition() + 1);
setTimeout(playButtonUpdater, 100);
}
};
const playpause = () => {
$('#playpause_button_icon').toggleClass('pause');
if (!sliderPlaying) {
if (getSliderPosition() === sliderLength)
setSliderPosition(0);
sliderPlaying = true;
playButtonUpdater();
}
else {
sliderPlaying = false;
}
};
BroadcastSlider = {
onSlider,
getSliderPosition,
setSliderPosition,
getSliderLength,
setSliderLength,
isSliderActive: () => sliderActive,
playpause,
addSavedRevision,
showReconnectUI,
setAuthors,
};
// assign event handlers to html UI elements after page load
fireWhenAllScriptsAreLoaded.push(() => {
$(document).keyup((e) => {
if (!e)
e = window.event;
const code = e.keyCode || e.which;
if (code === 37) { // left
if (e.shiftKey) {
$('#leftstar').click();
}
else {
$('#leftstep').click();
}
}
else if (code === 39) { // right
if (e.shiftKey) {
$('#rightstar').click();
}
else {
$('#rightstep').click();
}
}
else if (code === 32) { // spacebar
$('#playpause_button_icon').trigger('click');
}
});
// Resize
$(window).resize(() => {
updateSliderElements();
});
// Slider click
$('#ui-slider-bar').mousedown((evt) => {
$('#ui-slider-handle').css('left', (evt.clientX - $('#ui-slider-bar').offset().left));
$('#ui-slider-handle').trigger(evt);
});
// Slider dragging
$('#ui-slider-handle').mousedown(function (evt) {
this.startLoc = evt.clientX;
this.currentLoc = parseInt($(this).css('left'));
sliderActive = true;
$(document).mousemove((evt2) => {
$(this).css('pointer', 'move');
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
if (newloc < 0)
newloc = 0;
const maxPos = $('#ui-slider-bar').width() - 2;
if (newloc > maxPos)
newloc = maxPos;
const version = Math.floor(newloc * sliderLength / maxPos);
$('#revision_label').html(html10n.get('timeslider.version', { version }));
$(this).css('left', newloc);
if (getSliderPosition() !== version)
_callSliderCallbacks(version);
});
$(document).mouseup((evt2) => {
$(document).unbind('mousemove');
$(document).unbind('mouseup');
sliderActive = false;
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
if (newloc < 0)
newloc = 0;
const maxPos = $('#ui-slider-bar').width() - 2;
if (newloc > maxPos)
newloc = maxPos;
$(this).css('left', newloc);
setSliderPosition(Math.floor(newloc * sliderLength / maxPos));
if (parseInt($(this).css('left')) < 2) {
$(this).css('left', '2px');
}
else {
this.currentLoc = parseInt($(this).css('left'));
}
});
});
// play/pause toggling
$('#playpause_button_icon').click((evt) => {
BroadcastSlider.playpause();
});
// next/prev saved revision and changeset
$('.stepper').click(function (evt) {
switch ($(this).attr('id')) {
case 'leftstep':
setSliderPosition(getSliderPosition() - 1);
break;
case 'rightstep':
setSliderPosition(getSliderPosition() + 1);
break;
case 'leftstar': {
let nextStar = 0; // default to first revision in document
for (let i = 0; i < savedRevisions.length; i++) {
const pos = parseInt(savedRevisions[i].attr('pos'));
if (pos < getSliderPosition() && nextStar < pos)
nextStar = pos;
}
setSliderPosition(nextStar);
break;
}
case 'rightstar': {
let nextStar = sliderLength; // default to last revision in document
for (let i = 0; i < savedRevisions.length; i++) {
const pos = parseInt(savedRevisions[i].attr('pos'));
if (pos > getSliderPosition() && nextStar > pos)
nextStar = pos;
}
setSliderPosition(nextStar);
break;
}
}
});
if (clientVars) {
$('#timeslider-wrapper').show();
if (window.location.hash.length > 1) {
const hashRev = Number(window.location.hash.substr(1));
if (!isNaN(hashRev)) {
// this is necessary because of the socket.io-event which loads the changesets
setTimeout(() => { setSliderPosition(hashRev); }, 1);
}
}
setSliderLength(clientVars.collab_client_vars.rev);
setSliderPosition(clientVars.collab_client_vars.rev);
_.each(clientVars.savedRevisions, (revision) => {
addSavedRevision(revision.revNum, revision);
});
} }
setSliderPosition(nextStar);
break;
}
}
});
if (clientVars) {
$('#timeslider-wrapper').show();
if (window.location.hash.length > 1) {
const hashRev = Number(window.location.hash.substr(1));
if (!isNaN(hashRev)) {
// this is necessary because of the socket.io-event which loads the changesets
setTimeout(() => { setSliderPosition(hashRev); }, 1);
}
}
setSliderLength(clientVars.collab_client_vars.rev);
setSliderPosition(clientVars.collab_client_vars.rev);
_.each(clientVars.savedRevisions, (revision) => {
addSavedRevision(revision.revNum, revision);
}); });
} })();
BroadcastSlider.onSlider((loc) => {
$('#viewlatest').html(`${loc === BroadcastSlider.getSliderLength() ? 'Viewing' : 'View'} latest content`);
}); });
})(); return BroadcastSlider;
BroadcastSlider.onSlider((loc) => {
$('#viewlatest').html(
`${loc === BroadcastSlider.getSliderLength() ? 'Viewing' : 'View'} latest content`);
});
return BroadcastSlider;
}; };
export { loadBroadcastSliderJS };
exports.loadBroadcastSliderJS = loadBroadcastSliderJS;

View file

@ -1,203 +1,165 @@
'use strict'; 'use strict';
// One rep.line(div) can be broken in more than one line in the browser.
// This function is useful to get the caret position of the line as
// is represented by the browser
exports.getPosition = () => {
const range = getSelectionRange();
if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody') return null;
// When there's a <br> or any element that has no height, we can't get the dimension of the
// element where the caret is. As we can't get the element height, we create a text node to get
// the dimensions on the position.
const clonedRange = createSelectionRange(range);
const shadowCaret = $(document.createTextNode('|'));
clonedRange.insertNode(shadowCaret[0]);
clonedRange.selectNode(shadowCaret[0]);
const line = getPositionOfElementOrSelection(clonedRange);
shadowCaret.remove();
return line;
};
const createSelectionRange = (range) => { const createSelectionRange = (range) => {
const clonedRange = range.cloneRange(); const clonedRange = range.cloneRange();
// we set the selection start and end to avoid error when user selects a text bigger than
// we set the selection start and end to avoid error when user selects a text bigger than // the viewport height and uses the arrow keys to expand the selection. In this particular
// the viewport height and uses the arrow keys to expand the selection. In this particular // case is necessary to know where the selections ends because both edges of the selection
// case is necessary to know where the selections ends because both edges of the selection // is out of the viewport but we only use the end of it to calculate if it needs to scroll
// is out of the viewport but we only use the end of it to calculate if it needs to scroll clonedRange.setStart(range.endContainer, range.endOffset);
clonedRange.setStart(range.endContainer, range.endOffset); clonedRange.setEnd(range.endContainer, range.endOffset);
clonedRange.setEnd(range.endContainer, range.endOffset); return clonedRange;
return clonedRange;
}; };
const getPositionOfRepLineAtOffset = (node, offset) => { const getPositionOfRepLineAtOffset = (node, offset) => {
// it is not a text node, so we cannot make a selection // it is not a text node, so we cannot make a selection
if (node.tagName === 'BR' || node.tagName === 'EMPTY') { if (node.tagName === 'BR' || node.tagName === 'EMPTY') {
return getPositionOfElementOrSelection(node); return getPositionOfElementOrSelection(node);
} }
while (node.length === 0 && node.nextSibling) {
while (node.length === 0 && node.nextSibling) { node = node.nextSibling;
node = node.nextSibling; }
} const newRange = new Range();
newRange.setStart(node, offset);
const newRange = new Range(); newRange.setEnd(node, offset);
newRange.setStart(node, offset); const linePosition = getPositionOfElementOrSelection(newRange);
newRange.setEnd(node, offset); newRange.detach(); // performance sake
const linePosition = getPositionOfElementOrSelection(newRange); return linePosition;
newRange.detach(); // performance sake
return linePosition;
}; };
const getPositionOfElementOrSelection = (element) => { const getPositionOfElementOrSelection = (element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const linePosition = { const linePosition = {
bottom: rect.bottom, bottom: rect.bottom,
height: rect.height, height: rect.height,
top: rect.top, top: rect.top,
}; };
return linePosition; return linePosition;
}; };
// here we have two possibilities:
// [1] the line before the caret line has the same type, so both of them has the same margin,
// padding height, etc. So, we can use the caret line to make calculation necessary to know
// where is the top of the previous line
// [2] the line before is part of another rep line. It's possible this line has different margins
// height. So we have to get the exactly position of the line
exports.getPositionTopOfPreviousBrowserLine = (caretLinePosition, rep) => {
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
const isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep);
// the caret is in the beginning of a rep line, so the previous browser line
// is the last line browser line of the a rep line
if (isCaretLineFirstBrowserLine) { // [2]
const lineBeforeCaretLine = rep.selStart[0] - 1;
const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
const linePosition =
getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep);
previousLineTop = linePosition.top;
}
return previousLineTop;
};
const caretLineIsFirstBrowserLine = (caretLineTop, rep) => { const caretLineIsFirstBrowserLine = (caretLineTop, rep) => {
const caretRepLine = rep.selStart[0]; const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode; const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const firstRootNode = getFirstRootChildNode(lineNode); const firstRootNode = getFirstRootChildNode(lineNode);
// to get the position of the node we get the position of the first char
// to get the position of the node we get the position of the first char const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1);
const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1); return positionOfFirstRootNode.top === caretLineTop;
return positionOfFirstRootNode.top === caretLineTop;
}; };
// find the first root node, usually it is a text node // find the first root node, usually it is a text node
const getFirstRootChildNode = (node) => { const getFirstRootChildNode = (node) => {
if (!node.firstChild) { if (!node.firstChild) {
return node; return node;
} else { }
return getFirstRootChildNode(node.firstChild); else {
} return getFirstRootChildNode(node.firstChild);
}
}; };
const getDimensionOfLastBrowserLineOfRepLine = (line, rep) => { const getDimensionOfLastBrowserLineOfRepLine = (line, rep) => {
const lineNode = rep.lines.atIndex(line).lineNode; const lineNode = rep.lines.atIndex(line).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode); const lastRootChildNode = getLastRootChildNode(lineNode);
// we get the position of the line in the last char of it
// we get the position of the line in the last char of it const lastRootChildNodePosition = getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
const lastRootChildNodePosition = return lastRootChildNodePosition;
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
return lastRootChildNodePosition;
}; };
const getLastRootChildNode = (node) => { const getLastRootChildNode = (node) => {
if (!node.lastChild) { if (!node.lastChild) {
return { return {
node, node,
length: node.length, length: node.length,
}; };
} else { }
return getLastRootChildNode(node.lastChild); else {
} return getLastRootChildNode(node.lastChild);
}
}; };
// here we have two possibilities:
// [1] The next line is part of the same rep line of the caret line, so we have the same dimensions.
// So, we can use the caret line to calculate the bottom of the line.
// [2] the next line is part of another rep line.
// It's possible this line has different dimensions, so we have to get the exactly dimension of it
exports.getBottomOfNextBrowserLine = (caretLinePosition, rep) => {
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
const isCaretLineLastBrowserLine =
caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep);
// the caret is at the end of a rep line, so we can get the next browser line dimension
// using the position of the first char of the next rep line
if (isCaretLineLastBrowserLine) { // [2]
const nextLineAfterCaretLine = rep.selStart[0] + 1;
const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
const linePosition =
getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep);
nextLineBottom = linePosition.bottom;
}
return nextLineBottom;
};
const caretLineIsLastBrowserLineOfRepLine = (caretLineTop, rep) => { const caretLineIsLastBrowserLineOfRepLine = (caretLineTop, rep) => {
const caretRepLine = rep.selStart[0]; const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode; const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode); const lastRootChildNode = getLastRootChildNode(lineNode);
// we take a rep line and get the position of the last char of it
// we take a rep line and get the position of the last char of it const lastRootChildNodePosition = getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
const lastRootChildNodePosition = return lastRootChildNodePosition.top === caretLineTop;
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
return lastRootChildNodePosition.top === caretLineTop;
}; };
const getPreviousVisibleLine = (line, rep) => { const getPreviousVisibleLine = (line, rep) => {
const firstLineOfPad = 0; const firstLineOfPad = 0;
if (line <= firstLineOfPad) { if (line <= firstLineOfPad) {
return firstLineOfPad; return firstLineOfPad;
} else if (isLineVisible(line, rep)) { }
return line; else if (isLineVisible(line, rep)) {
} else { return line;
return getPreviousVisibleLine(line - 1, rep); }
} else {
return getPreviousVisibleLine(line - 1, rep);
}
}; };
exports.getPreviousVisibleLine = getPreviousVisibleLine;
const getNextVisibleLine = (line, rep) => { const getNextVisibleLine = (line, rep) => {
const lastLineOfThePad = rep.lines.length() - 1; const lastLineOfThePad = rep.lines.length() - 1;
if (line >= lastLineOfThePad) { if (line >= lastLineOfThePad) {
return lastLineOfThePad; return lastLineOfThePad;
} else if (isLineVisible(line, rep)) { }
return line; else if (isLineVisible(line, rep)) {
} else { return line;
return getNextVisibleLine(line + 1, rep); }
} else {
return getNextVisibleLine(line + 1, rep);
}
}; };
exports.getNextVisibleLine = getNextVisibleLine;
const isLineVisible = (line, rep) => rep.lines.atIndex(line).lineNode.offsetHeight > 0; const isLineVisible = (line, rep) => rep.lines.atIndex(line).lineNode.offsetHeight > 0;
const getDimensionOfFirstBrowserLineOfRepLine = (line, rep) => { const getDimensionOfFirstBrowserLineOfRepLine = (line, rep) => {
const lineNode = rep.lines.atIndex(line).lineNode; const lineNode = rep.lines.atIndex(line).lineNode;
const firstRootChildNode = getFirstRootChildNode(lineNode); const firstRootChildNode = getFirstRootChildNode(lineNode);
// we can get the position of the line, getting the position of the first char of the rep line
// we can get the position of the line, getting the position of the first char of the rep line const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1);
const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1); return firstRootChildNodePosition;
return firstRootChildNodePosition;
}; };
const getSelectionRange = () => { const getSelectionRange = () => {
if (!window.getSelection) { if (!window.getSelection) {
return; return;
} }
const selection = window.getSelection(); const selection = window.getSelection();
if (selection && selection.type !== 'None' && selection.rangeCount > 0) { if (selection && selection.type !== 'None' && selection.rangeCount > 0) {
return selection.getRangeAt(0); return selection.getRangeAt(0);
} else { }
return null; else {
} return null;
}
}; };
export const getPosition = () => {
const range = getSelectionRange();
if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody')
return null;
// When there's a <br> or any element that has no height, we can't get the dimension of the
// element where the caret is. As we can't get the element height, we create a text node to get
// the dimensions on the position.
const clonedRange = createSelectionRange(range);
const shadowCaret = $(document.createTextNode('|'));
clonedRange.insertNode(shadowCaret[0]);
clonedRange.selectNode(shadowCaret[0]);
const line = getPositionOfElementOrSelection(clonedRange);
shadowCaret.remove();
return line;
};
export const getPositionTopOfPreviousBrowserLine = (caretLinePosition, rep) => {
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
const isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep);
// the caret is in the beginning of a rep line, so the previous browser line
// is the last line browser line of the a rep line
if (isCaretLineFirstBrowserLine) { // [2]
const lineBeforeCaretLine = rep.selStart[0] - 1;
const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
const linePosition = getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep);
previousLineTop = linePosition.top;
}
return previousLineTop;
};
export const getBottomOfNextBrowserLine = (caretLinePosition, rep) => {
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
const isCaretLineLastBrowserLine = caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep);
// the caret is at the end of a rep line, so we can get the next browser line dimension
// using the position of the first char of the next rep line
if (isCaretLineLastBrowserLine) { // [2]
const nextLineAfterCaretLine = rep.selStart[0] + 1;
const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
const linePosition = getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep);
nextLineBottom = linePosition.bottom;
}
return nextLineBottom;
};
export { getPreviousVisibleLine };
export { getNextVisibleLine };

View file

@ -1,203 +1,171 @@
import AttributeMap from "./AttributeMap.js";
import AttributePool from "./AttributePool.js";
import * as Changeset from "./Changeset.js";
'use strict'; 'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const AttributeMap = require('./AttributeMap');
const AttributePool = require('./AttributePool');
const Changeset = require('./Changeset');
const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => { const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
// latest official text from server // latest official text from server
let baseAText = Changeset.makeAText('\n'); let baseAText = Changeset.makeAText('\n');
// changes applied to baseText that have been submitted // changes applied to baseText that have been submitted
let submittedChangeset = null; let submittedChangeset = null;
// changes applied to submittedChangeset since it was prepared // changes applied to submittedChangeset since it was prepared
let userChangeset = Changeset.identity(1); let userChangeset = Changeset.identity(1);
// is the changesetTracker enabled // is the changesetTracker enabled
let tracking = false; let tracking = false;
// stack state flag so that when we change the rep we don't // stack state flag so that when we change the rep we don't
// handle the notification recursively. When setting, always // handle the notification recursively. When setting, always
// unset in a "finally" block. When set to true, the setter // unset in a "finally" block. When set to true, the setter
// takes change of userChangeset. // takes change of userChangeset.
let applyingNonUserChanges = false; let applyingNonUserChanges = false;
let changeCallback = null;
let changeCallback = null; let changeCallbackTimeout = null;
const setChangeCallbackTimeout = () => {
let changeCallbackTimeout = null; // can call this multiple times per call-stack, because
// we only schedule a call to changeCallback if it exists
const setChangeCallbackTimeout = () => { // and if there isn't a timeout already scheduled.
// can call this multiple times per call-stack, because if (changeCallback && changeCallbackTimeout == null) {
// we only schedule a call to changeCallback if it exists changeCallbackTimeout = scheduler.setTimeout(() => {
// and if there isn't a timeout already scheduled. try {
if (changeCallback && changeCallbackTimeout == null) { changeCallback();
changeCallbackTimeout = scheduler.setTimeout(() => { }
try { catch (pseudoError) {
changeCallback(); // as empty as my soul
} catch (pseudoError) { }
// as empty as my soul finally {
} finally { changeCallbackTimeout = null;
changeCallbackTimeout = null; }
}, 0);
} }
}, 0); };
} let self;
}; return self = {
isTracking: () => tracking,
let self; setBaseText: (text) => {
return self = { self.setBaseAttributedText(Changeset.makeAText(text), null);
isTracking: () => tracking, },
setBaseText: (text) => { setBaseAttributedText: (atext, apoolJsonObj) => {
self.setBaseAttributedText(Changeset.makeAText(text), null); aceCallbacksProvider.withCallbacks('setBaseText', (callbacks) => {
}, tracking = true;
setBaseAttributedText: (atext, apoolJsonObj) => { baseAText = Changeset.cloneAText(atext);
aceCallbacksProvider.withCallbacks('setBaseText', (callbacks) => { if (apoolJsonObj) {
tracking = true; const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj);
baseAText = Changeset.cloneAText(atext); baseAText.attribs = Changeset.moveOpsToNewPool(baseAText.attribs, wireApool, apool);
if (apoolJsonObj) { }
const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj); submittedChangeset = null;
baseAText.attribs = Changeset.moveOpsToNewPool(baseAText.attribs, wireApool, apool); userChangeset = Changeset.identity(atext.text.length);
} applyingNonUserChanges = true;
submittedChangeset = null; try {
userChangeset = Changeset.identity(atext.text.length); callbacks.setDocumentAttributedText(atext);
applyingNonUserChanges = true; }
try { finally {
callbacks.setDocumentAttributedText(atext); applyingNonUserChanges = false;
} finally { }
applyingNonUserChanges = false; });
} },
}); composeUserChangeset: (c) => {
}, if (!tracking)
composeUserChangeset: (c) => { return;
if (!tracking) return; if (applyingNonUserChanges)
if (applyingNonUserChanges) return; return;
if (Changeset.isIdentity(c)) return; if (Changeset.isIdentity(c))
userChangeset = Changeset.compose(userChangeset, c, apool); return;
userChangeset = Changeset.compose(userChangeset, c, apool);
setChangeCallbackTimeout(); setChangeCallbackTimeout();
}, },
applyChangesToBase: (c, optAuthor, apoolJsonObj) => { applyChangesToBase: (c, optAuthor, apoolJsonObj) => {
if (!tracking) return; if (!tracking)
return;
aceCallbacksProvider.withCallbacks('applyChangesToBase', (callbacks) => { aceCallbacksProvider.withCallbacks('applyChangesToBase', (callbacks) => {
if (apoolJsonObj) { if (apoolJsonObj) {
const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj); const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj);
c = Changeset.moveOpsToNewPool(c, wireApool, apool); c = Changeset.moveOpsToNewPool(c, wireApool, apool);
} }
baseAText = Changeset.applyToAText(c, baseAText, apool);
baseAText = Changeset.applyToAText(c, baseAText, apool); let c2 = c;
if (submittedChangeset) {
let c2 = c; const oldSubmittedChangeset = submittedChangeset;
if (submittedChangeset) { submittedChangeset = Changeset.follow(c, oldSubmittedChangeset, false, apool);
const oldSubmittedChangeset = submittedChangeset; c2 = Changeset.follow(oldSubmittedChangeset, c, true, apool);
submittedChangeset = Changeset.follow(c, oldSubmittedChangeset, false, apool); }
c2 = Changeset.follow(oldSubmittedChangeset, c, true, apool); const preferInsertingAfterUserChanges = true;
} const oldUserChangeset = userChangeset;
userChangeset = Changeset.follow(c2, oldUserChangeset, preferInsertingAfterUserChanges, apool);
const preferInsertingAfterUserChanges = true; const postChange = Changeset.follow(oldUserChangeset, c2, !preferInsertingAfterUserChanges, apool);
const oldUserChangeset = userChangeset; const preferInsertionAfterCaret = true; // (optAuthor && optAuthor > thisAuthor);
userChangeset = Changeset.follow( applyingNonUserChanges = true;
c2, oldUserChangeset, preferInsertingAfterUserChanges, apool); try {
const postChange = Changeset.follow( callbacks.applyChangesetToDocument(postChange, preferInsertionAfterCaret);
oldUserChangeset, c2, !preferInsertingAfterUserChanges, apool); }
finally {
const preferInsertionAfterCaret = true; // (optAuthor && optAuthor > thisAuthor); applyingNonUserChanges = false;
applyingNonUserChanges = true; }
try { });
callbacks.applyChangesetToDocument(postChange, preferInsertionAfterCaret); },
} finally { prepareUserChangeset: () => {
applyingNonUserChanges = false; // If there are user changes to submit, 'changeset' will be the
} // changeset, else it will be null.
}); let toSubmit;
}, if (submittedChangeset) {
prepareUserChangeset: () => { // submission must have been canceled, prepare new changeset
// If there are user changes to submit, 'changeset' will be the // that includes old submittedChangeset
// changeset, else it will be null. toSubmit = Changeset.compose(submittedChangeset, userChangeset, apool);
let toSubmit;
if (submittedChangeset) {
// submission must have been canceled, prepare new changeset
// that includes old submittedChangeset
toSubmit = Changeset.compose(submittedChangeset, userChangeset, apool);
} else {
// Get my authorID
const authorId = parent.parent.pad.myUserInfo.userId;
// Sanitize authorship: Replace all author attributes with this user's author ID in case the
// text was copied from another author.
const cs = Changeset.unpack(userChangeset);
const assem = Changeset.mergingOpAssembler();
for (const op of Changeset.deserializeOps(cs.ops)) {
if (op.opcode === '+') {
const attribs = AttributeMap.fromString(op.attribs, apool);
const oldAuthorId = attribs.get('author');
if (oldAuthorId != null && oldAuthorId !== authorId) {
attribs.set('author', authorId);
op.attribs = attribs.toString();
} }
} else {
assem.append(op); // Get my authorID
} const authorId = parent.parent.pad.myUserInfo.userId;
assem.endDocument(); // Sanitize authorship: Replace all author attributes with this user's author ID in case the
userChangeset = Changeset.pack(cs.oldLen, cs.newLen, assem.toString(), cs.charBank); // text was copied from another author.
Changeset.checkRep(userChangeset); const cs = Changeset.unpack(userChangeset);
const assem = Changeset.mergingOpAssembler();
if (Changeset.isIdentity(userChangeset)) toSubmit = null; for (const op of Changeset.deserializeOps(cs.ops)) {
else toSubmit = userChangeset; if (op.opcode === '+') {
} const attribs = AttributeMap.fromString(op.attribs, apool);
const oldAuthorId = attribs.get('author');
let cs = null; if (oldAuthorId != null && oldAuthorId !== authorId) {
if (toSubmit) { attribs.set('author', authorId);
submittedChangeset = toSubmit; op.attribs = attribs.toString();
userChangeset = Changeset.identity(Changeset.newLen(toSubmit)); }
}
cs = toSubmit; assem.append(op);
} }
let wireApool = null; assem.endDocument();
if (cs) { userChangeset = Changeset.pack(cs.oldLen, cs.newLen, assem.toString(), cs.charBank);
const forWire = Changeset.prepareForWire(cs, apool); Changeset.checkRep(userChangeset);
wireApool = forWire.pool.toJsonable(); if (Changeset.isIdentity(userChangeset))
cs = forWire.translated; toSubmit = null;
} else
toSubmit = userChangeset;
const data = { }
changeset: cs, let cs = null;
apool: wireApool, if (toSubmit) {
}; submittedChangeset = toSubmit;
return data; userChangeset = Changeset.identity(Changeset.newLen(toSubmit));
}, cs = toSubmit;
applyPreparedChangesetToBase: () => { }
if (!submittedChangeset) { let wireApool = null;
// violation of protocol; use prepareUserChangeset first if (cs) {
throw new Error('applySubmittedChangesToBase: no submitted changes to apply'); const forWire = Changeset.prepareForWire(cs, apool);
} wireApool = forWire.pool.toJsonable();
// bumpDebug("applying committed changeset: "+submittedChangeset.encodeToString(false)); cs = forWire.translated;
baseAText = Changeset.applyToAText(submittedChangeset, baseAText, apool); }
submittedChangeset = null; const data = {
}, changeset: cs,
setUserChangeNotificationCallback: (callback) => { apool: wireApool,
changeCallback = callback; };
}, return data;
hasUncommittedChanges: () => !!(submittedChangeset || (!Changeset.isIdentity(userChangeset))), },
}; applyPreparedChangesetToBase: () => {
if (!submittedChangeset) {
// violation of protocol; use prepareUserChangeset first
throw new Error('applySubmittedChangesToBase: no submitted changes to apply');
}
// bumpDebug("applying committed changeset: "+submittedChangeset.encodeToString(false));
baseAText = Changeset.applyToAText(submittedChangeset, baseAText, apool);
submittedChangeset = null;
},
setUserChangeNotificationCallback: (callback) => {
changeCallback = callback;
},
hasUncommittedChanges: () => !!(submittedChangeset || (!Changeset.isIdentity(userChangeset))),
};
}; };
export { makeChangesetTracker };
exports.makeChangesetTracker = makeChangesetTracker;

View file

@ -1,274 +1,247 @@
import ChatMessage from "./ChatMessage.js";
import { padutils as padutils$0 } from "./pad_utils.js";
import { padcookie as padcookie$0 } from "./pad_cookie.js";
import Tinycon from "tinycon/tinycon";
import * as hooks from "./pluginfw/hooks.js";
import { padeditor as padeditor$0 } from "./pad_editor.js";
'use strict'; 'use strict';
/** const padutils = { padutils: padutils$0 }.padutils;
* Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd) const padcookie = { padcookie: padcookie$0 }.padcookie;
* const padeditor = { padeditor: padeditor$0 }.padeditor;
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const ChatMessage = require('./ChatMessage');
const padutils = require('./pad_utils').padutils;
const padcookie = require('./pad_cookie').padcookie;
const Tinycon = require('tinycon/tinycon');
const hooks = require('./pluginfw/hooks');
const padeditor = require('./pad_editor').padeditor;
// Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463 // Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463
const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
export const chat = (() => {
exports.chat = (() => { let isStuck = false;
let isStuck = false; let userAndChat = false;
let userAndChat = false; let chatMentions = 0;
let chatMentions = 0; return {
return { show() {
show() { $('#chaticon').removeClass('visible');
$('#chaticon').removeClass('visible'); $('#chatbox').addClass('visible');
$('#chatbox').addClass('visible'); this.scrollDown(true);
this.scrollDown(true); chatMentions = 0;
chatMentions = 0; Tinycon.setBubble(0);
Tinycon.setBubble(0); $('.chat-gritter-msg').each(function () {
$('.chat-gritter-msg').each(function () { $.gritter.remove(this.id);
$.gritter.remove(this.id); });
}); },
}, focus: () => {
focus: () => { setTimeout(() => {
setTimeout(() => { $('#chatinput').focus();
$('#chatinput').focus(); }, 100);
}, 100); },
}, // Make chat stick to right hand side of screen
// Make chat stick to right hand side of screen stickToScreen(fromInitialCall) {
stickToScreen(fromInitialCall) { if (pad.settings.hideChat) {
if (pad.settings.hideChat) { return;
return; }
} this.show();
this.show(); isStuck = (!isStuck || fromInitialCall);
isStuck = (!isStuck || fromInitialCall); $('#chatbox').hide();
$('#chatbox').hide(); // Add timeout to disable the chatbox animations
// Add timeout to disable the chatbox animations setTimeout(() => {
setTimeout(() => { $('#chatbox, .sticky-container').toggleClass('stickyChat', isStuck);
$('#chatbox, .sticky-container').toggleClass('stickyChat', isStuck); $('#chatbox').css('display', 'flex');
$('#chatbox').css('display', 'flex'); }, 0);
}, 0); padcookie.setPref('chatAlwaysVisible', isStuck);
$('#options-stickychat').prop('checked', isStuck);
padcookie.setPref('chatAlwaysVisible', isStuck); },
$('#options-stickychat').prop('checked', isStuck); chatAndUsers(fromInitialCall) {
}, const toEnable = $('#options-chatandusers').is(':checked');
chatAndUsers(fromInitialCall) { if (toEnable || !userAndChat || fromInitialCall) {
const toEnable = $('#options-chatandusers').is(':checked'); this.stickToScreen(true);
if (toEnable || !userAndChat || fromInitialCall) { $('#options-stickychat').prop('checked', true);
this.stickToScreen(true); $('#options-chatandusers').prop('checked', true);
$('#options-stickychat').prop('checked', true); $('#options-stickychat').prop('disabled', 'disabled');
$('#options-chatandusers').prop('checked', true); userAndChat = true;
$('#options-stickychat').prop('disabled', 'disabled'); }
userAndChat = true; else {
} else { $('#options-stickychat').prop('disabled', false);
$('#options-stickychat').prop('disabled', false); userAndChat = false;
userAndChat = false; }
} padcookie.setPref('chatAndUsers', userAndChat);
padcookie.setPref('chatAndUsers', userAndChat); $('#users, .sticky-container')
$('#users, .sticky-container') .toggleClass('chatAndUsers popup-show stickyUsers', userAndChat);
.toggleClass('chatAndUsers popup-show stickyUsers', userAndChat); $('#chatbox').toggleClass('chatAndUsersChat', userAndChat);
$('#chatbox').toggleClass('chatAndUsersChat', userAndChat); },
}, hide() {
hide() { // decide on hide logic based on chat window being maximized or not
// decide on hide logic based on chat window being maximized or not if ($('#options-stickychat').prop('checked')) {
if ($('#options-stickychat').prop('checked')) { this.stickToScreen();
this.stickToScreen(); $('#options-stickychat').prop('checked', false);
$('#options-stickychat').prop('checked', false); }
} else { else {
$('#chatcounter').text('0'); $('#chatcounter').text('0');
$('#chaticon').addClass('visible'); $('#chaticon').addClass('visible');
$('#chatbox').removeClass('visible'); $('#chatbox').removeClass('visible');
} }
}, },
scrollDown(force) { scrollDown(force) {
if ($('#chatbox').hasClass('visible')) { if ($('#chatbox').hasClass('visible')) {
if (force || !this.lastMessage || !this.lastMessage.position() || if (force || !this.lastMessage || !this.lastMessage.position() ||
this.lastMessage.position().top < ($('#chattext').outerHeight() + 20)) { this.lastMessage.position().top < ($('#chattext').outerHeight() + 20)) {
// if we use a slow animate here we can have a race condition // if we use a slow animate here we can have a race condition
// when a users focus can not be moved away from the last message recieved. // when a users focus can not be moved away from the last message recieved.
$('#chattext').animate( $('#chattext').animate({ scrollTop: $('#chattext')[0].scrollHeight }, { duration: 400, queue: false });
{scrollTop: $('#chattext')[0].scrollHeight}, this.lastMessage = $('#chattext > p').eq(-1);
{duration: 400, queue: false}); }
this.lastMessage = $('#chattext > p').eq(-1); }
} },
} async send() {
}, const text = $('#chatinput').val();
async send() { if (text.replace(/\s+/, '').length === 0)
const text = $('#chatinput').val(); return;
if (text.replace(/\s+/, '').length === 0) return; const message = new ChatMessage(text);
const message = new ChatMessage(text); await hooks.aCallAll('chatSendMessage', Object.freeze({ message }));
await hooks.aCallAll('chatSendMessage', Object.freeze({message})); this._pad.collabClient.sendMessage({ type: 'CHAT_MESSAGE', message });
this._pad.collabClient.sendMessage({type: 'CHAT_MESSAGE', message}); $('#chatinput').val('');
$('#chatinput').val(''); },
}, async addMessage(msg, increment, isHistoryAdd) {
async addMessage(msg, increment, isHistoryAdd) { msg = ChatMessage.fromObject(msg);
msg = ChatMessage.fromObject(msg); // correct the time
// correct the time msg.time += this._pad.clientTimeOffset;
msg.time += this._pad.clientTimeOffset; if (!msg.authorId) {
/*
if (!msg.authorId) { * If, for a bug or a database corruption, the message coming from the
/* * server does not contain the authorId field (see for example #3731),
* If, for a bug or a database corruption, the message coming from the * let's be defensive and replace it with "unknown".
* server does not contain the authorId field (see for example #3731), */
* let's be defensive and replace it with "unknown". msg.authorId = 'unknown';
*/ console.warn('The "authorId" field of a chat message coming from the server was not present. ' +
msg.authorId = 'unknown'; 'Replacing with "unknown". This may be a bug or a database corruption.');
console.warn( }
'The "authorId" field of a chat message coming from the server was not present. ' + const authorClass = (authorId) => `author-${authorId.replace(/[^a-y0-9]/g, (c) => {
'Replacing with "unknown". This may be a bug or a database corruption.'); if (c === '.')
} return '-';
return `z${c.charCodeAt(0)}z`;
const authorClass = (authorId) => `author-${authorId.replace(/[^a-y0-9]/g, (c) => { })}`;
if (c === '.') return '-'; // the hook args
return `z${c.charCodeAt(0)}z`; const ctx = {
})}`; authorName: msg.displayName != null ? msg.displayName : html10n.get('pad.userlist.unnamed'),
author: msg.authorId,
// the hook args text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'),
const ctx = { message: msg,
authorName: msg.displayName != null ? msg.displayName : html10n.get('pad.userlist.unnamed'), rendered: null,
author: msg.authorId, sticky: false,
text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'), timestamp: msg.time,
message: msg, timeStr: (() => {
rendered: null, let minutes = `${new Date(msg.time).getMinutes()}`;
sticky: false, let hours = `${new Date(msg.time).getHours()}`;
timestamp: msg.time, if (minutes.length === 1)
timeStr: (() => { minutes = `0${minutes}`;
let minutes = `${new Date(msg.time).getMinutes()}`; if (hours.length === 1)
let hours = `${new Date(msg.time).getHours()}`; hours = `0${hours}`;
if (minutes.length === 1) minutes = `0${minutes}`; return `${hours}:${minutes}`;
if (hours.length === 1) hours = `0${hours}`; })(),
return `${hours}:${minutes}`; duration: 4000,
})(), };
duration: 4000, // is the users focus already in the chatbox?
}; const alreadyFocused = $('#chatinput').is(':focus');
// does the user already have the chatbox open?
// is the users focus already in the chatbox? const chatOpen = $('#chatbox').hasClass('visible');
const alreadyFocused = $('#chatinput').is(':focus'); // does this message contain this user's name? (is the current user mentioned?)
const wasMentioned = msg.authorId !== window.clientVars.userId &&
// does the user already have the chatbox open? ctx.authorName !== html10n.get('pad.userlist.unnamed') &&
const chatOpen = $('#chatbox').hasClass('visible'); normalize(ctx.text).includes(normalize(ctx.authorName));
// If the user was mentioned, make the message sticky
// does this message contain this user's name? (is the current user mentioned?) if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {
const wasMentioned = chatMentions++;
msg.authorId !== window.clientVars.userId && Tinycon.setBubble(chatMentions);
ctx.authorName !== html10n.get('pad.userlist.unnamed') && ctx.sticky = true;
normalize(ctx.text).includes(normalize(ctx.authorName)); }
await hooks.aCallAll('chatNewMessage', ctx);
// If the user was mentioned, make the message sticky const cls = authorClass(ctx.author);
if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) { const chatMsg = ctx.rendered != null ? $(ctx.rendered) : $('<p>')
chatMentions++; .attr('data-authorId', ctx.author)
Tinycon.setBubble(chatMentions); .addClass(cls)
ctx.sticky = true; .append($('<b>').text(`${ctx.authorName}:`))
} .append($('<span>')
.addClass('time')
await hooks.aCallAll('chatNewMessage', ctx); .addClass(cls)
const cls = authorClass(ctx.author); // Hook functions are trusted to not introduce an XSS vulnerability by adding
const chatMsg = ctx.rendered != null ? $(ctx.rendered) : $('<p>') // unescaped user input to ctx.timeStr.
.attr('data-authorId', ctx.author) .html(ctx.timeStr))
.addClass(cls) .append(' ')
.append($('<b>').text(`${ctx.authorName}:`)) // ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not
.append($('<span>') // introduce an XSS vulnerability by adding unescaped user input.
.addClass('time') .append($('<div>').html(ctx.text).contents());
.addClass(cls) if (isHistoryAdd)
// Hook functions are trusted to not introduce an XSS vulnerability by adding chatMsg.insertAfter('#chatloadmessagesbutton');
// unescaped user input to ctx.timeStr. else
.html(ctx.timeStr)) $('#chattext').append(chatMsg);
.append(' ') chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e));
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not // should we increment the counter??
// introduce an XSS vulnerability by adding unescaped user input. if (increment && !isHistoryAdd) {
.append($('<div>').html(ctx.text).contents()); // Update the counter of unread messages
if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton'); let count = Number($('#chatcounter').text());
else $('#chattext').append(chatMsg); count++;
chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e)); $('#chatcounter').text(count);
if (!chatOpen && ctx.duration > 0) {
// should we increment the counter?? const text = $('<p>')
if (increment && !isHistoryAdd) { .append($('<span>').addClass('author-name').text(ctx.authorName))
// Update the counter of unread messages // ctx.text was HTML-escaped before calling the hook. Hook functions are trusted
let count = Number($('#chatcounter').text()); // to not introduce an XSS vulnerability by adding unescaped user input.
count++; .append($('<div>').html(ctx.text).contents());
$('#chatcounter').text(count); text.each((i, e) => html10n.translateElement(html10n.translations, e));
$.gritter.add({
if (!chatOpen && ctx.duration > 0) { text,
const text = $('<p>') sticky: ctx.sticky,
.append($('<span>').addClass('author-name').text(ctx.authorName)) time: ctx.duration,
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted position: 'bottom',
// to not introduce an XSS vulnerability by adding unescaped user input. class_name: 'chat-gritter-msg',
.append($('<div>').html(ctx.text).contents()); });
text.each((i, e) => html10n.translateElement(html10n.translations, e)); }
$.gritter.add({ }
text, if (!isHistoryAdd)
sticky: ctx.sticky, this.scrollDown();
time: ctx.duration, },
position: 'bottom', init(pad) {
class_name: 'chat-gritter-msg', this._pad = pad;
}); $('#chatinput').on('keydown', (evt) => {
} // If the event is Alt C or Escape & we're already in the chat menu
} // Send the users focus back to the pad
if (!isHistoryAdd) this.scrollDown(); if ((evt.altKey === true && evt.which === 67) || evt.which === 27) {
}, // If we're in chat already..
init(pad) { $(':focus').blur(); // required to do not try to remove!
this._pad = pad; padeditor.ace.focus(); // Sends focus back to pad
$('#chatinput').on('keydown', (evt) => { evt.preventDefault();
// If the event is Alt C or Escape & we're already in the chat menu return false;
// Send the users focus back to the pad }
if ((evt.altKey === true && evt.which === 67) || evt.which === 27) { });
// If we're in chat already.. // Clear the chat mentions when the user clicks on the chat input box
$(':focus').blur(); // required to do not try to remove! $('#chatinput').click(() => {
padeditor.ace.focus(); // Sends focus back to pad chatMentions = 0;
evt.preventDefault(); Tinycon.setBubble(0);
return false; });
} const self = this;
}); $('body:not(#chatinput)').on('keypress', function (evt) {
// Clear the chat mentions when the user clicks on the chat input box if (evt.altKey && evt.which === 67) {
$('#chatinput').click(() => { // Alt c focuses on the Chat window
chatMentions = 0; $(this).blur();
Tinycon.setBubble(0); self.show();
}); $('#chatinput').focus();
evt.preventDefault();
const self = this; }
$('body:not(#chatinput)').on('keypress', function (evt) { });
if (evt.altKey && evt.which === 67) { $('#chatinput').keypress((evt) => {
// Alt c focuses on the Chat window // if the user typed enter, fire the send
$(this).blur(); if (evt.key === 'Enter' && !evt.shiftKey) {
self.show(); evt.preventDefault();
$('#chatinput').focus(); this.send();
evt.preventDefault(); }
} });
}); // initial messages are loaded in pad.js' _afterHandshake
$('#chatcounter').text(0);
$('#chatinput').keypress((evt) => { $('#chatloadmessagesbutton').click(() => {
// if the user typed enter, fire the send const start = Math.max(this.historyPointer - 20, 0);
if (evt.key === 'Enter' && !evt.shiftKey) { const end = this.historyPointer;
evt.preventDefault(); if (start === end)
this.send(); return; // nothing to load
} $('#chatloadmessagesbutton').css('display', 'none');
}); $('#chatloadmessagesball').css('display', 'block');
pad.collabClient.sendMessage({ type: 'GET_CHAT_MESSAGES', start, end });
// initial messages are loaded in pad.js' _afterHandshake this.historyPointer = start;
});
$('#chatcounter').text(0); },
$('#chatloadmessagesbutton').click(() => { };
const start = Math.max(this.historyPointer - 20, 0);
const end = this.historyPointer;
if (start === end) return; // nothing to load
$('#chatloadmessagesbutton').css('display', 'none');
$('#chatloadmessagesball').css('display', 'block');
pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end});
this.historyPointer = start;
});
},
};
})(); })();

View file

@ -1,11 +1,12 @@
import { chat as chat$0 } from "./chat.js";
import * as hooks from "./pluginfw/hooks.js";
import browser from "./vendors/browser.js";
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it. * This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -21,482 +22,451 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const chat = { chat: chat$0 }.chat;
const chat = require('./chat').chat;
const hooks = require('./pluginfw/hooks');
const browser = require('./vendors/browser');
// Dependency fill on init. This exists for `pad.socket` only. // Dependency fill on init. This exists for `pad.socket` only.
// TODO: bind directly to the socket. // TODO: bind directly to the socket.
let pad = undefined; let pad = undefined;
const getSocket = () => pad && pad.socket; const getSocket = () => pad && pad.socket;
/** Call this when the document is ready, and a new Ace2Editor() has been created and inited. /** Call this when the document is ready, and a new Ace2Editor() has been created and inited.
ACE's ready callback does not need to have fired yet. ACE's ready callback does not need to have fired yet.
"serverVars" are from calling doc.getCollabClientVars() on the server. */ "serverVars" are from calling doc.getCollabClientVars() on the server. */
const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad) => { const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad) => {
const editor = ace2editor; const editor = ace2editor;
pad = _pad; // Inject pad to avoid a circular dependency. pad = _pad; // Inject pad to avoid a circular dependency.
let rev = serverVars.rev;
let rev = serverVars.rev; let committing = false;
let committing = false; let stateMessage;
let stateMessage; let channelState = 'CONNECTING';
let channelState = 'CONNECTING'; let lastCommitTime = 0;
let lastCommitTime = 0; let initialStartConnectTime = 0;
let initialStartConnectTime = 0; let commitDelay = 500;
let commitDelay = 500; const userId = initialUserInfo.userId;
// var socket;
const userId = initialUserInfo.userId; const userSet = {}; // userId -> userInfo
// var socket; userSet[userId] = initialUserInfo;
const userSet = {}; // userId -> userInfo let isPendingRevision = false;
userSet[userId] = initialUserInfo; const callbacks = {
onUserJoin: () => { },
let isPendingRevision = false; onUserLeave: () => { },
onUpdateUserInfo: () => { },
const callbacks = { onChannelStateChange: () => { },
onUserJoin: () => {}, onClientMessage: () => { },
onUserLeave: () => {}, onInternalAction: () => { },
onUpdateUserInfo: () => {}, onConnectionTrouble: () => { },
onChannelStateChange: () => {}, onServerMessage: () => { },
onClientMessage: () => {},
onInternalAction: () => {},
onConnectionTrouble: () => {},
onServerMessage: () => {},
};
if (browser.firefox) {
// Prevent "escape" from taking effect and canceling a comet connection;
// doesn't work if focus is on an iframe.
$(window).bind('keydown', (evt) => {
if (evt.which === 27) {
evt.preventDefault();
}
});
}
const handleUserChanges = () => {
if (editor.getInInternationalComposition()) {
// handleUserChanges() will be called again once composition ends so there's no need to set up
// a future call before returning.
return;
}
const now = Date.now();
if ((!getSocket()) || channelState === 'CONNECTING') {
if (channelState === 'CONNECTING' && (now - initialStartConnectTime) > 20000) {
setChannelState('DISCONNECTED', 'initsocketfail');
} else {
// check again in a bit
setTimeout(handleUserChanges, 1000);
}
return;
}
if (committing) {
if (now - lastCommitTime > 20000) {
// a commit is taking too long
setChannelState('DISCONNECTED', 'slowcommit');
} else if (now - lastCommitTime > 5000) {
callbacks.onConnectionTrouble('SLOW');
} else {
// run again in a few seconds, to detect a disconnect
setTimeout(handleUserChanges, 3000);
}
return;
}
const earliestCommit = lastCommitTime + commitDelay;
if (now < earliestCommit) {
setTimeout(handleUserChanges, earliestCommit - now);
return;
}
let sentMessage = false;
// Check if there are any pending revisions to be received from server.
// Allow only if there are no pending revisions to be received from server
if (!isPendingRevision) {
const userChangesData = editor.prepareUserChangeset();
if (userChangesData.changeset) {
lastCommitTime = now;
committing = true;
stateMessage = {
type: 'USER_CHANGES',
baseRev: rev,
changeset: userChangesData.changeset,
apool: userChangesData.apool,
};
sendMessage(stateMessage);
sentMessage = true;
callbacks.onInternalAction('commitPerformed');
}
} else {
// run again in a few seconds, to check if there was a reconnection attempt
setTimeout(handleUserChanges, 3000);
}
if (sentMessage) {
// run again in a few seconds, to detect a disconnect
setTimeout(handleUserChanges, 3000);
}
};
const acceptCommit = () => {
editor.applyPreparedChangesetToBase();
setStateIdle();
try {
callbacks.onInternalAction('commitAcceptedByServer');
callbacks.onConnectionTrouble('OK');
} catch (err) { /* intentionally ignored */ }
handleUserChanges();
};
const setUpSocket = () => {
setChannelState('CONNECTED');
doDeferredActions();
initialStartConnectTime = Date.now();
};
const sendMessage = (msg) => {
getSocket().json.send(
{
type: 'COLLABROOM',
component: 'pad',
data: msg,
});
};
const serverMessageTaskQueue = new class {
constructor() {
this._promiseChain = Promise.resolve();
}
async enqueue(fn) {
const taskPromise = this._promiseChain.then(fn);
// Use .catch() to prevent rejections from halting the queue.
this._promiseChain = taskPromise.catch(() => {});
// Do NOT do `return await this._promiseChain;` because the caller would not see an error if
// fn() throws/rejects (due to the .catch() added above).
return await taskPromise;
}
}();
const handleMessageFromServer = (evt) => {
if (!getSocket()) return;
if (!evt.data) return;
const wrapper = evt;
if (wrapper.type !== 'COLLABROOM' && wrapper.type !== 'CUSTOM') return;
const msg = wrapper.data;
if (msg.type === 'NEW_CHANGES') {
serverMessageTaskQueue.enqueue(async () => {
// Avoid updating the DOM while the user is composing a character. Notes about this `await`:
// * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not
// currently composing a character then execution will continue without error.
// * We assume that it is not possible for a new 'compositionstart' event to fire after
// the `await` but before the next line of code after the `await` (or, if it is
// possible, that the chances are so small or the consequences so minor that it's not
// worth addressing).
await editor.getInInternationalComposition();
const {newRev, changeset, author = '', apool} = msg;
if (newRev !== (rev + 1)) {
window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_newchanges");
return;
}
rev = newRev;
editor.applyChangesToBase(changeset, author, apool);
});
} else if (msg.type === 'ACCEPT_COMMIT') {
serverMessageTaskQueue.enqueue(() => {
const {newRev} = msg;
// newRev will equal rev if the changeset has no net effect (identity changeset, removing
// and re-adding the same characters with the same attributes, or retransmission of an
// already applied changeset).
if (![rev, rev + 1].includes(newRev)) {
window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
return;
}
rev = newRev;
acceptCommit();
});
} else if (msg.type === 'CLIENT_RECONNECT') {
// Server sends a CLIENT_RECONNECT message when there is a client reconnect.
// Server also returns all pending revisions along with this CLIENT_RECONNECT message
serverMessageTaskQueue.enqueue(() => {
if (msg.noChanges) {
// If no revisions are pending, just make everything normal
setIsPendingRevision(false);
return;
}
const {headRev, newRev, changeset, author = '', apool} = msg;
if (newRev !== (rev + 1)) {
window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
return;
}
rev = newRev;
if (author === pad.getUserId()) {
acceptCommit();
} else {
editor.applyChangesToBase(changeset, author, apool);
}
if (newRev === headRev) {
// Once we have applied all pending revisions, make everything normal
setIsPendingRevision(false);
}
});
} else if (msg.type === 'USER_NEWINFO') {
const userInfo = msg.userInfo;
const id = userInfo.userId;
if (userSet[id]) {
userSet[id] = userInfo;
callbacks.onUpdateUserInfo(userInfo);
} else {
userSet[id] = userInfo;
callbacks.onUserJoin(userInfo);
}
tellAceActiveAuthorInfo(userInfo);
} else if (msg.type === 'USER_LEAVE') {
const userInfo = msg.userInfo;
const id = userInfo.userId;
if (userSet[id]) {
delete userSet[userInfo.userId];
fadeAceAuthorInfo(userInfo);
callbacks.onUserLeave(userInfo);
}
} else if (msg.type === 'CLIENT_MESSAGE') {
callbacks.onClientMessage(msg.payload);
} else if (msg.type === 'CHAT_MESSAGE') {
chat.addMessage(msg.message, true, false);
} else if (msg.type === 'CHAT_MESSAGES') {
for (let i = msg.messages.length - 1; i >= 0; i--) {
chat.addMessage(msg.messages[i], true, true);
}
if (!chat.gotInitalMessages) {
chat.scrollDown();
chat.gotInitalMessages = true;
chat.historyPointer = clientVars.chatHead - msg.messages.length;
}
// messages are loaded, so hide the loading-ball
$('#chatloadmessagesball').css('display', 'none');
// there are less than 100 messages or we reached the top
if (chat.historyPointer <= 0) {
$('#chatloadmessagesbutton').css('display', 'none');
} else {
// there are still more messages, re-show the load-button
$('#chatloadmessagesbutton').css('display', 'block');
}
}
// HACKISH: User messages do not have "payload" but "userInfo", so that all
// "handleClientMessage_USER_" hooks would work, populate payload
// FIXME: USER_* messages to have "payload" property instead of "userInfo",
// seems like a quite a big work
if (msg.type.indexOf('USER_') > -1) {
msg.payload = msg.userInfo;
}
// Similar for NEW_CHANGES
if (msg.type === 'NEW_CHANGES') msg.payload = msg;
hooks.callAll(`handleClientMessage_${msg.type}`, {payload: msg.payload});
};
const updateUserInfo = (userInfo) => {
userInfo.userId = userId;
userSet[userId] = userInfo;
tellAceActiveAuthorInfo(userInfo);
if (!getSocket()) return;
sendMessage(
{
type: 'USERINFO_UPDATE',
userInfo,
});
};
const tellAceActiveAuthorInfo = (userInfo) => {
tellAceAuthorInfo(userInfo.userId, userInfo.colorId);
};
const tellAceAuthorInfo = (userId, colorId, inactive) => {
if (typeof colorId === 'number') {
colorId = clientVars.colorPalette[colorId];
}
const cssColor = colorId;
if (inactive) {
editor.setAuthorInfo(userId, {
bgcolor: cssColor,
fade: 0.5,
});
} else {
editor.setAuthorInfo(userId, {
bgcolor: cssColor,
});
}
};
const fadeAceAuthorInfo = (userInfo) => {
tellAceAuthorInfo(userInfo.userId, userInfo.colorId, true);
};
const getConnectedUsers = () => valuesArray(userSet);
const tellAceAboutHistoricalAuthors = (hadata) => {
for (const [author, data] of Object.entries(hadata)) {
if (!userSet[author]) {
tellAceAuthorInfo(author, data.colorId, true);
}
}
};
const setChannelState = (newChannelState, moreInfo) => {
if (newChannelState !== channelState) {
channelState = newChannelState;
callbacks.onChannelStateChange(channelState, moreInfo);
}
};
const valuesArray = (obj) => {
const array = [];
$.each(obj, (k, v) => {
array.push(v);
});
return array;
};
// We need to present a working interface even before the socket
// is connected for the first time.
let deferredActions = [];
const defer = (func, tag) => function (...args) {
const action = () => {
func.call(this, ...args);
}; };
action.tag = tag; if (browser.firefox) {
if (channelState === 'CONNECTING') { // Prevent "escape" from taking effect and canceling a comet connection;
deferredActions.push(action); // doesn't work if focus is on an iframe.
} else { $(window).bind('keydown', (evt) => {
action(); if (evt.which === 27) {
} evt.preventDefault();
}; }
const doDeferredActions = (tag) => {
const newArray = [];
for (let i = 0; i < deferredActions.length; i++) {
const a = deferredActions[i];
if ((!tag) || (tag === a.tag)) {
a();
} else {
newArray.push(a);
}
}
deferredActions = newArray;
};
const sendClientMessage = (msg) => {
sendMessage(
{
type: 'CLIENT_MESSAGE',
payload: msg,
}); });
};
const getCurrentRevisionNumber = () => rev;
const getMissedChanges = () => {
const obj = {};
obj.userInfo = userSet[userId];
obj.baseRev = rev;
if (committing && stateMessage) {
obj.committedChangeset = stateMessage.changeset;
obj.committedChangesetAPool = stateMessage.apool;
editor.applyPreparedChangesetToBase();
} }
const userChangesData = editor.prepareUserChangeset(); const handleUserChanges = () => {
if (userChangesData.changeset) { if (editor.getInInternationalComposition()) {
obj.furtherChangeset = userChangesData.changeset; // handleUserChanges() will be called again once composition ends so there's no need to set up
obj.furtherChangesetAPool = userChangesData.apool; // a future call before returning.
} return;
return obj;
};
const setStateIdle = () => {
committing = false;
callbacks.onInternalAction('newlyIdle');
schedulePerhapsCallIdleFuncs();
};
const setIsPendingRevision = (value) => {
isPendingRevision = value;
};
const idleFuncs = [];
const callWhenNotCommitting = (func) => {
idleFuncs.push(func);
schedulePerhapsCallIdleFuncs();
};
const schedulePerhapsCallIdleFuncs = () => {
setTimeout(() => {
if (!committing) {
while (idleFuncs.length > 0) {
const f = idleFuncs.shift();
f();
} }
} const now = Date.now();
}, 0); if ((!getSocket()) || channelState === 'CONNECTING') {
}; if (channelState === 'CONNECTING' && (now - initialStartConnectTime) > 20000) {
setChannelState('DISCONNECTED', 'initsocketfail');
const self = { }
setOnUserJoin: (cb) => { else {
callbacks.onUserJoin = cb; // check again in a bit
}, setTimeout(handleUserChanges, 1000);
setOnUserLeave: (cb) => { }
callbacks.onUserLeave = cb; return;
}, }
setOnUpdateUserInfo: (cb) => { if (committing) {
callbacks.onUpdateUserInfo = cb; if (now - lastCommitTime > 20000) {
}, // a commit is taking too long
setOnChannelStateChange: (cb) => { setChannelState('DISCONNECTED', 'slowcommit');
callbacks.onChannelStateChange = cb; }
}, else if (now - lastCommitTime > 5000) {
setOnClientMessage: (cb) => { callbacks.onConnectionTrouble('SLOW');
callbacks.onClientMessage = cb; }
}, else {
setOnInternalAction: (cb) => { // run again in a few seconds, to detect a disconnect
callbacks.onInternalAction = cb; setTimeout(handleUserChanges, 3000);
}, }
setOnConnectionTrouble: (cb) => { return;
callbacks.onConnectionTrouble = cb; }
}, const earliestCommit = lastCommitTime + commitDelay;
updateUserInfo: defer(updateUserInfo), if (now < earliestCommit) {
handleMessageFromServer, setTimeout(handleUserChanges, earliestCommit - now);
getConnectedUsers, return;
sendClientMessage, }
sendMessage, let sentMessage = false;
getCurrentRevisionNumber, // Check if there are any pending revisions to be received from server.
getMissedChanges, // Allow only if there are no pending revisions to be received from server
callWhenNotCommitting, if (!isPendingRevision) {
addHistoricalAuthors: tellAceAboutHistoricalAuthors, const userChangesData = editor.prepareUserChangeset();
setChannelState, if (userChangesData.changeset) {
setStateIdle, lastCommitTime = now;
setIsPendingRevision, committing = true;
set commitDelay(ms) { commitDelay = ms; }, stateMessage = {
get commitDelay() { return commitDelay; }, type: 'USER_CHANGES',
}; baseRev: rev,
changeset: userChangesData.changeset,
tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData); apool: userChangesData.apool,
tellAceActiveAuthorInfo(initialUserInfo); };
sendMessage(stateMessage);
editor.setProperty('userAuthor', userId); sentMessage = true;
editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool); callbacks.onInternalAction('commitPerformed');
editor.setUserChangeNotificationCallback(handleUserChanges); }
}
setUpSocket(); else {
return self; // run again in a few seconds, to check if there was a reconnection attempt
setTimeout(handleUserChanges, 3000);
}
if (sentMessage) {
// run again in a few seconds, to detect a disconnect
setTimeout(handleUserChanges, 3000);
}
};
const acceptCommit = () => {
editor.applyPreparedChangesetToBase();
setStateIdle();
try {
callbacks.onInternalAction('commitAcceptedByServer');
callbacks.onConnectionTrouble('OK');
}
catch (err) { /* intentionally ignored */ }
handleUserChanges();
};
const setUpSocket = () => {
setChannelState('CONNECTED');
doDeferredActions();
initialStartConnectTime = Date.now();
};
const sendMessage = (msg) => {
getSocket().json.send({
type: 'COLLABROOM',
component: 'pad',
data: msg,
});
};
const serverMessageTaskQueue = new class {
constructor() {
this._promiseChain = Promise.resolve();
}
async enqueue(fn) {
const taskPromise = this._promiseChain.then(fn);
// Use .catch() to prevent rejections from halting the queue.
this._promiseChain = taskPromise.catch(() => { });
// Do NOT do `return await this._promiseChain;` because the caller would not see an error if
// fn() throws/rejects (due to the .catch() added above).
return await taskPromise;
}
}();
const handleMessageFromServer = (evt) => {
if (!getSocket())
return;
if (!evt.data)
return;
const wrapper = evt;
if (wrapper.type !== 'COLLABROOM' && wrapper.type !== 'CUSTOM')
return;
const msg = wrapper.data;
if (msg.type === 'NEW_CHANGES') {
serverMessageTaskQueue.enqueue(async () => {
// Avoid updating the DOM while the user is composing a character. Notes about this `await`:
// * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not
// currently composing a character then execution will continue without error.
// * We assume that it is not possible for a new 'compositionstart' event to fire after
// the `await` but before the next line of code after the `await` (or, if it is
// possible, that the chances are so small or the consequences so minor that it's not
// worth addressing).
await editor.getInInternationalComposition();
const { newRev, changeset, author = '', apool } = msg;
if (newRev !== (rev + 1)) {
window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_newchanges");
return;
}
rev = newRev;
editor.applyChangesToBase(changeset, author, apool);
});
}
else if (msg.type === 'ACCEPT_COMMIT') {
serverMessageTaskQueue.enqueue(() => {
const { newRev } = msg;
// newRev will equal rev if the changeset has no net effect (identity changeset, removing
// and re-adding the same characters with the same attributes, or retransmission of an
// already applied changeset).
if (![rev, rev + 1].includes(newRev)) {
window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
return;
}
rev = newRev;
acceptCommit();
});
}
else if (msg.type === 'CLIENT_RECONNECT') {
// Server sends a CLIENT_RECONNECT message when there is a client reconnect.
// Server also returns all pending revisions along with this CLIENT_RECONNECT message
serverMessageTaskQueue.enqueue(() => {
if (msg.noChanges) {
// If no revisions are pending, just make everything normal
setIsPendingRevision(false);
return;
}
const { headRev, newRev, changeset, author = '', apool } = msg;
if (newRev !== (rev + 1)) {
window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
return;
}
rev = newRev;
if (author === pad.getUserId()) {
acceptCommit();
}
else {
editor.applyChangesToBase(changeset, author, apool);
}
if (newRev === headRev) {
// Once we have applied all pending revisions, make everything normal
setIsPendingRevision(false);
}
});
}
else if (msg.type === 'USER_NEWINFO') {
const userInfo = msg.userInfo;
const id = userInfo.userId;
if (userSet[id]) {
userSet[id] = userInfo;
callbacks.onUpdateUserInfo(userInfo);
}
else {
userSet[id] = userInfo;
callbacks.onUserJoin(userInfo);
}
tellAceActiveAuthorInfo(userInfo);
}
else if (msg.type === 'USER_LEAVE') {
const userInfo = msg.userInfo;
const id = userInfo.userId;
if (userSet[id]) {
delete userSet[userInfo.userId];
fadeAceAuthorInfo(userInfo);
callbacks.onUserLeave(userInfo);
}
}
else if (msg.type === 'CLIENT_MESSAGE') {
callbacks.onClientMessage(msg.payload);
}
else if (msg.type === 'CHAT_MESSAGE') {
chat.addMessage(msg.message, true, false);
}
else if (msg.type === 'CHAT_MESSAGES') {
for (let i = msg.messages.length - 1; i >= 0; i--) {
chat.addMessage(msg.messages[i], true, true);
}
if (!chat.gotInitalMessages) {
chat.scrollDown();
chat.gotInitalMessages = true;
chat.historyPointer = clientVars.chatHead - msg.messages.length;
}
// messages are loaded, so hide the loading-ball
$('#chatloadmessagesball').css('display', 'none');
// there are less than 100 messages or we reached the top
if (chat.historyPointer <= 0) {
$('#chatloadmessagesbutton').css('display', 'none');
}
else {
// there are still more messages, re-show the load-button
$('#chatloadmessagesbutton').css('display', 'block');
}
}
// HACKISH: User messages do not have "payload" but "userInfo", so that all
// "handleClientMessage_USER_" hooks would work, populate payload
// FIXME: USER_* messages to have "payload" property instead of "userInfo",
// seems like a quite a big work
if (msg.type.indexOf('USER_') > -1) {
msg.payload = msg.userInfo;
}
// Similar for NEW_CHANGES
if (msg.type === 'NEW_CHANGES')
msg.payload = msg;
hooks.callAll(`handleClientMessage_${msg.type}`, { payload: msg.payload });
};
const updateUserInfo = (userInfo) => {
userInfo.userId = userId;
userSet[userId] = userInfo;
tellAceActiveAuthorInfo(userInfo);
if (!getSocket())
return;
sendMessage({
type: 'USERINFO_UPDATE',
userInfo,
});
};
const tellAceActiveAuthorInfo = (userInfo) => {
tellAceAuthorInfo(userInfo.userId, userInfo.colorId);
};
const tellAceAuthorInfo = (userId, colorId, inactive) => {
if (typeof colorId === 'number') {
colorId = clientVars.colorPalette[colorId];
}
const cssColor = colorId;
if (inactive) {
editor.setAuthorInfo(userId, {
bgcolor: cssColor,
fade: 0.5,
});
}
else {
editor.setAuthorInfo(userId, {
bgcolor: cssColor,
});
}
};
const fadeAceAuthorInfo = (userInfo) => {
tellAceAuthorInfo(userInfo.userId, userInfo.colorId, true);
};
const getConnectedUsers = () => valuesArray(userSet);
const tellAceAboutHistoricalAuthors = (hadata) => {
for (const [author, data] of Object.entries(hadata)) {
if (!userSet[author]) {
tellAceAuthorInfo(author, data.colorId, true);
}
}
};
const setChannelState = (newChannelState, moreInfo) => {
if (newChannelState !== channelState) {
channelState = newChannelState;
callbacks.onChannelStateChange(channelState, moreInfo);
}
};
const valuesArray = (obj) => {
const array = [];
$.each(obj, (k, v) => {
array.push(v);
});
return array;
};
// We need to present a working interface even before the socket
// is connected for the first time.
let deferredActions = [];
const defer = (func, tag) => function (...args) {
const action = () => {
func.call(this, ...args);
};
action.tag = tag;
if (channelState === 'CONNECTING') {
deferredActions.push(action);
}
else {
action();
}
};
const doDeferredActions = (tag) => {
const newArray = [];
for (let i = 0; i < deferredActions.length; i++) {
const a = deferredActions[i];
if ((!tag) || (tag === a.tag)) {
a();
}
else {
newArray.push(a);
}
}
deferredActions = newArray;
};
const sendClientMessage = (msg) => {
sendMessage({
type: 'CLIENT_MESSAGE',
payload: msg,
});
};
const getCurrentRevisionNumber = () => rev;
const getMissedChanges = () => {
const obj = {};
obj.userInfo = userSet[userId];
obj.baseRev = rev;
if (committing && stateMessage) {
obj.committedChangeset = stateMessage.changeset;
obj.committedChangesetAPool = stateMessage.apool;
editor.applyPreparedChangesetToBase();
}
const userChangesData = editor.prepareUserChangeset();
if (userChangesData.changeset) {
obj.furtherChangeset = userChangesData.changeset;
obj.furtherChangesetAPool = userChangesData.apool;
}
return obj;
};
const setStateIdle = () => {
committing = false;
callbacks.onInternalAction('newlyIdle');
schedulePerhapsCallIdleFuncs();
};
const setIsPendingRevision = (value) => {
isPendingRevision = value;
};
const idleFuncs = [];
const callWhenNotCommitting = (func) => {
idleFuncs.push(func);
schedulePerhapsCallIdleFuncs();
};
const schedulePerhapsCallIdleFuncs = () => {
setTimeout(() => {
if (!committing) {
while (idleFuncs.length > 0) {
const f = idleFuncs.shift();
f();
}
}
}, 0);
};
const self = {
setOnUserJoin: (cb) => {
callbacks.onUserJoin = cb;
},
setOnUserLeave: (cb) => {
callbacks.onUserLeave = cb;
},
setOnUpdateUserInfo: (cb) => {
callbacks.onUpdateUserInfo = cb;
},
setOnChannelStateChange: (cb) => {
callbacks.onChannelStateChange = cb;
},
setOnClientMessage: (cb) => {
callbacks.onClientMessage = cb;
},
setOnInternalAction: (cb) => {
callbacks.onInternalAction = cb;
},
setOnConnectionTrouble: (cb) => {
callbacks.onConnectionTrouble = cb;
},
updateUserInfo: defer(updateUserInfo),
handleMessageFromServer,
getConnectedUsers,
sendClientMessage,
sendMessage,
getCurrentRevisionNumber,
getMissedChanges,
callWhenNotCommitting,
addHistoricalAuthors: tellAceAboutHistoricalAuthors,
setChannelState,
setStateIdle,
setIsPendingRevision,
set commitDelay(ms) { commitDelay = ms; },
get commitDelay() { return commitDelay; },
};
tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData);
tellAceActiveAuthorInfo(initialUserInfo);
editor.setProperty('userAuthor', userId);
editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool);
editor.setUserChangeNotificationCallback(handleUserChanges);
setUpSocket();
return self;
}; };
export { getCollabClient };
exports.getCollabClient = getCollabClient;

View file

@ -1,11 +1,9 @@
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it. * This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/colorutils.js // DO NOT EDIT THIS FILE, edit infrastructure/ace/www/colorutils.js
// THIS FILE IS ALSO SERVED AS CLIENT-SIDE JS // THIS FILE IS ALSO SERVED AS CLIENT-SIDE JS
/** /**
@ -23,47 +21,39 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const colorutils = {}; const colorutils = {};
// Check that a given value is a css hex color value, e.g. // Check that a given value is a css hex color value, e.g.
// "#ffffff" or "#fff" // "#ffffff" or "#fff"
colorutils.isCssHex = (cssColor) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(cssColor); colorutils.isCssHex = (cssColor) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(cssColor);
// "#ffffff" or "#fff" or "ffffff" or "fff" to [1.0, 1.0, 1.0] // "#ffffff" or "#fff" or "ffffff" or "fff" to [1.0, 1.0, 1.0]
colorutils.css2triple = (cssColor) => { colorutils.css2triple = (cssColor) => {
const sixHex = colorutils.css2sixhex(cssColor); const sixHex = colorutils.css2sixhex(cssColor);
const hexToFloat = (hh) => Number(`0x${hh}`) / 255;
const hexToFloat = (hh) => Number(`0x${hh}`) / 255; return [
return [ hexToFloat(sixHex.substr(0, 2)),
hexToFloat(sixHex.substr(0, 2)), hexToFloat(sixHex.substr(2, 2)),
hexToFloat(sixHex.substr(2, 2)), hexToFloat(sixHex.substr(4, 2)),
hexToFloat(sixHex.substr(4, 2)), ];
];
}; };
// "#ffffff" or "#fff" or "ffffff" or "fff" to "ffffff" // "#ffffff" or "#fff" or "ffffff" or "fff" to "ffffff"
colorutils.css2sixhex = (cssColor) => { colorutils.css2sixhex = (cssColor) => {
let h = /[0-9a-fA-F]+/.exec(cssColor)[0]; let h = /[0-9a-fA-F]+/.exec(cssColor)[0];
if (h.length !== 6) { if (h.length !== 6) {
const a = h.charAt(0); const a = h.charAt(0);
const b = h.charAt(1); const b = h.charAt(1);
const c = h.charAt(2); const c = h.charAt(2);
h = a + a + b + b + c + c; h = a + a + b + b + c + c;
} }
return h; return h;
}; };
// [1.0, 1.0, 1.0] -> "#ffffff" // [1.0, 1.0, 1.0] -> "#ffffff"
colorutils.triple2css = (triple) => { colorutils.triple2css = (triple) => {
const floatToHex = (n) => { const floatToHex = (n) => {
const n2 = colorutils.clamp(Math.round(n * 255), 0, 255); const n2 = colorutils.clamp(Math.round(n * 255), 0, 255);
return (`0${n2.toString(16)}`).slice(-2); return (`0${n2.toString(16)}`).slice(-2);
}; };
return `#${floatToHex(triple[0])}${floatToHex(triple[1])}${floatToHex(triple[2])}`; return `#${floatToHex(triple[0])}${floatToHex(triple[1])}${floatToHex(triple[2])}`;
}; };
colorutils.clamp = (v, bot, top) => v < bot ? bot : (v > top ? top : v); colorutils.clamp = (v, bot, top) => v < bot ? bot : (v > top ? top : v);
colorutils.min3 = (a, b, c) => (a < b) ? (a < c ? a : c) : (b < c ? b : c); colorutils.min3 = (a, b, c) => (a < b) ? (a < c ? a : c) : (b < c ? b : c);
colorutils.max3 = (a, b, c) => (a > b) ? (a > c ? a : c) : (b > c ? b : c); colorutils.max3 = (a, b, c) => (a > b) ? (a > c ? a : c) : (b > c ? b : c);
@ -71,51 +61,42 @@ colorutils.colorMin = (c) => colorutils.min3(c[0], c[1], c[2]);
colorutils.colorMax = (c) => colorutils.max3(c[0], c[1], c[2]); colorutils.colorMax = (c) => colorutils.max3(c[0], c[1], c[2]);
colorutils.scale = (v, bot, top) => colorutils.clamp(bot + v * (top - bot), 0, 1); colorutils.scale = (v, bot, top) => colorutils.clamp(bot + v * (top - bot), 0, 1);
colorutils.unscale = (v, bot, top) => colorutils.clamp((v - bot) / (top - bot), 0, 1); colorutils.unscale = (v, bot, top) => colorutils.clamp((v - bot) / (top - bot), 0, 1);
colorutils.scaleColor = (c, bot, top) => [ colorutils.scaleColor = (c, bot, top) => [
colorutils.scale(c[0], bot, top), colorutils.scale(c[0], bot, top),
colorutils.scale(c[1], bot, top), colorutils.scale(c[1], bot, top),
colorutils.scale(c[2], bot, top), colorutils.scale(c[2], bot, top),
]; ];
colorutils.unscaleColor = (c, bot, top) => [ colorutils.unscaleColor = (c, bot, top) => [
colorutils.unscale(c[0], bot, top), colorutils.unscale(c[0], bot, top),
colorutils.unscale(c[1], bot, top), colorutils.unscale(c[1], bot, top),
colorutils.unscale(c[2], bot, top), colorutils.unscale(c[2], bot, top),
]; ];
// rule of thumb for RGB brightness; 1.0 is white // rule of thumb for RGB brightness; 1.0 is white
colorutils.luminosity = (c) => c[0] * 0.30 + c[1] * 0.59 + c[2] * 0.11; colorutils.luminosity = (c) => c[0] * 0.30 + c[1] * 0.59 + c[2] * 0.11;
colorutils.saturate = (c) => { colorutils.saturate = (c) => {
const min = colorutils.colorMin(c); const min = colorutils.colorMin(c);
const max = colorutils.colorMax(c); const max = colorutils.colorMax(c);
if (max - min <= 0) return [1.0, 1.0, 1.0]; if (max - min <= 0)
return colorutils.unscaleColor(c, min, max); return [1.0, 1.0, 1.0];
return colorutils.unscaleColor(c, min, max);
}; };
colorutils.blend = (c1, c2, t) => [ colorutils.blend = (c1, c2, t) => [
colorutils.scale(t, c1[0], c2[0]), colorutils.scale(t, c1[0], c2[0]),
colorutils.scale(t, c1[1], c2[1]), colorutils.scale(t, c1[1], c2[1]),
colorutils.scale(t, c1[2], c2[2]), colorutils.scale(t, c1[2], c2[2]),
]; ];
colorutils.invert = (c) => [1 - c[0], 1 - c[1], 1 - c[2]]; colorutils.invert = (c) => [1 - c[0], 1 - c[1], 1 - c[2]];
colorutils.complementary = (c) => { colorutils.complementary = (c) => {
const inv = colorutils.invert(c); const inv = colorutils.invert(c);
return [ return [
(inv[0] >= c[0]) ? Math.min(inv[0] * 1.30, 1) : (c[0] * 0.30), (inv[0] >= c[0]) ? Math.min(inv[0] * 1.30, 1) : (c[0] * 0.30),
(inv[1] >= c[1]) ? Math.min(inv[1] * 1.59, 1) : (c[1] * 0.59), (inv[1] >= c[1]) ? Math.min(inv[1] * 1.59, 1) : (c[1] * 0.59),
(inv[2] >= c[2]) ? Math.min(inv[2] * 1.11, 1) : (c[2] * 0.11), (inv[2] >= c[2]) ? Math.min(inv[2] * 1.11, 1) : (c[2] * 0.11),
]; ];
}; };
colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => { colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => {
const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff'; const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff';
const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222'; const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222';
return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black;
return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black;
}; };
export { colorutils };
exports.colorutils = colorutils;

File diff suppressed because it is too large Load diff

View file

@ -1,72 +1,47 @@
'use strict'; 'use strict';
export const makeCSSManager = (browserSheet) => {
/** const browserRules = () => (browserSheet.cssRules || browserSheet.rules);
* This code is mostly from the old Etherpad. Please help us to comment this code. const browserDeleteRule = (i) => {
* This helps other people to understand this code better and helps them to improve it. if (browserSheet.deleteRule)
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED browserSheet.deleteRule(i);
*/ else
browserSheet.removeRule(i);
/** };
* Copyright 2009 Google Inc. const browserInsertRule = (i, selector) => {
* if (browserSheet.insertRule)
* Licensed under the Apache License, Version 2.0 (the "License"); browserSheet.insertRule(`${selector} {}`, i);
* you may not use this file except in compliance with the License. else
* You may obtain a copy of the License at browserSheet.addRule(selector, null, i);
* };
* http://www.apache.org/licenses/LICENSE-2.0 const selectorList = [];
* const indexOfSelector = (selector) => {
* Unless required by applicable law or agreed to in writing, software for (let i = 0; i < selectorList.length; i++) {
* distributed under the License is distributed on an "AS-IS" BASIS, if (selectorList[i] === selector) {
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. return i;
* See the License for the specific language governing permissions and }
* limitations under the License. }
*/ return -1;
};
exports.makeCSSManager = (browserSheet) => { const selectorStyle = (selector) => {
const browserRules = () => (browserSheet.cssRules || browserSheet.rules); let i = indexOfSelector(selector);
if (i < 0) {
const browserDeleteRule = (i) => { // add selector
if (browserSheet.deleteRule) browserSheet.deleteRule(i); browserInsertRule(0, selector);
else browserSheet.removeRule(i); selectorList.splice(0, 0, selector);
}; i = 0;
}
const browserInsertRule = (i, selector) => { return browserRules().item(i).style;
if (browserSheet.insertRule) browserSheet.insertRule(`${selector} {}`, i); };
else browserSheet.addRule(selector, null, i); const removeSelectorStyle = (selector) => {
}; const i = indexOfSelector(selector);
const selectorList = []; if (i >= 0) {
browserDeleteRule(i);
const indexOfSelector = (selector) => { selectorList.splice(i, 1);
for (let i = 0; i < selectorList.length; i++) { }
if (selectorList[i] === selector) { };
return i; return {
} selectorStyle,
} removeSelectorStyle,
return -1; info: () => `${selectorList.length}:${browserRules().length}`,
}; };
const selectorStyle = (selector) => {
let i = indexOfSelector(selector);
if (i < 0) {
// add selector
browserInsertRule(0, selector);
selectorList.splice(0, 0, selector);
i = 0;
}
return browserRules().item(i).style;
};
const removeSelectorStyle = (selector) => {
const i = indexOfSelector(selector);
if (i >= 0) {
browserDeleteRule(i);
selectorList.splice(i, 1);
}
};
return {
selectorStyle,
removeSelectorStyle,
info: () => `${selectorList.length}:${browserRules().length}`,
};
}; };

View file

@ -1,278 +1,244 @@
import * as Security from "./security.js";
import * as hooks from "./pluginfw/hooks.js";
import * as _ from "./underscore.js";
import { lineAttributeMarker as lineAttributeMarker$0 } from "./linestylefilter.js";
'use strict'; 'use strict';
const lineAttributeMarker = { lineAttributeMarker: lineAttributeMarker$0 }.lineAttributeMarker;
// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.domline const noop = () => { };
// %APPJET%: import("etherpad.admin.plugins");
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// requires: top
// requires: plugins
// requires: undefined
const Security = require('./security');
const hooks = require('./pluginfw/hooks');
const _ = require('./underscore');
const lineAttributeMarker = require('./linestylefilter').lineAttributeMarker;
const noop = () => {};
const domline = {}; const domline = {};
domline.addToLineClass = (lineClass, cls) => { domline.addToLineClass = (lineClass, cls) => {
// an "empty span" at any point can be used to add classes to // an "empty span" at any point can be used to add classes to
// the line, using line:className. otherwise, we ignore // the line, using line:className. otherwise, we ignore
// the span. // the span.
cls.replace(/\S+/g, (c) => { cls.replace(/\S+/g, (c) => {
if (c.indexOf('line:') === 0) { if (c.indexOf('line:') === 0) {
// add class to line // add class to line
lineClass = (lineClass ? `${lineClass} ` : '') + c.substring(5); lineClass = (lineClass ? `${lineClass} ` : '') + c.substring(5);
} }
}); });
return lineClass; return lineClass;
}; };
// if "document" is falsy we don't create a DOM node, just // if "document" is falsy we don't create a DOM node, just
// an object with innerHTML and className // an object with innerHTML and className
domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => { domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
const result = { const result = {
node: null, node: null,
appendSpan: noop, appendSpan: noop,
prepareForAdd: noop, prepareForAdd: noop,
notifyAdded: noop, notifyAdded: noop,
clearSpans: noop, clearSpans: noop,
finishUpdate: noop, finishUpdate: noop,
lineMarker: 0, lineMarker: 0,
};
const document = optDocument;
if (document) {
result.node = document.createElement('div');
// JAWS and NVDA screen reader compatibility. Only needed if in a real browser.
result.node.setAttribute('aria-live', 'assertive');
} else {
result.node = {
innerHTML: '',
className: '',
}; };
} const document = optDocument;
if (document) {
let html = []; result.node = document.createElement('div');
let preHtml = ''; // JAWS and NVDA screen reader compatibility. Only needed if in a real browser.
let postHtml = ''; result.node.setAttribute('aria-live', 'assertive');
let curHTML = null; }
else {
const processSpaces = (s) => domline.processSpaces(s, doesWrap); result.node = {
const perTextNodeProcess = (doesWrap ? _.identity : processSpaces); innerHTML: '',
const perHtmlLineProcess = (doesWrap ? processSpaces : _.identity); className: '',
let lineClass = 'ace-line'; };
}
result.appendSpan = (txt, cls) => { let html = [];
let processedMarker = false; let preHtml = '';
// Handle lineAttributeMarker, if present let postHtml = '';
if (cls.indexOf(lineAttributeMarker) >= 0) { let curHTML = null;
let listType = /(?:^| )list:(\S+)/.exec(cls); const processSpaces = (s) => domline.processSpaces(s, doesWrap);
const start = /(?:^| )start:(\S+)/.exec(cls); const perTextNodeProcess = (doesWrap ? _.identity : processSpaces);
const perHtmlLineProcess = (doesWrap ? processSpaces : _.identity);
_.map(hooks.callAll('aceDomLinePreProcessLineAttributes', { let lineClass = 'ace-line';
domline, result.appendSpan = (txt, cls) => {
cls, let processedMarker = false;
}), (modifier) => { // Handle lineAttributeMarker, if present
preHtml += modifier.preHtml; if (cls.indexOf(lineAttributeMarker) >= 0) {
postHtml += modifier.postHtml; let listType = /(?:^| )list:(\S+)/.exec(cls);
processedMarker |= modifier.processedMarker; const start = /(?:^| )start:(\S+)/.exec(cls);
}); _.map(hooks.callAll('aceDomLinePreProcessLineAttributes', {
if (listType) { domline,
listType = listType[1]; cls,
if (listType) { }), (modifier) => {
if (listType.indexOf('number') < 0) { preHtml += modifier.preHtml;
preHtml += `<ul class="list-${Security.escapeHTMLAttribute(listType)}"><li>`; postHtml += modifier.postHtml;
postHtml = `</li></ul>${postHtml}`; processedMarker |= modifier.processedMarker;
} else { });
if (start) { // is it a start of a list with more than one item in? if (listType) {
if (Number.parseInt(start[1]) === 1) { // if its the first one at this level? listType = listType[1];
// Add start class to DIV node if (listType) {
lineClass = `${lineClass} ` + `list-start-${listType}`; if (listType.indexOf('number') < 0) {
} preHtml += `<ul class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
preHtml += postHtml = `</li></ul>${postHtml}`;
`<ol start=${start[1]} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`; }
} else { else {
// Handles pasted contents into existing lists if (start) { // is it a start of a list with more than one item in?
preHtml += `<ol class="list-${Security.escapeHTMLAttribute(listType)}"><li>`; if (Number.parseInt(start[1]) === 1) { // if its the first one at this level?
// Add start class to DIV node
lineClass = `${lineClass} ` + `list-start-${listType}`;
}
preHtml +=
`<ol start=${start[1]} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
}
else {
// Handles pasted contents into existing lists
preHtml += `<ol class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
}
postHtml += '</li></ol>';
}
}
processedMarker = true;
}
_.map(hooks.callAll('aceDomLineProcessLineAttributes', {
domline,
cls,
}), (modifier) => {
preHtml += modifier.preHtml;
postHtml += modifier.postHtml;
processedMarker |= modifier.processedMarker;
});
if (processedMarker) {
result.lineMarker += txt.length;
return; // don't append any text
} }
postHtml += '</li></ol>';
}
} }
processedMarker = true; let href = null;
} let simpleTags = null;
_.map(hooks.callAll('aceDomLineProcessLineAttributes', { if (cls.indexOf('url') >= 0) {
domline, cls = cls.replace(/(^| )url:(\S+)/g, (x0, space, url) => {
cls, href = url;
}), (modifier) => { return `${space}url`;
preHtml += modifier.preHtml; });
postHtml += modifier.postHtml;
processedMarker |= modifier.processedMarker;
});
if (processedMarker) {
result.lineMarker += txt.length;
return; // don't append any text
}
}
let href = null;
let simpleTags = null;
if (cls.indexOf('url') >= 0) {
cls = cls.replace(/(^| )url:(\S+)/g, (x0, space, url) => {
href = url;
return `${space}url`;
});
}
if (cls.indexOf('tag') >= 0) {
cls = cls.replace(/(^| )tag:(\S+)/g, (x0, space, tag) => {
if (!simpleTags) simpleTags = [];
simpleTags.push(tag.toLowerCase());
return space + tag;
});
}
let extraOpenTags = '';
let extraCloseTags = '';
_.map(hooks.callAll('aceCreateDomLine', {
domline,
cls,
}), (modifier) => {
cls = modifier.cls;
extraOpenTags += modifier.extraOpenTags;
extraCloseTags = modifier.extraCloseTags + extraCloseTags;
});
if ((!txt) && cls) {
lineClass = domline.addToLineClass(lineClass, cls);
} else if (txt) {
if (href) {
const urn_schemes = new RegExp('^(about|geo|mailto|tel):');
// if the url doesn't include a protocol prefix, assume http
if (!~href.indexOf('://') && !urn_schemes.test(href)) {
href = `http://${href}`;
} }
// Using rel="noreferrer" stops leaking the URL/location of the pad when if (cls.indexOf('tag') >= 0) {
// clicking links in the document. cls = cls.replace(/(^| )tag:(\S+)/g, (x0, space, tag) => {
// Not all browsers understand this attribute, but it's part of the HTML5 standard. if (!simpleTags)
// https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer simpleTags = [];
// Additionally, we do rel="noopener" to ensure a higher level of referrer security. simpleTags.push(tag.toLowerCase());
// https://html.spec.whatwg.org/multipage/links.html#link-type-noopener return space + tag;
// https://mathiasbynens.github.io/rel-noopener/ });
// https://github.com/ether/etherpad-lite/pull/3636 }
const escapedHref = Security.escapeHTMLAttribute(href); let extraOpenTags = '';
extraOpenTags = `${extraOpenTags}<a href="${escapedHref}" rel="noreferrer noopener">`; let extraCloseTags = '';
extraCloseTags = `</a>${extraCloseTags}`; _.map(hooks.callAll('aceCreateDomLine', {
} domline,
if (simpleTags) { cls,
simpleTags.sort(); }), (modifier) => {
extraOpenTags = `${extraOpenTags}<${simpleTags.join('><')}>`; cls = modifier.cls;
simpleTags.reverse(); extraOpenTags += modifier.extraOpenTags;
extraCloseTags = `</${simpleTags.join('></')}>${extraCloseTags}`; extraCloseTags = modifier.extraCloseTags + extraCloseTags;
} });
html.push( if ((!txt) && cls) {
'<span class="', Security.escapeHTMLAttribute(cls || ''), lineClass = domline.addToLineClass(lineClass, cls);
'">', }
extraOpenTags, else if (txt) {
perTextNodeProcess(Security.escapeHTML(txt)), if (href) {
extraCloseTags, const urn_schemes = new RegExp('^(about|geo|mailto|tel):');
'</span>'); // if the url doesn't include a protocol prefix, assume http
} if (!~href.indexOf('://') && !urn_schemes.test(href)) {
}; href = `http://${href}`;
result.clearSpans = () => { }
html = []; // Using rel="noreferrer" stops leaking the URL/location of the pad when
lineClass = 'ace-line'; // clicking links in the document.
result.lineMarker = 0; // Not all browsers understand this attribute, but it's part of the HTML5 standard.
}; // https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer
// Additionally, we do rel="noopener" to ensure a higher level of referrer security.
const writeHTML = () => { // https://html.spec.whatwg.org/multipage/links.html#link-type-noopener
let newHTML = perHtmlLineProcess(html.join('')); // https://mathiasbynens.github.io/rel-noopener/
if (!newHTML) { // https://github.com/ether/etherpad-lite/pull/3636
if ((!document) || (!optBrowser)) { const escapedHref = Security.escapeHTMLAttribute(href);
newHTML += '&nbsp;'; extraOpenTags = `${extraOpenTags}<a href="${escapedHref}" rel="noreferrer noopener">`;
} else { extraCloseTags = `</a>${extraCloseTags}`;
newHTML += '<br/>'; }
} if (simpleTags) {
} simpleTags.sort();
if (nonEmpty) { extraOpenTags = `${extraOpenTags}<${simpleTags.join('><')}>`;
newHTML = (preHtml || '') + newHTML + (postHtml || ''); simpleTags.reverse();
} extraCloseTags = `</${simpleTags.join('></')}>${extraCloseTags}`;
html = preHtml = postHtml = ''; // free memory }
if (newHTML !== curHTML) { html.push('<span class="', Security.escapeHTMLAttribute(cls || ''), '">', extraOpenTags, perTextNodeProcess(Security.escapeHTML(txt)), extraCloseTags, '</span>');
curHTML = newHTML; }
result.node.innerHTML = curHTML; };
} result.clearSpans = () => {
if (lineClass != null) result.node.className = lineClass; html = [];
lineClass = 'ace-line';
hooks.callAll('acePostWriteDomLineHTML', { result.lineMarker = 0;
node: result.node, };
}); const writeHTML = () => {
}; let newHTML = perHtmlLineProcess(html.join(''));
result.prepareForAdd = writeHTML; if (!newHTML) {
result.finishUpdate = writeHTML; if ((!document) || (!optBrowser)) {
return result; newHTML += '&nbsp;';
}
else {
newHTML += '<br/>';
}
}
if (nonEmpty) {
newHTML = (preHtml || '') + newHTML + (postHtml || '');
}
html = preHtml = postHtml = ''; // free memory
if (newHTML !== curHTML) {
curHTML = newHTML;
result.node.innerHTML = curHTML;
}
if (lineClass != null)
result.node.className = lineClass;
hooks.callAll('acePostWriteDomLineHTML', {
node: result.node,
});
};
result.prepareForAdd = writeHTML;
result.finishUpdate = writeHTML;
return result;
}; };
domline.processSpaces = (s, doesWrap) => { domline.processSpaces = (s, doesWrap) => {
if (s.indexOf('<') < 0 && !doesWrap) { if (s.indexOf('<') < 0 && !doesWrap) {
// short-cut // short-cut
return s.replace(/ /g, '&nbsp;'); return s.replace(/ /g, '&nbsp;');
}
const parts = [];
s.replace(/<[^>]*>?| |[^ <]+/g, (m) => {
parts.push(m);
});
if (doesWrap) {
let endOfLine = true;
let beforeSpace = false;
// last space in a run is normal, others are nbsp,
// end of line is nbsp
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (p === ' ') {
if (endOfLine || beforeSpace) parts[i] = '&nbsp;';
endOfLine = false;
beforeSpace = true;
} else if (p.charAt(0) !== '<') {
endOfLine = false;
beforeSpace = false;
}
} }
// beginning of line is nbsp const parts = [];
for (let i = 0; i < parts.length; i++) { s.replace(/<[^>]*>?| |[^ <]+/g, (m) => {
const p = parts[i]; parts.push(m);
if (p === ' ') { });
parts[i] = '&nbsp;'; if (doesWrap) {
break; let endOfLine = true;
} else if (p.charAt(0) !== '<') { let beforeSpace = false;
break; // last space in a run is normal, others are nbsp,
} // end of line is nbsp
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (p === ' ') {
if (endOfLine || beforeSpace)
parts[i] = '&nbsp;';
endOfLine = false;
beforeSpace = true;
}
else if (p.charAt(0) !== '<') {
endOfLine = false;
beforeSpace = false;
}
}
// beginning of line is nbsp
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === ' ') {
parts[i] = '&nbsp;';
break;
}
else if (p.charAt(0) !== '<') {
break;
}
}
} }
} else { else {
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const p = parts[i]; const p = parts[i];
if (p === ' ') { if (p === ' ') {
parts[i] = '&nbsp;'; parts[i] = '&nbsp;';
} }
}
} }
} return parts.join('');
return parts.join('');
}; };
export { domline };
exports.domline = domline;

View file

@ -1,5 +1,4 @@
'use strict'; 'use strict';
/* eslint-disable-next-line max-len */ /* eslint-disable-next-line max-len */
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0 // @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0
/** /**
@ -18,45 +17,41 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const randomPadName = () => { const randomPadName = () => {
// the number of distinct chars (64) is chosen to ensure that the selection will be uniform when // the number of distinct chars (64) is chosen to ensure that the selection will be uniform when
// using the PRNG below // using the PRNG below
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_'; const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';
// the length of the pad name is chosen to get 120-bit security: log2(64^20) = 120 // the length of the pad name is chosen to get 120-bit security: log2(64^20) = 120
const stringLength = 20; const stringLength = 20;
// make room for 8-bit integer values that span from 0 to 255. // make room for 8-bit integer values that span from 0 to 255.
const randomarray = new Uint8Array(stringLength); const randomarray = new Uint8Array(stringLength);
// use browser's PRNG to generate a "unique" sequence // use browser's PRNG to generate a "unique" sequence
const cryptoObj = window.crypto || window.msCrypto; // for IE 11 const cryptoObj = window.crypto || window.msCrypto; // for IE 11
cryptoObj.getRandomValues(randomarray); cryptoObj.getRandomValues(randomarray);
let randomstring = ''; let randomstring = '';
for (let i = 0; i < stringLength; i++) { for (let i = 0; i < stringLength; i++) {
// instead of writing "Math.floor(randomarray[i]/256*64)" // instead of writing "Math.floor(randomarray[i]/256*64)"
// we can save some cycles. // we can save some cycles.
const rnum = Math.floor(randomarray[i] / 4); const rnum = Math.floor(randomarray[i] / 4);
randomstring += chars.substring(rnum, rnum + 1); randomstring += chars.substring(rnum, rnum + 1);
}
return randomstring;
};
$(() => {
$('#go2Name').submit(() => {
const padname = $('#padname').val();
if (padname.length > 0) {
window.location = `p/${encodeURIComponent(padname.trim())}`;
} else {
alert('Please enter a name');
} }
return false; return randomstring;
}); };
$(() => {
$('#button').click(() => { $('#go2Name').submit(() => {
window.location = `p/${randomPadName()}`; const padname = $('#padname').val();
}); if (padname.length > 0) {
window.location = `p/${encodeURIComponent(padname.trim())}`;
// start the custom js }
if (typeof window.customStart === 'function') window.customStart(); else {
alert('Please enter a name');
}
return false;
});
$('#button').click(() => {
window.location = `p/${randomPadName()}`;
});
// start the custom js
if (typeof window.customStart === 'function')
window.customStart();
}); });
// @license-end

View file

@ -1,16 +1,14 @@
'use strict'; 'use strict';
((document) => { ((document) => {
// Set language for l10n // Set language for l10n
let language = document.cookie.match(/language=((\w{2,3})(-\w+)?)/); let language = document.cookie.match(/language=((\w{2,3})(-\w+)?)/);
if (language) language = language[1]; if (language)
language = language[1];
html10n.bind('indexed', () => { html10n.bind('indexed', () => {
html10n.localize([language, navigator.language, navigator.userLanguage, 'en']); html10n.localize([language, navigator.language, navigator.userLanguage, 'en']);
}); });
html10n.bind('localized', () => {
html10n.bind('localized', () => { document.documentElement.lang = html10n.getLanguage();
document.documentElement.lang = html10n.getLanguage(); document.documentElement.dir = html10n.getDirection();
document.documentElement.dir = html10n.getDirection(); });
});
})(document); })(document);

View file

@ -1,291 +1,240 @@
import * as Changeset from "./Changeset.js";
import * as attributes from "./attributes.js";
import * as hooks from "./pluginfw/hooks.js";
import AttributeManager from "./AttributeManager.js";
import { padutils as padutils$0 } from "./pad_utils.js";
'use strict'; 'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.linestylefilter
// %APPJET%: import("etherpad.collab.ace.easysync2.Changeset");
// %APPJET%: import("etherpad.admin.plugins");
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// requires: easysync2.Changeset
// requires: top
// requires: plugins
// requires: undefined
const Changeset = require('./Changeset');
const attributes = require('./attributes');
const hooks = require('./pluginfw/hooks');
const linestylefilter = {}; const linestylefilter = {};
const AttributeManager = require('./AttributeManager'); const padutils = { padutils: padutils$0 }.padutils;
const padutils = require('./pad_utils').padutils;
linestylefilter.ATTRIB_CLASSES = { linestylefilter.ATTRIB_CLASSES = {
bold: 'tag:b', bold: 'tag:b',
italic: 'tag:i', italic: 'tag:i',
underline: 'tag:u', underline: 'tag:u',
strikethrough: 'tag:s', strikethrough: 'tag:s',
}; };
const lineAttributeMarker = 'lineAttribMarker'; const lineAttributeMarker = 'lineAttribMarker';
exports.lineAttributeMarker = lineAttributeMarker;
linestylefilter.getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => { linestylefilter.getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => {
if (c === '.') return '-'; if (c === '.')
return `z${c.charCodeAt(0)}z`; return '-';
return `z${c.charCodeAt(0)}z`;
})}`; })}`;
// lineLength is without newline; aline includes newline, // lineLength is without newline; aline includes newline,
// but may be falsy if lineLength == 0 // but may be falsy if lineLength == 0
linestylefilter.getLineStyleFilter = (lineLength, aline, textAndClassFunc, apool) => { linestylefilter.getLineStyleFilter = (lineLength, aline, textAndClassFunc, apool) => {
// Plugin Hook to add more Attrib Classes // Plugin Hook to add more Attrib Classes
for (const attribClasses of hooks.callAll('aceAttribClasses', linestylefilter.ATTRIB_CLASSES)) { for (const attribClasses of hooks.callAll('aceAttribClasses', linestylefilter.ATTRIB_CLASSES)) {
Object.assign(linestylefilter.ATTRIB_CLASSES, attribClasses); Object.assign(linestylefilter.ATTRIB_CLASSES, attribClasses);
} }
if (lineLength === 0)
if (lineLength === 0) return textAndClassFunc; return textAndClassFunc;
const nextAfterAuthorColors = textAndClassFunc;
const nextAfterAuthorColors = textAndClassFunc; const authorColorFunc = (() => {
const lineEnd = lineLength;
const authorColorFunc = (() => { let curIndex = 0;
const lineEnd = lineLength; let extraClasses;
let curIndex = 0; let leftInAuthor;
let extraClasses; const attribsToClasses = (attribs) => {
let leftInAuthor; let classes = '';
let isLineAttribMarker = false;
const attribsToClasses = (attribs) => { for (const [key, value] of attributes.attribsFromString(attribs, apool)) {
let classes = ''; if (!key || !value)
let isLineAttribMarker = false; continue;
if (!isLineAttribMarker && AttributeManager.lineAttributes.indexOf(key) >= 0) {
for (const [key, value] of attributes.attribsFromString(attribs, apool)) { isLineAttribMarker = true;
if (!key || !value) continue; }
if (!isLineAttribMarker && AttributeManager.lineAttributes.indexOf(key) >= 0) { if (key === 'author') {
isLineAttribMarker = true; classes += ` ${linestylefilter.getAuthorClassName(value)}`;
} }
if (key === 'author') { else if (key === 'list') {
classes += ` ${linestylefilter.getAuthorClassName(value)}`; classes += ` list:${value}`;
} else if (key === 'list') { }
classes += ` list:${value}`; else if (key === 'start') {
} else if (key === 'start') { // Needed to introduce the correct Ordered list item start number on import
// Needed to introduce the correct Ordered list item start number on import classes += ` start:${value}`;
classes += ` start:${value}`; }
} else if (linestylefilter.ATTRIB_CLASSES[key]) { else if (linestylefilter.ATTRIB_CLASSES[key]) {
classes += ` ${linestylefilter.ATTRIB_CLASSES[key]}`; classes += ` ${linestylefilter.ATTRIB_CLASSES[key]}`;
} else { }
const results = hooks.callAll('aceAttribsToClasses', {linestylefilter, key, value}); else {
classes += ` ${results.join(' ')}`; const results = hooks.callAll('aceAttribsToClasses', { linestylefilter, key, value });
} classes += ` ${results.join(' ')}`;
} }
}
if (isLineAttribMarker) classes += ` ${lineAttributeMarker}`; if (isLineAttribMarker)
return classes.substring(1); classes += ` ${lineAttributeMarker}`;
}; return classes.substring(1);
};
const attrOps = Changeset.deserializeOps(aline); const attrOps = Changeset.deserializeOps(aline);
let attrOpsNext = attrOps.next(); let attrOpsNext = attrOps.next();
let nextOp, nextOpClasses; let nextOp, nextOpClasses;
const goNextOp = () => {
const goNextOp = () => { nextOp = attrOpsNext.done ? new Changeset.Op() : attrOpsNext.value;
nextOp = attrOpsNext.done ? new Changeset.Op() : attrOpsNext.value; if (!attrOpsNext.done)
if (!attrOpsNext.done) attrOpsNext = attrOps.next(); attrOpsNext = attrOps.next();
nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs)); nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs));
}; };
goNextOp();
const nextClasses = () => {
if (curIndex < lineEnd) {
extraClasses = nextOpClasses;
leftInAuthor = nextOp.chars;
goNextOp(); goNextOp();
while (nextOp.opcode && nextOpClasses === extraClasses) { const nextClasses = () => {
leftInAuthor += nextOp.chars; if (curIndex < lineEnd) {
goNextOp(); extraClasses = nextOpClasses;
} leftInAuthor = nextOp.chars;
} goNextOp();
}; while (nextOp.opcode && nextOpClasses === extraClasses) {
nextClasses(); leftInAuthor += nextOp.chars;
goNextOp();
return (txt, cls) => { }
const disableAuthColorForThisLine = hooks.callAll('disableAuthorColorsForThisLine', { }
linestylefilter, };
text: txt, nextClasses();
class: cls, return (txt, cls) => {
}); const disableAuthColorForThisLine = hooks.callAll('disableAuthorColorsForThisLine', {
const disableAuthors = (disableAuthColorForThisLine == null || linestylefilter,
disableAuthColorForThisLine.length === 0) ? false : disableAuthColorForThisLine[0]; text: txt,
while (txt.length > 0) { class: cls,
if (leftInAuthor <= 0 || disableAuthors) { });
// prevent infinite loop if something funny's going on const disableAuthors = (disableAuthColorForThisLine == null ||
return nextAfterAuthorColors(txt, cls); disableAuthColorForThisLine.length === 0) ? false : disableAuthColorForThisLine[0];
} while (txt.length > 0) {
let spanSize = txt.length; if (leftInAuthor <= 0 || disableAuthors) {
if (spanSize > leftInAuthor) { // prevent infinite loop if something funny's going on
spanSize = leftInAuthor; return nextAfterAuthorColors(txt, cls);
} }
const curTxt = txt.substring(0, spanSize); let spanSize = txt.length;
txt = txt.substring(spanSize); if (spanSize > leftInAuthor) {
nextAfterAuthorColors(curTxt, (cls && `${cls} `) + extraClasses); spanSize = leftInAuthor;
curIndex += spanSize; }
leftInAuthor -= spanSize; const curTxt = txt.substring(0, spanSize);
if (leftInAuthor === 0) { txt = txt.substring(spanSize);
nextClasses(); nextAfterAuthorColors(curTxt, (cls && `${cls} `) + extraClasses);
} curIndex += spanSize;
} leftInAuthor -= spanSize;
}; if (leftInAuthor === 0) {
})(); nextClasses();
return authorColorFunc; }
}
};
})();
return authorColorFunc;
}; };
linestylefilter.getAtSignSplitterFilter = (lineText, textAndClassFunc) => { linestylefilter.getAtSignSplitterFilter = (lineText, textAndClassFunc) => {
const at = /@/g; const at = /@/g;
at.lastIndex = 0; at.lastIndex = 0;
let splitPoints = null; let splitPoints = null;
let execResult; let execResult;
while ((execResult = at.exec(lineText))) { while ((execResult = at.exec(lineText))) {
if (!splitPoints) { if (!splitPoints) {
splitPoints = []; splitPoints = [];
}
splitPoints.push(execResult.index);
} }
splitPoints.push(execResult.index); if (!splitPoints)
} return textAndClassFunc;
return linestylefilter.textAndClassFuncSplitter(textAndClassFunc, splitPoints);
if (!splitPoints) return textAndClassFunc;
return linestylefilter.textAndClassFuncSplitter(textAndClassFunc, splitPoints);
}; };
linestylefilter.getRegexpFilter = (regExp, tag) => (lineText, textAndClassFunc) => { linestylefilter.getRegexpFilter = (regExp, tag) => (lineText, textAndClassFunc) => {
regExp.lastIndex = 0; regExp.lastIndex = 0;
let regExpMatchs = null; let regExpMatchs = null;
let splitPoints = null; let splitPoints = null;
let execResult; let execResult;
while ((execResult = regExp.exec(lineText))) { while ((execResult = regExp.exec(lineText))) {
if (!regExpMatchs) { if (!regExpMatchs) {
regExpMatchs = []; regExpMatchs = [];
splitPoints = []; splitPoints = [];
}
const startIndex = execResult.index;
const regExpMatch = execResult[0];
regExpMatchs.push([startIndex, regExpMatch]);
splitPoints.push(startIndex, startIndex + regExpMatch.length);
} }
const startIndex = execResult.index; if (!regExpMatchs)
const regExpMatch = execResult[0]; return textAndClassFunc;
regExpMatchs.push([startIndex, regExpMatch]); const regExpMatchForIndex = (idx) => {
splitPoints.push(startIndex, startIndex + regExpMatch.length); for (let k = 0; k < regExpMatchs.length; k++) {
} const u = regExpMatchs[k];
if (idx >= u[0] && idx < u[0] + u[1].length) {
if (!regExpMatchs) return textAndClassFunc; return u[1];
}
const regExpMatchForIndex = (idx) => { }
for (let k = 0; k < regExpMatchs.length; k++) { return false;
const u = regExpMatchs[k];
if (idx >= u[0] && idx < u[0] + u[1].length) {
return u[1];
}
}
return false;
};
const handleRegExpMatchsAfterSplit = (() => {
let curIndex = 0;
return (txt, cls) => {
const txtlen = txt.length;
let newCls = cls;
const regExpMatch = regExpMatchForIndex(curIndex);
if (regExpMatch) {
newCls += ` ${tag}:${regExpMatch}`;
}
textAndClassFunc(txt, newCls);
curIndex += txtlen;
}; };
})(); const handleRegExpMatchsAfterSplit = (() => {
let curIndex = 0;
return linestylefilter.textAndClassFuncSplitter(handleRegExpMatchsAfterSplit, splitPoints); return (txt, cls) => {
const txtlen = txt.length;
let newCls = cls;
const regExpMatch = regExpMatchForIndex(curIndex);
if (regExpMatch) {
newCls += ` ${tag}:${regExpMatch}`;
}
textAndClassFunc(txt, newCls);
curIndex += txtlen;
};
})();
return linestylefilter.textAndClassFuncSplitter(handleRegExpMatchsAfterSplit, splitPoints);
}; };
linestylefilter.getURLFilter = linestylefilter.getRegexpFilter(padutils.urlRegex, 'url'); linestylefilter.getURLFilter = linestylefilter.getRegexpFilter(padutils.urlRegex, 'url');
linestylefilter.textAndClassFuncSplitter = (func, splitPointsOpt) => { linestylefilter.textAndClassFuncSplitter = (func, splitPointsOpt) => {
let nextPointIndex = 0; let nextPointIndex = 0;
let idx = 0; let idx = 0;
// don't split at 0
// don't split at 0 while (splitPointsOpt &&
while (splitPointsOpt && nextPointIndex < splitPointsOpt.length &&
nextPointIndex < splitPointsOpt.length && splitPointsOpt[nextPointIndex] === 0) {
splitPointsOpt[nextPointIndex] === 0) {
nextPointIndex++;
}
const spanHandler = (txt, cls) => {
if ((!splitPointsOpt) || nextPointIndex >= splitPointsOpt.length) {
func(txt, cls);
idx += txt.length;
} else {
const splitPoints = splitPointsOpt;
const pointLocInSpan = splitPoints[nextPointIndex] - idx;
const txtlen = txt.length;
if (pointLocInSpan >= txtlen) {
func(txt, cls);
idx += txt.length;
if (pointLocInSpan === txtlen) {
nextPointIndex++;
}
} else {
if (pointLocInSpan > 0) {
func(txt.substring(0, pointLocInSpan), cls);
idx += pointLocInSpan;
}
nextPointIndex++; nextPointIndex++;
// recurse
spanHandler(txt.substring(pointLocInSpan), cls);
}
} }
}; const spanHandler = (txt, cls) => {
return spanHandler; if ((!splitPointsOpt) || nextPointIndex >= splitPointsOpt.length) {
func(txt, cls);
idx += txt.length;
}
else {
const splitPoints = splitPointsOpt;
const pointLocInSpan = splitPoints[nextPointIndex] - idx;
const txtlen = txt.length;
if (pointLocInSpan >= txtlen) {
func(txt, cls);
idx += txt.length;
if (pointLocInSpan === txtlen) {
nextPointIndex++;
}
}
else {
if (pointLocInSpan > 0) {
func(txt.substring(0, pointLocInSpan), cls);
idx += pointLocInSpan;
}
nextPointIndex++;
// recurse
spanHandler(txt.substring(pointLocInSpan), cls);
}
}
};
return spanHandler;
}; };
linestylefilter.getFilterStack = (lineText, textAndClassFunc, abrowser) => { linestylefilter.getFilterStack = (lineText, textAndClassFunc, abrowser) => {
let func = linestylefilter.getURLFilter(lineText, textAndClassFunc); let func = linestylefilter.getURLFilter(lineText, textAndClassFunc);
const hookFilters = hooks.callAll('aceGetFilterStack', {
const hookFilters = hooks.callAll('aceGetFilterStack', { linestylefilter,
linestylefilter, browser: abrowser,
browser: abrowser, });
}); hookFilters.map((hookFilter) => {
hookFilters.map((hookFilter) => { func = hookFilter(lineText, func);
func = hookFilter(lineText, func); });
}); return func;
return func;
}; };
// domLineObj is like that returned by domline.createDomLine // domLineObj is like that returned by domline.createDomLine
linestylefilter.populateDomLine = (textLine, aline, apool, domLineObj) => { linestylefilter.populateDomLine = (textLine, aline, apool, domLineObj) => {
// remove final newline from text if any // remove final newline from text if any
let text = textLine; let text = textLine;
if (text.slice(-1) === '\n') { if (text.slice(-1) === '\n') {
text = text.substring(0, text.length - 1); text = text.substring(0, text.length - 1);
} }
const textAndClassFunc = (tokenText, tokenClass) => {
const textAndClassFunc = (tokenText, tokenClass) => { domLineObj.appendSpan(tokenText, tokenClass);
domLineObj.appendSpan(tokenText, tokenClass); };
}; let func = linestylefilter.getFilterStack(text, textAndClassFunc);
func = linestylefilter.getLineStyleFilter(text.length, aline, func, apool);
let func = linestylefilter.getFilterStack(text, textAndClassFunc); func(text, '');
func = linestylefilter.getLineStyleFilter(text.length, aline, func, apool);
func(text, '');
}; };
export { lineAttributeMarker };
exports.linestylefilter = linestylefilter; export { linestylefilter };

File diff suppressed because it is too large Load diff

View file

@ -1,194 +1,161 @@
'use strict'; 'use strict';
exports.showCountDownTimerToReconnectOnModal = ($modal, pad) => {
if (clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) {
createCountDownElementsIfNecessary($modal);
const timer = createTimerForModal($modal, pad);
$modal.find('#cancelreconnect').one('click', () => {
timer.cancel();
disableAutomaticReconnection($modal);
});
enableAutomaticReconnection($modal);
}
};
const createCountDownElementsIfNecessary = ($modal) => { const createCountDownElementsIfNecessary = ($modal) => {
const elementsDoNotExist = $modal.find('#cancelreconnect').length === 0; const elementsDoNotExist = $modal.find('#cancelreconnect').length === 0;
if (elementsDoNotExist) { if (elementsDoNotExist) {
const $defaultMessage = $modal.find('#defaulttext'); const $defaultMessage = $modal.find('#defaulttext');
const $reconnectButton = $modal.find('#forcereconnect'); const $reconnectButton = $modal.find('#forcereconnect');
// create extra DOM elements, if they don't exist
// create extra DOM elements, if they don't exist const $reconnectTimerMessage = $('<p>')
const $reconnectTimerMessage =
$('<p>')
.addClass('reconnecttimer') .addClass('reconnecttimer')
.append( .append($('<span>')
$('<span>') .attr('data-l10n-id', 'pad.modals.reconnecttimer')
.attr('data-l10n-id', 'pad.modals.reconnecttimer') .text('Trying to reconnect in'))
.text('Trying to reconnect in'))
.append(' ') .append(' ')
.append( .append($('<span>')
$('<span>') .addClass('timetoexpire'));
.addClass('timetoexpire')); const $cancelReconnect = $('<button>')
const $cancelReconnect =
$('<button>')
.attr('id', 'cancelreconnect') .attr('id', 'cancelreconnect')
.attr('data-l10n-id', 'pad.modals.cancel') .attr('data-l10n-id', 'pad.modals.cancel')
.text('Cancel'); .text('Cancel');
localize($reconnectTimerMessage);
localize($reconnectTimerMessage); localize($cancelReconnect);
localize($cancelReconnect); $reconnectTimerMessage.insertAfter($defaultMessage);
$cancelReconnect.insertAfter($reconnectButton);
$reconnectTimerMessage.insertAfter($defaultMessage);
$cancelReconnect.insertAfter($reconnectButton);
}
};
const localize = ($element) => {
html10n.translateElement(html10n.translations, $element.get(0));
};
const createTimerForModal = ($modal, pad) => {
const timeUntilReconnection =
clientVars.automaticReconnectionTimeout * reconnectionTries.nextTry();
const timer = new CountDownTimer(timeUntilReconnection);
timer.onTick((minutes, seconds) => {
updateCountDownTimerMessage($modal, minutes, seconds);
}).onExpire(() => {
const wasANetworkError = $modal.is('.disconnected');
if (wasANetworkError) {
// cannot simply reconnect, client is having issues to establish connection to server
waitUntilClientCanConnectToServerAndThen(() => { forceReconnection($modal); }, pad);
} else {
forceReconnection($modal);
} }
}).start();
return timer;
}; };
const localize = ($element) => {
html10n.translateElement(html10n.translations, $element.get(0));
};
const createTimerForModal = ($modal, pad) => {
const timeUntilReconnection = clientVars.automaticReconnectionTimeout * reconnectionTries.nextTry();
const timer = new CountDownTimer(timeUntilReconnection);
timer.onTick((minutes, seconds) => {
updateCountDownTimerMessage($modal, minutes, seconds);
}).onExpire(() => {
const wasANetworkError = $modal.is('.disconnected');
if (wasANetworkError) {
// cannot simply reconnect, client is having issues to establish connection to server
waitUntilClientCanConnectToServerAndThen(() => { forceReconnection($modal); }, pad);
}
else {
forceReconnection($modal);
}
}).start();
return timer;
};
const disableAutomaticReconnection = ($modal) => { const disableAutomaticReconnection = ($modal) => {
toggleAutomaticReconnectionOption($modal, true); toggleAutomaticReconnectionOption($modal, true);
}; };
const enableAutomaticReconnection = ($modal) => { const enableAutomaticReconnection = ($modal) => {
toggleAutomaticReconnectionOption($modal, false); toggleAutomaticReconnectionOption($modal, false);
}; };
const toggleAutomaticReconnectionOption = ($modal, disableAutomaticReconnect) => { const toggleAutomaticReconnectionOption = ($modal, disableAutomaticReconnect) => {
$modal.find('#cancelreconnect, .reconnecttimer').toggleClass('hidden', disableAutomaticReconnect); $modal.find('#cancelreconnect, .reconnecttimer').toggleClass('hidden', disableAutomaticReconnect);
$modal.find('#defaulttext').toggleClass('hidden', !disableAutomaticReconnect); $modal.find('#defaulttext').toggleClass('hidden', !disableAutomaticReconnect);
}; };
const waitUntilClientCanConnectToServerAndThen = (callback, pad) => { const waitUntilClientCanConnectToServerAndThen = (callback, pad) => {
whenConnectionIsRestablishedWithServer(callback, pad); whenConnectionIsRestablishedWithServer(callback, pad);
pad.socket.connect(); pad.socket.connect();
}; };
const whenConnectionIsRestablishedWithServer = (callback, pad) => { const whenConnectionIsRestablishedWithServer = (callback, pad) => {
// only add listener for the first try, don't need to add another listener // only add listener for the first try, don't need to add another listener
// on every unsuccessful try // on every unsuccessful try
if (reconnectionTries.counter === 1) { if (reconnectionTries.counter === 1) {
pad.socket.once('connect', callback); pad.socket.once('connect', callback);
} }
}; };
const forceReconnection = ($modal) => { const forceReconnection = ($modal) => {
$modal.find('#forcereconnect').click(); $modal.find('#forcereconnect').click();
}; };
const updateCountDownTimerMessage = ($modal, minutes, seconds) => { const updateCountDownTimerMessage = ($modal, minutes, seconds) => {
minutes = minutes < 10 ? `0${minutes}` : minutes; minutes = minutes < 10 ? `0${minutes}` : minutes;
seconds = seconds < 10 ? `0${seconds}` : seconds; seconds = seconds < 10 ? `0${seconds}` : seconds;
$modal.find('.timetoexpire').text(`${minutes}:${seconds}`);
$modal.find('.timetoexpire').text(`${minutes}:${seconds}`);
}; };
// store number of tries to reconnect to server, in order to increase time to wait // store number of tries to reconnect to server, in order to increase time to wait
// until next try // until next try
const reconnectionTries = { const reconnectionTries = {
counter: 0, counter: 0,
nextTry() {
nextTry() { // double the time to try to reconnect on every time reconnection fails
// double the time to try to reconnect on every time reconnection fails const nextCounterFactor = 2 ** this.counter;
const nextCounterFactor = 2 ** this.counter; this.counter++;
this.counter++; return nextCounterFactor;
},
return nextCounterFactor;
},
}; };
// Timer based on http://stackoverflow.com/a/20618517. // Timer based on http://stackoverflow.com/a/20618517.
// duration: how many **seconds** until the timer ends // duration: how many **seconds** until the timer ends
// granularity (optional): how many **milliseconds** // granularity (optional): how many **milliseconds**
// between each 'tick' of timer. Default: 1000ms (1s) // between each 'tick' of timer. Default: 1000ms (1s)
const CountDownTimer = function (duration, granularity) { const CountDownTimer = function (duration, granularity) {
this.duration = duration; this.duration = duration;
this.granularity = granularity || 1000; this.granularity = granularity || 1000;
this.running = false; this.running = false;
this.onTickCallbacks = [];
this.onTickCallbacks = []; this.onExpireCallbacks = [];
this.onExpireCallbacks = [];
}; };
CountDownTimer.prototype.start = function () { CountDownTimer.prototype.start = function () {
if (this.running) { if (this.running) {
return; return;
}
this.running = true;
const start = Date.now();
const that = this;
let diff;
const timer = () => {
diff = that.duration - Math.floor((Date.now() - start) / 1000);
if (diff > 0) {
that.timeoutId = setTimeout(timer, that.granularity);
that.tick(diff);
} else {
that.running = false;
that.tick(0);
that.expire();
} }
}; this.running = true;
timer(); const start = Date.now();
const that = this;
let diff;
const timer = () => {
diff = that.duration - Math.floor((Date.now() - start) / 1000);
if (diff > 0) {
that.timeoutId = setTimeout(timer, that.granularity);
that.tick(diff);
}
else {
that.running = false;
that.tick(0);
that.expire();
}
};
timer();
}; };
CountDownTimer.prototype.tick = function (diff) { CountDownTimer.prototype.tick = function (diff) {
const obj = CountDownTimer.parse(diff); const obj = CountDownTimer.parse(diff);
this.onTickCallbacks.forEach(function (callback) { this.onTickCallbacks.forEach(function (callback) {
callback.call(this, obj.minutes, obj.seconds); callback.call(this, obj.minutes, obj.seconds);
}, this); }, this);
}; };
CountDownTimer.prototype.expire = function () { CountDownTimer.prototype.expire = function () {
this.onExpireCallbacks.forEach(function (callback) { this.onExpireCallbacks.forEach(function (callback) {
callback.call(this); callback.call(this);
}, this); }, this);
}; };
CountDownTimer.prototype.onTick = function (callback) { CountDownTimer.prototype.onTick = function (callback) {
if (typeof callback === 'function') { if (typeof callback === 'function') {
this.onTickCallbacks.push(callback); this.onTickCallbacks.push(callback);
} }
return this; return this;
}; };
CountDownTimer.prototype.onExpire = function (callback) { CountDownTimer.prototype.onExpire = function (callback) {
if (typeof callback === 'function') { if (typeof callback === 'function') {
this.onExpireCallbacks.push(callback); this.onExpireCallbacks.push(callback);
} }
return this; return this;
}; };
CountDownTimer.prototype.cancel = function () { CountDownTimer.prototype.cancel = function () {
this.running = false; this.running = false;
clearTimeout(this.timeoutId); clearTimeout(this.timeoutId);
return this; return this;
}; };
CountDownTimer.parse = (seconds) => ({ CountDownTimer.parse = (seconds) => ({
minutes: (seconds / 60) | 0, minutes: (seconds / 60) | 0,
seconds: (seconds % 60) | 0, seconds: (seconds % 60) | 0,
}); });
export const showCountDownTimerToReconnectOnModal = ($modal, pad) => {
if (clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) {
createCountDownElementsIfNecessary($modal);
const timer = createTimerForModal($modal, pad);
$modal.find('#cancelreconnect').one('click', () => {
timer.cancel();
disableAutomaticReconnection($modal);
});
enableAutomaticReconnection($modal);
}
};

View file

@ -1,11 +1,10 @@
import { padmodals as padmodals$0 } from "./pad_modals.js";
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it. * This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -21,72 +20,65 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const padmodals = { padmodals: padmodals$0 }.padmodals;
const padmodals = require('./pad_modals').padmodals;
const padconnectionstatus = (() => { const padconnectionstatus = (() => {
let status = { let status = {
what: 'connecting', what: 'connecting',
}; };
const self = {
const self = { init: () => {
init: () => { $('button#forcereconnect').click(() => {
$('button#forcereconnect').click(() => { window.location.reload();
window.location.reload(); });
}); },
}, connected: () => {
connected: () => { status = {
status = { what: 'connected',
what: 'connected', };
}; padmodals.showModal('connected');
padmodals.showModal('connected'); padmodals.hideOverlay();
padmodals.hideOverlay(); },
}, reconnecting: () => {
reconnecting: () => { status = {
status = { what: 'reconnecting',
what: 'reconnecting', };
}; padmodals.showModal('reconnecting');
padmodals.showOverlay();
padmodals.showModal('reconnecting'); },
padmodals.showOverlay(); disconnected: (msg) => {
}, if (status.what === 'disconnected')
disconnected: (msg) => { return;
if (status.what === 'disconnected') return; status = {
what: 'disconnected',
status = { why: msg,
what: 'disconnected', };
why: msg, // These message IDs correspond to localized strings that are presented to the user. If a new
}; // message ID is added here then a new div must be added to src/templates/pad.html and the
// corresponding l10n IDs must be added to the language files in src/locales.
// These message IDs correspond to localized strings that are presented to the user. If a new const knownReasons = [
// message ID is added here then a new div must be added to src/templates/pad.html and the 'badChangeset',
// corresponding l10n IDs must be added to the language files in src/locales. 'corruptPad',
const knownReasons = [ 'deleted',
'badChangeset', 'disconnected',
'corruptPad', 'initsocketfail',
'deleted', 'looping',
'disconnected', 'rateLimited',
'initsocketfail', 'rejected',
'looping', 'slowcommit',
'rateLimited', 'unauth',
'rejected', 'userdup',
'slowcommit', ];
'unauth', let k = String(msg);
'userdup', if (knownReasons.indexOf(k) === -1) {
]; // Fall back to a generic message.
let k = String(msg); k = 'disconnected';
if (knownReasons.indexOf(k) === -1) { }
// Fall back to a generic message. padmodals.showModal(k);
k = 'disconnected'; padmodals.showOverlay();
} },
isFullyConnected: () => status.what === 'connected',
padmodals.showModal(k); getStatus: () => status,
padmodals.showOverlay(); };
}, return self;
isFullyConnected: () => status.what === 'connected',
getStatus: () => status,
};
return self;
})(); })();
export { padconnectionstatus };
exports.padconnectionstatus = padconnectionstatus;

View file

@ -1,5 +1,5 @@
import * as padUtils from "./pad_utils.js";
'use strict'; 'use strict';
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -15,56 +15,50 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const Cookies = { Cookies: padUtils }.Cookies;
const Cookies = require('./pad_utils').Cookies; export const padcookie = new class {
constructor() {
exports.padcookie = new class { this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp';
constructor() {
this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp';
}
init() {
const prefs = this.readPrefs_() || {};
delete prefs.userId;
delete prefs.name;
delete prefs.colorId;
this.writePrefs_(prefs);
// Re-read the saved cookie to test if cookies are enabled.
if (this.readPrefs_() == null) {
$.gritter.add({
title: 'Error',
text: html10n.get('pad.noCookie'),
sticky: true,
class_name: 'error',
});
} }
} init() {
const prefs = this.readPrefs_() || {};
readPrefs_() { delete prefs.userId;
try { delete prefs.name;
const json = Cookies.get(this.cookieName_); delete prefs.colorId;
if (json == null) return null; this.writePrefs_(prefs);
return JSON.parse(json); // Re-read the saved cookie to test if cookies are enabled.
} catch (e) { if (this.readPrefs_() == null) {
return null; $.gritter.add({
title: 'Error',
text: html10n.get('pad.noCookie'),
sticky: true,
class_name: 'error',
});
}
}
readPrefs_() {
try {
const json = Cookies.get(this.cookieName_);
if (json == null)
return null;
return JSON.parse(json);
}
catch (e) {
return null;
}
}
writePrefs_(prefs) {
Cookies.set(this.cookieName_, JSON.stringify(prefs), { expires: 365 * 100 });
}
getPref(prefName) {
return this.readPrefs_()[prefName];
}
setPref(prefName, value) {
const prefs = this.readPrefs_();
prefs[prefName] = value;
this.writePrefs_(prefs);
}
clear() {
this.writePrefs_({});
} }
}
writePrefs_(prefs) {
Cookies.set(this.cookieName_, JSON.stringify(prefs), {expires: 365 * 100});
}
getPref(prefName) {
return this.readPrefs_()[prefName];
}
setPref(prefName, value) {
const prefs = this.readPrefs_();
prefs[prefName] = value;
this.writePrefs_(prefs);
}
clear() {
this.writePrefs_({});
}
}(); }();

View file

@ -1,481 +1,434 @@
import browser from "./vendors/browser.js";
import * as hooks from "./pluginfw/hooks.js";
import { padutils as padutils$0 } from "./pad_utils.js";
import { padeditor as padeditor$0 } from "./pad_editor.js";
import * as padsavedrevs from "./pad_savedrevs.js";
import _ from "underscore";
import "./vendors/nice-select.js";
'use strict'; 'use strict';
const padutils = { padutils: padutils$0 }.padutils;
/** const padeditor = { padeditor: padeditor$0 }.padeditor;
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const browser = require('./vendors/browser');
const hooks = require('./pluginfw/hooks');
const padutils = require('./pad_utils').padutils;
const padeditor = require('./pad_editor').padeditor;
const padsavedrevs = require('./pad_savedrevs');
const _ = require('underscore');
require('./vendors/nice-select');
class ToolbarItem { class ToolbarItem {
constructor(element) { constructor(element) {
this.$el = element; this.$el = element;
}
getCommand() {
return this.$el.attr('data-key');
}
getValue() {
if (this.isSelect()) {
return this.$el.find('select').val();
} }
} getCommand() {
return this.$el.attr('data-key');
setValue(val) {
if (this.isSelect()) {
return this.$el.find('select').val(val);
} }
} getValue() {
if (this.isSelect()) {
getType() { return this.$el.find('select').val();
return this.$el.attr('data-type'); }
} }
setValue(val) {
isSelect() { if (this.isSelect()) {
return this.getType() === 'select'; return this.$el.find('select').val(val);
} }
}
isButton() { getType() {
return this.getType() === 'button'; return this.$el.attr('data-type');
} }
isSelect() {
bind(callback) { return this.getType() === 'select';
if (this.isButton()) { }
this.$el.click((event) => { isButton() {
$(':focus').blur(); return this.getType() === 'button';
callback(this.getCommand(), this); }
event.preventDefault(); bind(callback) {
}); if (this.isButton()) {
} else if (this.isSelect()) { this.$el.click((event) => {
this.$el.find('select').change(() => { $(':focus').blur();
callback(this.getCommand(), this); callback(this.getCommand(), this);
}); event.preventDefault();
});
}
else if (this.isSelect()) {
this.$el.find('select').change(() => {
callback(this.getCommand(), this);
});
}
} }
}
} }
const syncAnimation = (() => { const syncAnimation = (() => {
const SYNCING = -100; const SYNCING = -100;
const DONE = 100; const DONE = 100;
let state = DONE; let state = DONE;
const fps = 25; const fps = 25;
const step = 1 / fps; const step = 1 / fps;
const T_START = -0.5; const T_START = -0.5;
const T_FADE = 1.0; const T_FADE = 1.0;
const T_GONE = 1.5; const T_GONE = 1.5;
const animator = padutils.makeAnimationScheduler(() => { const animator = padutils.makeAnimationScheduler(() => {
if (state === SYNCING || state === DONE) { if (state === SYNCING || state === DONE) {
return false; return false;
} else if (state >= T_GONE) {
state = DONE;
$('#syncstatussyncing').css('display', 'none');
$('#syncstatusdone').css('display', 'none');
return false;
} else if (state < 0) {
state += step;
if (state >= 0) {
$('#syncstatussyncing').css('display', 'none');
$('#syncstatusdone').css('display', 'block').css('opacity', 1);
}
return true;
} else {
state += step;
if (state >= T_FADE) {
$('#syncstatusdone').css('opacity', (T_GONE - state) / (T_GONE - T_FADE));
}
return true;
}
}, step * 1000);
return {
syncing: () => {
state = SYNCING;
$('#syncstatussyncing').css('display', 'block');
$('#syncstatusdone').css('display', 'none');
},
done: () => {
state = T_START;
animator.scheduleAnimation();
},
};
})();
exports.padeditbar = new class {
constructor() {
this._editbarPosition = 0;
this.commands = {};
this.dropdowns = [];
}
init() {
$('#editbar .editbarbutton').attr('unselectable', 'on'); // for IE
this.enable();
$('#editbar [data-key]').each((i, elt) => {
$(elt).unbind('click');
new ToolbarItem($(elt)).bind((command, item) => {
this.triggerCommand(command, item);
});
});
$('body:not(#editorcontainerbox)').on('keydown', (evt) => {
this._bodyKeyEvent(evt);
});
$('.show-more-icon-btn').click(() => {
$('.toolbar').toggleClass('full-icons');
});
this.checkAllIconsAreDisplayedInToolbar();
$(window).resize(_.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
this._registerDefaultCommands();
hooks.callAll('postToolbarInit', {
toolbar: this,
ace: padeditor.ace,
});
/*
* On safari, the dropdown in the toolbar gets hidden because of toolbar
* overflow:hidden property. This is a bug from Safari: any children with
* position:fixed (like the dropdown) should be displayed no matter
* overflow:hidden on parent
*/
if (!browser.safari) {
$('select').niceSelect();
}
// When editor is scrolled, we add a class to style the editbar differently
$('iframe[name="ace_outer"]').contents().scroll((ev) => {
$('#editbar').toggleClass('editor-scrolled', $(ev.currentTarget).scrollTop() > 2);
});
}
isEnabled() { return true; }
disable() {
$('#editbar').addClass('disabledtoolbar').removeClass('enabledtoolbar');
}
enable() {
$('#editbar').addClass('enabledtoolbar').removeClass('disabledtoolbar');
}
registerCommand(cmd, callback) {
this.commands[cmd] = callback;
return this;
}
registerDropdownCommand(cmd, dropdown) {
dropdown = dropdown || cmd;
this.dropdowns.push(dropdown);
this.registerCommand(cmd, () => {
this.toggleDropDown(dropdown);
});
}
registerAceCommand(cmd, callback) {
this.registerCommand(cmd, (cmd, ace, item) => {
ace.callWithAce((ace) => {
callback(cmd, ace, item);
}, cmd, true);
});
}
triggerCommand(cmd, item) {
if (this.isEnabled() && this.commands[cmd]) {
this.commands[cmd](cmd, padeditor.ace, item);
}
if (padeditor.ace) padeditor.ace.focus();
}
// cb is deprecated (this function is synchronous so a callback is unnecessary).
toggleDropDown(moduleName, cb = null) {
let cbErr = null;
try {
// do nothing if users are sticked
if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) {
return;
}
$('.nice-select').removeClass('open');
$('.toolbar-popup').removeClass('popup-show');
// hide all modules and remove highlighting of all buttons
if (moduleName === 'none') {
for (const thisModuleName of this.dropdowns) {
// skip the userlist
if (thisModuleName === 'users') continue;
const module = $(`#${thisModuleName}`);
// skip any "force reconnect" message
const isAForceReconnectMessage = module.find('button#forcereconnect:visible').length > 0;
if (isAForceReconnectMessage) continue;
if (module.hasClass('popup-show')) {
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
module.removeClass('popup-show');
}
} }
} else { else if (state >= T_GONE) {
// hide all modules that are not selected and remove highlighting state = DONE;
// respectively add highlighting to the corresponding button $('#syncstatussyncing').css('display', 'none');
for (const thisModuleName of this.dropdowns) { $('#syncstatusdone').css('display', 'none');
const module = $(`#${thisModuleName}`); return false;
if (module.hasClass('popup-show')) {
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
module.removeClass('popup-show');
} else if (thisModuleName === moduleName) {
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
module.addClass('popup-show');
}
} }
} else if (state < 0) {
} catch (err) { state += step;
cbErr = err || new Error(err); if (state >= 0) {
} finally { $('#syncstatussyncing').css('display', 'none');
if (cb) Promise.resolve().then(() => cb(cbErr)); $('#syncstatusdone').css('display', 'block').css('opacity', 1);
} }
} return true;
setSyncStatus(status) {
if (status === 'syncing') {
syncAnimation.syncing();
} else if (status === 'done') {
syncAnimation.done();
}
}
setEmbedLinks() {
const padUrl = window.location.href.split('?')[0];
const params = '?showControls=true&showChat=true&showLineNumbers=true&useMonospaceFont=false';
const props = 'width="100%" height="600" frameborder="0"';
if ($('#readonlyinput').is(':checked')) {
const urlParts = padUrl.split('/');
urlParts.pop();
const readonlyLink = `${urlParts.join('/')}/${clientVars.readOnlyId}`;
$('#embedinput')
.val(`<iframe name="embed_readonly" src="${readonlyLink}${params}" ${props}></iframe>`);
$('#linkinput').val(readonlyLink);
} else {
$('#embedinput')
.val(`<iframe name="embed_readwrite" src="${padUrl}${params}" ${props}></iframe>`);
$('#linkinput').val(padUrl);
}
}
checkAllIconsAreDisplayedInToolbar() {
// reset style
$('.toolbar').removeClass('cropped');
$('body').removeClass('mobile-layout');
const menuLeft = $('.toolbar .menu_left')[0];
// this is approximate, we cannot measure it because on mobile
// Layout it takes the full width on the bottom of the page
const menuRightWidth = 280;
if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width() - menuRightWidth ||
$('.toolbar').width() < 1000) {
$('body').addClass('mobile-layout');
}
if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width()) {
$('.toolbar').addClass('cropped');
}
}
_bodyKeyEvent(evt) {
// If the event is Alt F9 or Escape & we're already in the editbar menu
// Send the users focus back to the pad
if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {
if ($(':focus').parents('.toolbar').length === 1) {
// If we're in the editbar already..
// Close any dropdowns we have open..
this.toggleDropDown('none');
// Shift focus away from any drop downs
$(':focus').blur(); // required to do not try to remove!
// Check we're on a pad and not on the timeslider
// Or some other window I haven't thought about!
if (typeof pad === 'undefined') {
// Timeslider probably..
$('#editorcontainerbox').focus(); // Focus back onto the pad
} else {
padeditor.ace.focus(); // Sends focus back to pad
// The above focus doesn't always work in FF, you have to hit enter afterwards
evt.preventDefault();
} }
} else { else {
// Focus on the editbar :) state += step;
const firstEditbarElement = parent.parent.$('#editbar button').first(); if (state >= T_FADE) {
$('#syncstatusdone').css('opacity', (T_GONE - state) / (T_GONE - T_FADE));
$(evt.currentTarget).blur(); }
firstEditbarElement.focus(); return true;
evt.preventDefault(); }
} }, step * 1000);
} return {
// Are we in the toolbar?? syncing: () => {
if ($(':focus').parents('.toolbar').length === 1) { state = SYNCING;
// On arrow keys go to next/previous button item in editbar $('#syncstatussyncing').css('display', 'block');
if (evt.keyCode !== 39 && evt.keyCode !== 37) return; $('#syncstatusdone').css('display', 'none');
},
// Get all the focusable items in the editbar done: () => {
const focusItems = $('#editbar').find('button, select'); state = T_START;
animator.scheduleAnimation();
// On left arrow move to next button in editbar },
if (evt.keyCode === 37) {
// If a dropdown is visible or we're in an input don't move to the next button
if ($('.popup').is(':visible') || evt.target.localName === 'input') return;
this._editbarPosition--;
// Allow focus to shift back to end of row and start of row
if (this._editbarPosition === -1) this._editbarPosition = focusItems.length - 1;
$(focusItems[this._editbarPosition]).focus();
}
// On right arrow move to next button in editbar
if (evt.keyCode === 39) {
// If a dropdown is visible or we're in an input don't move to the next button
if ($('.popup').is(':visible') || evt.target.localName === 'input') return;
this._editbarPosition++;
// Allow focus to shift back to end of row and start of row
if (this._editbarPosition >= focusItems.length) this._editbarPosition = 0;
$(focusItems[this._editbarPosition]).focus();
}
}
}
_registerDefaultCommands() {
this.registerDropdownCommand('showusers', 'users');
this.registerDropdownCommand('settings');
this.registerDropdownCommand('connectivity');
this.registerDropdownCommand('import_export');
this.registerDropdownCommand('embed');
this.registerCommand('settings', () => {
this.toggleDropDown('settings');
$('#options-stickychat').focus();
});
this.registerCommand('import_export', () => {
this.toggleDropDown('import_export');
// If Import file input exists then focus on it..
if ($('#importfileinput').length !== 0) {
setTimeout(() => {
$('#importfileinput').focus();
}, 100);
} else {
$('.exportlink').first().focus();
}
});
this.registerCommand('showusers', () => {
this.toggleDropDown('users');
$('#myusernameedit').focus();
});
this.registerCommand('embed', () => {
this.setEmbedLinks();
this.toggleDropDown('embed');
$('#linkinput').focus().select();
});
this.registerCommand('savedRevision', () => {
padsavedrevs.saveNow();
});
this.registerCommand('showTimeSlider', () => {
document.location = `${document.location.pathname}/timeslider`;
});
const aceAttributeCommand = (cmd, ace) => {
ace.ace_toggleAttributeOnSelection(cmd);
}; };
this.registerAceCommand('bold', aceAttributeCommand); })();
this.registerAceCommand('italic', aceAttributeCommand); export const padeditbar = new class {
this.registerAceCommand('underline', aceAttributeCommand); constructor() {
this.registerAceCommand('strikethrough', aceAttributeCommand); this._editbarPosition = 0;
this.commands = {};
this.registerAceCommand('undo', (cmd, ace) => { this.dropdowns = [];
ace.ace_doUndoRedo(cmd); }
}); init() {
$('#editbar .editbarbutton').attr('unselectable', 'on'); // for IE
this.registerAceCommand('redo', (cmd, ace) => { this.enable();
ace.ace_doUndoRedo(cmd); $('#editbar [data-key]').each((i, elt) => {
}); $(elt).unbind('click');
new ToolbarItem($(elt)).bind((command, item) => {
this.registerAceCommand('insertunorderedlist', (cmd, ace) => { this.triggerCommand(command, item);
ace.ace_doInsertUnorderedList(); });
}); });
$('body:not(#editorcontainerbox)').on('keydown', (evt) => {
this.registerAceCommand('insertorderedlist', (cmd, ace) => { this._bodyKeyEvent(evt);
ace.ace_doInsertOrderedList(); });
}); $('.show-more-icon-btn').click(() => {
$('.toolbar').toggleClass('full-icons');
this.registerAceCommand('indent', (cmd, ace) => { });
if (!ace.ace_doIndentOutdent(false)) { this.checkAllIconsAreDisplayedInToolbar();
ace.ace_doInsertUnorderedList(); $(window).resize(_.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
} this._registerDefaultCommands();
}); hooks.callAll('postToolbarInit', {
toolbar: this,
this.registerAceCommand('outdent', (cmd, ace) => { ace: padeditor.ace,
ace.ace_doIndentOutdent(true); });
}); /*
* On safari, the dropdown in the toolbar gets hidden because of toolbar
this.registerAceCommand('clearauthorship', (cmd, ace) => { * overflow:hidden property. This is a bug from Safari: any children with
// If we have the whole document selected IE control A has been hit * position:fixed (like the dropdown) should be displayed no matter
const rep = ace.ace_getRep(); * overflow:hidden on parent
let doPrompt = false; */
const lastChar = rep.lines.atIndex(rep.lines.length() - 1).width - 1; if (!browser.safari) {
const lastLineIndex = rep.lines.length() - 1; $('select').niceSelect();
if (rep.selStart[0] === 0 && rep.selStart[1] === 0) {
// nesting intentionally here to make things readable
if (rep.selEnd[0] === lastLineIndex && rep.selEnd[1] === lastChar) {
doPrompt = true;
} }
} // When editor is scrolled, we add a class to style the editbar differently
/* $('iframe[name="ace_outer"]').contents().scroll((ev) => {
* NOTICE: This command isn't fired on Control Shift C. $('#editbar').toggleClass('editor-scrolled', $(ev.currentTarget).scrollTop() > 2);
* I intentionally didn't create duplicate code because if you are hitting });
* Control Shift C we make the assumption you are a "power user" }
* and as such we assume you don't need the prompt to bug you each time! isEnabled() { return true; }
* This does make wonder if it's worth having a checkbox to avoid being disable() {
* prompted again but that's probably overkill for this contribution. $('#editbar').addClass('disabledtoolbar').removeClass('enabledtoolbar');
*/ }
enable() {
// if we don't have any text selected, we have a caret or we have already said to prompt $('#editbar').addClass('enabledtoolbar').removeClass('disabledtoolbar');
if ((!(rep.selStart && rep.selEnd)) || ace.ace_isCaret() || doPrompt) { }
if (window.confirm(html10n.get('pad.editbar.clearcolors'))) { registerCommand(cmd, callback) {
ace.ace_performDocumentApplyAttributesToCharRange(0, ace.ace_getRep().alltext.length, [ this.commands[cmd] = callback;
['author', ''], return this;
]); }
registerDropdownCommand(cmd, dropdown) {
dropdown = dropdown || cmd;
this.dropdowns.push(dropdown);
this.registerCommand(cmd, () => {
this.toggleDropDown(dropdown);
});
}
registerAceCommand(cmd, callback) {
this.registerCommand(cmd, (cmd, ace, item) => {
ace.callWithAce((ace) => {
callback(cmd, ace, item);
}, cmd, true);
});
}
triggerCommand(cmd, item) {
if (this.isEnabled() && this.commands[cmd]) {
this.commands[cmd](cmd, padeditor.ace, item);
} }
} else { if (padeditor.ace)
ace.ace_setAttributeOnSelection('author', ''); padeditor.ace.focus();
} }
}); // cb is deprecated (this function is synchronous so a callback is unnecessary).
toggleDropDown(moduleName, cb = null) {
this.registerCommand('timeslider_returnToPad', (cmd) => { let cbErr = null;
if (document.referrer.length > 0 && try {
document.referrer.substring(document.referrer.lastIndexOf('/') - 1, // do nothing if users are sticked
document.referrer.lastIndexOf('/')) === 'p') { if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) {
document.location = document.referrer; return;
} else { }
document.location = document.location.href $('.nice-select').removeClass('open');
.substring(0, document.location.href.lastIndexOf('/')); $('.toolbar-popup').removeClass('popup-show');
} // hide all modules and remove highlighting of all buttons
}); if (moduleName === 'none') {
} for (const thisModuleName of this.dropdowns) {
// skip the userlist
if (thisModuleName === 'users')
continue;
const module = $(`#${thisModuleName}`);
// skip any "force reconnect" message
const isAForceReconnectMessage = module.find('button#forcereconnect:visible').length > 0;
if (isAForceReconnectMessage)
continue;
if (module.hasClass('popup-show')) {
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
module.removeClass('popup-show');
}
}
}
else {
// hide all modules that are not selected and remove highlighting
// respectively add highlighting to the corresponding button
for (const thisModuleName of this.dropdowns) {
const module = $(`#${thisModuleName}`);
if (module.hasClass('popup-show')) {
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
module.removeClass('popup-show');
}
else if (thisModuleName === moduleName) {
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
module.addClass('popup-show');
}
}
}
}
catch (err) {
cbErr = err || new Error(err);
}
finally {
if (cb)
Promise.resolve().then(() => cb(cbErr));
}
}
setSyncStatus(status) {
if (status === 'syncing') {
syncAnimation.syncing();
}
else if (status === 'done') {
syncAnimation.done();
}
}
setEmbedLinks() {
const padUrl = window.location.href.split('?')[0];
const params = '?showControls=true&showChat=true&showLineNumbers=true&useMonospaceFont=false';
const props = 'width="100%" height="600" frameborder="0"';
if ($('#readonlyinput').is(':checked')) {
const urlParts = padUrl.split('/');
urlParts.pop();
const readonlyLink = `${urlParts.join('/')}/${clientVars.readOnlyId}`;
$('#embedinput')
.val(`<iframe name="embed_readonly" src="${readonlyLink}${params}" ${props}></iframe>`);
$('#linkinput').val(readonlyLink);
}
else {
$('#embedinput')
.val(`<iframe name="embed_readwrite" src="${padUrl}${params}" ${props}></iframe>`);
$('#linkinput').val(padUrl);
}
}
checkAllIconsAreDisplayedInToolbar() {
// reset style
$('.toolbar').removeClass('cropped');
$('body').removeClass('mobile-layout');
const menuLeft = $('.toolbar .menu_left')[0];
// this is approximate, we cannot measure it because on mobile
// Layout it takes the full width on the bottom of the page
const menuRightWidth = 280;
if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width() - menuRightWidth ||
$('.toolbar').width() < 1000) {
$('body').addClass('mobile-layout');
}
if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width()) {
$('.toolbar').addClass('cropped');
}
}
_bodyKeyEvent(evt) {
// If the event is Alt F9 or Escape & we're already in the editbar menu
// Send the users focus back to the pad
if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {
if ($(':focus').parents('.toolbar').length === 1) {
// If we're in the editbar already..
// Close any dropdowns we have open..
this.toggleDropDown('none');
// Shift focus away from any drop downs
$(':focus').blur(); // required to do not try to remove!
// Check we're on a pad and not on the timeslider
// Or some other window I haven't thought about!
if (typeof pad === 'undefined') {
// Timeslider probably..
$('#editorcontainerbox').focus(); // Focus back onto the pad
}
else {
padeditor.ace.focus(); // Sends focus back to pad
// The above focus doesn't always work in FF, you have to hit enter afterwards
evt.preventDefault();
}
}
else {
// Focus on the editbar :)
const firstEditbarElement = parent.parent.$('#editbar button').first();
$(evt.currentTarget).blur();
firstEditbarElement.focus();
evt.preventDefault();
}
}
// Are we in the toolbar??
if ($(':focus').parents('.toolbar').length === 1) {
// On arrow keys go to next/previous button item in editbar
if (evt.keyCode !== 39 && evt.keyCode !== 37)
return;
// Get all the focusable items in the editbar
const focusItems = $('#editbar').find('button, select');
// On left arrow move to next button in editbar
if (evt.keyCode === 37) {
// If a dropdown is visible or we're in an input don't move to the next button
if ($('.popup').is(':visible') || evt.target.localName === 'input')
return;
this._editbarPosition--;
// Allow focus to shift back to end of row and start of row
if (this._editbarPosition === -1)
this._editbarPosition = focusItems.length - 1;
$(focusItems[this._editbarPosition]).focus();
}
// On right arrow move to next button in editbar
if (evt.keyCode === 39) {
// If a dropdown is visible or we're in an input don't move to the next button
if ($('.popup').is(':visible') || evt.target.localName === 'input')
return;
this._editbarPosition++;
// Allow focus to shift back to end of row and start of row
if (this._editbarPosition >= focusItems.length)
this._editbarPosition = 0;
$(focusItems[this._editbarPosition]).focus();
}
}
}
_registerDefaultCommands() {
this.registerDropdownCommand('showusers', 'users');
this.registerDropdownCommand('settings');
this.registerDropdownCommand('connectivity');
this.registerDropdownCommand('import_export');
this.registerDropdownCommand('embed');
this.registerCommand('settings', () => {
this.toggleDropDown('settings');
$('#options-stickychat').focus();
});
this.registerCommand('import_export', () => {
this.toggleDropDown('import_export');
// If Import file input exists then focus on it..
if ($('#importfileinput').length !== 0) {
setTimeout(() => {
$('#importfileinput').focus();
}, 100);
}
else {
$('.exportlink').first().focus();
}
});
this.registerCommand('showusers', () => {
this.toggleDropDown('users');
$('#myusernameedit').focus();
});
this.registerCommand('embed', () => {
this.setEmbedLinks();
this.toggleDropDown('embed');
$('#linkinput').focus().select();
});
this.registerCommand('savedRevision', () => {
padsavedrevs.saveNow();
});
this.registerCommand('showTimeSlider', () => {
document.location = `${document.location.pathname}/timeslider`;
});
const aceAttributeCommand = (cmd, ace) => {
ace.ace_toggleAttributeOnSelection(cmd);
};
this.registerAceCommand('bold', aceAttributeCommand);
this.registerAceCommand('italic', aceAttributeCommand);
this.registerAceCommand('underline', aceAttributeCommand);
this.registerAceCommand('strikethrough', aceAttributeCommand);
this.registerAceCommand('undo', (cmd, ace) => {
ace.ace_doUndoRedo(cmd);
});
this.registerAceCommand('redo', (cmd, ace) => {
ace.ace_doUndoRedo(cmd);
});
this.registerAceCommand('insertunorderedlist', (cmd, ace) => {
ace.ace_doInsertUnorderedList();
});
this.registerAceCommand('insertorderedlist', (cmd, ace) => {
ace.ace_doInsertOrderedList();
});
this.registerAceCommand('indent', (cmd, ace) => {
if (!ace.ace_doIndentOutdent(false)) {
ace.ace_doInsertUnorderedList();
}
});
this.registerAceCommand('outdent', (cmd, ace) => {
ace.ace_doIndentOutdent(true);
});
this.registerAceCommand('clearauthorship', (cmd, ace) => {
// If we have the whole document selected IE control A has been hit
const rep = ace.ace_getRep();
let doPrompt = false;
const lastChar = rep.lines.atIndex(rep.lines.length() - 1).width - 1;
const lastLineIndex = rep.lines.length() - 1;
if (rep.selStart[0] === 0 && rep.selStart[1] === 0) {
// nesting intentionally here to make things readable
if (rep.selEnd[0] === lastLineIndex && rep.selEnd[1] === lastChar) {
doPrompt = true;
}
}
/*
* NOTICE: This command isn't fired on Control Shift C.
* I intentionally didn't create duplicate code because if you are hitting
* Control Shift C we make the assumption you are a "power user"
* and as such we assume you don't need the prompt to bug you each time!
* This does make wonder if it's worth having a checkbox to avoid being
* prompted again but that's probably overkill for this contribution.
*/
// if we don't have any text selected, we have a caret or we have already said to prompt
if ((!(rep.selStart && rep.selEnd)) || ace.ace_isCaret() || doPrompt) {
if (window.confirm(html10n.get('pad.editbar.clearcolors'))) {
ace.ace_performDocumentApplyAttributesToCharRange(0, ace.ace_getRep().alltext.length, [
['author', ''],
]);
}
}
else {
ace.ace_setAttributeOnSelection('author', '');
}
});
this.registerCommand('timeslider_returnToPad', (cmd) => {
if (document.referrer.length > 0 &&
document.referrer.substring(document.referrer.lastIndexOf('/') - 1, document.referrer.lastIndexOf('/')) === 'p') {
document.location = document.referrer;
}
else {
document.location = document.location.href
.substring(0, document.location.href.lastIndexOf('/'));
}
});
}
}(); }();

View file

@ -1,10 +1,11 @@
import * as padUtils from "./pad_utils.js";
import { padcookie as padcookie$0 } from "./pad_cookie.js";
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it. * This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -20,191 +21,177 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const Cookies = { Cookies: padUtils }.Cookies;
const Cookies = require('./pad_utils').Cookies; const padcookie = { padcookie: padcookie$0 }.padcookie;
const padcookie = require('./pad_cookie').padcookie; const padutils = { padutils: padUtils }.padutils;
const padutils = require('./pad_utils').padutils;
const padeditor = (() => { const padeditor = (() => {
let Ace2Editor = undefined; let Ace2Editor = undefined;
let pad = undefined; let pad = undefined;
let settings = undefined; let settings = undefined;
const self = {
const self = { ace: null,
ace: null, // this is accessed directly from other files
// this is accessed directly from other files viewZoom: 100,
viewZoom: 100, init: async (initialViewOptions, _pad) => {
init: async (initialViewOptions, _pad) => { Ace2Editor = require('./ace').Ace2Editor;
Ace2Editor = require('./ace').Ace2Editor; pad = _pad;
pad = _pad; settings = pad.settings;
settings = pad.settings; self.ace = new Ace2Editor();
self.ace = new Ace2Editor(); await self.ace.init('editorcontainer', '');
await self.ace.init('editorcontainer', ''); $('#editorloadingbox').hide();
$('#editorloadingbox').hide(); // Listen for clicks on sidediv items
// Listen for clicks on sidediv items const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody'); $outerdoc.find('#sidedivinner').on('click', 'div', function () {
$outerdoc.find('#sidedivinner').on('click', 'div', function () { const targetLineNumber = $(this).index() + 1;
const targetLineNumber = $(this).index() + 1; window.location.hash = `L${targetLineNumber}`;
window.location.hash = `L${targetLineNumber}`; });
}); exports.focusOnLine(self.ace);
exports.focusOnLine(self.ace); self.ace.setProperty('wraps', true);
self.ace.setProperty('wraps', true); self.initViewOptions();
self.initViewOptions(); self.setViewOptions(initialViewOptions);
self.setViewOptions(initialViewOptions); // view bar
// view bar $('#viewbarcontents').show();
$('#viewbarcontents').show(); },
}, initViewOptions: () => {
initViewOptions: () => { // Line numbers
// Line numbers padutils.bindCheckboxChange($('#options-linenoscheck'), () => {
padutils.bindCheckboxChange($('#options-linenoscheck'), () => { pad.changeViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck')));
pad.changeViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck'))); });
}); // Author colors
padutils.bindCheckboxChange($('#options-colorscheck'), () => {
// Author colors padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck'));
padutils.bindCheckboxChange($('#options-colorscheck'), () => { pad.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck'));
padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck')); });
pad.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck')); // Right to left
}); padutils.bindCheckboxChange($('#options-rtlcheck'), () => {
pad.changeViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck')));
// Right to left });
padutils.bindCheckboxChange($('#options-rtlcheck'), () => { html10n.bind('localized', () => {
pad.changeViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck'))); pad.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
}); padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection()));
html10n.bind('localized', () => { });
pad.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection())); // font family change
padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection())); $('#viewfontmenu').change(() => {
}); pad.changeViewOption('padFontFamily', $('#viewfontmenu').val());
});
// font family change // Language
$('#viewfontmenu').change(() => { html10n.bind('localized', () => {
pad.changeViewOption('padFontFamily', $('#viewfontmenu').val()); $('#languagemenu').val(html10n.getLanguage());
}); // translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist
// this does not interfere with html10n's normal value-setting because
// Language // html10n just ingores <input>s
html10n.bind('localized', () => { // also, a value which has been set by the user will be not overwritten
$('#languagemenu').val(html10n.getLanguage()); // since a user-edited <input> does *not* have the editempty-class
// translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist $('input[data-l10n-id]').each((key, input) => {
input = $(input);
// this does not interfere with html10n's normal value-setting because if (input.hasClass('editempty')) {
// html10n just ingores <input>s input.val(html10n.get(input.attr('data-l10n-id')));
// also, a value which has been set by the user will be not overwritten }
// since a user-edited <input> does *not* have the editempty-class });
$('input[data-l10n-id]').each((key, input) => { });
input = $(input); $('#languagemenu').val(html10n.getLanguage());
if (input.hasClass('editempty')) { $('#languagemenu').change(() => {
input.val(html10n.get(input.attr('data-l10n-id'))); Cookies.set('language', $('#languagemenu').val());
} window.html10n.localize([$('#languagemenu').val(), 'en']);
}); if ($('select').niceSelect) {
}); $('select').niceSelect('update');
$('#languagemenu').val(html10n.getLanguage()); }
$('#languagemenu').change(() => { });
Cookies.set('language', $('#languagemenu').val()); },
window.html10n.localize([$('#languagemenu').val(), 'en']); setViewOptions: (newOptions) => {
if ($('select').niceSelect) { const getOption = (key, defaultValue) => {
$('select').niceSelect('update'); const value = String(newOptions[key]);
} if (value === 'true')
}); return true;
}, if (value === 'false')
setViewOptions: (newOptions) => { return false;
const getOption = (key, defaultValue) => { return defaultValue;
const value = String(newOptions[key]);
if (value === 'true') return true;
if (value === 'false') return false;
return defaultValue;
};
let v;
v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
self.ace.setProperty('rtlIsTrue', v);
padutils.setCheckbox($('#options-rtlcheck'), v);
v = getOption('showLineNumbers', true);
self.ace.setProperty('showslinenumbers', v);
padutils.setCheckbox($('#options-linenoscheck'), v);
v = getOption('showAuthorColors', true);
self.ace.setProperty('showsauthorcolors', v);
$('#chattext').toggleClass('authorColors', v);
$('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v);
padutils.setCheckbox($('#options-colorscheck'), v);
// Override from parameters if true
if (settings.noColors !== false) {
self.ace.setProperty('showsauthorcolors', !settings.noColors);
}
self.ace.setProperty('textface', newOptions.padFontFamily || '');
},
dispose: () => {
if (self.ace) {
self.ace.destroy();
self.ace = null;
}
},
enable: () => {
if (self.ace) {
self.ace.setEditable(true);
}
},
disable: () => {
if (self.ace) {
self.ace.setEditable(false);
}
},
restoreRevisionText: (dataFromServer) => {
pad.addHistoricalAuthors(dataFromServer.historicalAuthorData);
self.ace.importAText(dataFromServer.atext, dataFromServer.apool, true);
},
};
return self;
})();
exports.padeditor = padeditor;
exports.focusOnLine = (ace) => {
// If a number is in the URI IE #L124 go to that line number
const lineNumber = window.location.hash.substr(1);
if (lineNumber) {
if (lineNumber[0] === 'L') {
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
const lineNumberInt = parseInt(lineNumber.substr(1));
if (lineNumberInt) {
const $inner = $('iframe[name="ace_outer"]').contents().find('iframe')
.contents().find('#innerdocbody');
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
if (line.length !== 0) {
let offsetTop = line.offset().top;
offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));
const hasMobileLayout = $('body').hasClass('mobile-layout');
if (!hasMobileLayout) {
offsetTop += parseInt($inner.css('padding-top').replace('px', ''));
}
const $outerdocHTML = $('iframe[name="ace_outer"]').contents()
.find('#outerdocbody').parent();
$outerdoc.css({top: `${offsetTop}px`}); // Chrome
$outerdocHTML.animate({scrollTop: offsetTop}); // needed for FF
const node = line[0];
ace.callWithAce((ace) => {
const selection = {
startPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
endPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
}; };
ace.ace_setSelection(selection); let v;
}); v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
self.ace.setProperty('rtlIsTrue', v);
padutils.setCheckbox($('#options-rtlcheck'), v);
v = getOption('showLineNumbers', true);
self.ace.setProperty('showslinenumbers', v);
padutils.setCheckbox($('#options-linenoscheck'), v);
v = getOption('showAuthorColors', true);
self.ace.setProperty('showsauthorcolors', v);
$('#chattext').toggleClass('authorColors', v);
$('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v);
padutils.setCheckbox($('#options-colorscheck'), v);
// Override from parameters if true
if (settings.noColors !== false) {
self.ace.setProperty('showsauthorcolors', !settings.noColors);
}
self.ace.setProperty('textface', newOptions.padFontFamily || '');
},
dispose: () => {
if (self.ace) {
self.ace.destroy();
self.ace = null;
}
},
enable: () => {
if (self.ace) {
self.ace.setEditable(true);
}
},
disable: () => {
if (self.ace) {
self.ace.setEditable(false);
}
},
restoreRevisionText: (dataFromServer) => {
pad.addHistoricalAuthors(dataFromServer.historicalAuthorData);
self.ace.importAText(dataFromServer.atext, dataFromServer.apool, true);
},
};
return self;
})();
export const focusOnLine = (ace) => {
// If a number is in the URI IE #L124 go to that line number
const lineNumber = window.location.hash.substr(1);
if (lineNumber) {
if (lineNumber[0] === 'L') {
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
const lineNumberInt = parseInt(lineNumber.substr(1));
if (lineNumberInt) {
const $inner = $('iframe[name="ace_outer"]').contents().find('iframe')
.contents().find('#innerdocbody');
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
if (line.length !== 0) {
let offsetTop = line.offset().top;
offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));
const hasMobileLayout = $('body').hasClass('mobile-layout');
if (!hasMobileLayout) {
offsetTop += parseInt($inner.css('padding-top').replace('px', ''));
}
const $outerdocHTML = $('iframe[name="ace_outer"]').contents()
.find('#outerdocbody').parent();
$outerdoc.css({ top: `${offsetTop}px` }); // Chrome
$outerdocHTML.animate({ scrollTop: offsetTop }); // needed for FF
const node = line[0];
ace.callWithAce((ace) => {
const selection = {
startPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
endPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
};
ace.ace_setSelection(selection);
});
}
}
} }
}
} }
} // End of setSelection / set Y position of editor
// End of setSelection / set Y position of editor
}; };
export { padeditor };

View file

@ -1,11 +1,9 @@
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it. * This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -21,163 +19,154 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const padimpexp = (() => { const padimpexp = (() => {
let pad; let pad;
// /// import
// /// import const addImportFrames = () => {
const addImportFrames = () => { $('#import .importframe').remove();
$('#import .importframe').remove(); const iframe = $('<iframe>')
const iframe = $('<iframe>') .css('display', 'none')
.css('display', 'none') .attr('name', 'importiframe')
.attr('name', 'importiframe') .addClass('importframe');
.addClass('importframe'); $('#import').append(iframe);
$('#import').append(iframe);
};
const fileInputUpdated = () => {
$('#importsubmitinput').addClass('throbbold');
$('#importformfilediv').addClass('importformenabled');
$('#importsubmitinput').removeAttr('disabled');
$('#importmessagefail').fadeOut('fast');
};
const fileInputSubmit = function (e) {
e.preventDefault();
$('#importmessagefail').fadeOut('fast');
if (!window.confirm(html10n.get('pad.impexp.confirmimport'))) return;
$('#importsubmitinput').attr({disabled: true}).val(html10n.get('pad.impexp.importing'));
window.setTimeout(() => $('#importfileinput').attr({disabled: true}), 0);
$('#importarrow').stop(true, true).hide();
$('#importstatusball').show();
(async () => {
const {code, message, data: {directDatabaseAccess} = {}} = await $.ajax({
url: `${window.location.href.split('?')[0].split('#')[0]}/import`,
method: 'POST',
data: new FormData(this),
processData: false,
contentType: false,
dataType: 'json',
timeout: 25000,
}).catch((err) => {
if (err.responseJSON) return err.responseJSON;
return {code: 2, message: 'Unknown import error'};
});
if (code !== 0) {
importErrorMessage(message);
} else {
$('#import_export').removeClass('popup-show');
if (directDatabaseAccess) window.location.reload();
}
$('#importsubmitinput').removeAttr('disabled').val(html10n.get('pad.impexp.importbutton'));
window.setTimeout(() => $('#importfileinput').removeAttr('disabled'), 0);
$('#importstatusball').hide();
addImportFrames();
})();
};
const importErrorMessage = (status) => {
const known = [
'convertFailed',
'uploadFailed',
'padHasData',
'maxFileSize',
'permission',
];
const msg = html10n.get(`pad.impexp.${known.indexOf(status) !== -1 ? status : 'copypaste'}`);
const showError = (fade) => {
const popup = $('#importmessagefail').empty()
.append($('<strong>')
.css('color', 'red')
.text(`${html10n.get('pad.impexp.importfailed')}: `))
.append(document.createTextNode(msg));
popup[(fade ? 'fadeIn' : 'show')]();
}; };
const fileInputUpdated = () => {
if ($('#importexport .importmessage').is(':visible')) { $('#importsubmitinput').addClass('throbbold');
$('#importmessagesuccess').fadeOut('fast'); $('#importformfilediv').addClass('importformenabled');
$('#importmessagefail').fadeOut('fast', () => showError(true)); $('#importsubmitinput').removeAttr('disabled');
} else { $('#importmessagefail').fadeOut('fast');
showError(); };
const fileInputSubmit = function (e) {
e.preventDefault();
$('#importmessagefail').fadeOut('fast');
if (!window.confirm(html10n.get('pad.impexp.confirmimport')))
return;
$('#importsubmitinput').attr({ disabled: true }).val(html10n.get('pad.impexp.importing'));
window.setTimeout(() => $('#importfileinput').attr({ disabled: true }), 0);
$('#importarrow').stop(true, true).hide();
$('#importstatusball').show();
(async () => {
const { code, message, data: { directDatabaseAccess } = {} } = await $.ajax({
url: `${window.location.href.split('?')[0].split('#')[0]}/import`,
method: 'POST',
data: new FormData(this),
processData: false,
contentType: false,
dataType: 'json',
timeout: 25000,
}).catch((err) => {
if (err.responseJSON)
return err.responseJSON;
return { code: 2, message: 'Unknown import error' };
});
if (code !== 0) {
importErrorMessage(message);
}
else {
$('#import_export').removeClass('popup-show');
if (directDatabaseAccess)
window.location.reload();
}
$('#importsubmitinput').removeAttr('disabled').val(html10n.get('pad.impexp.importbutton'));
window.setTimeout(() => $('#importfileinput').removeAttr('disabled'), 0);
$('#importstatusball').hide();
addImportFrames();
})();
};
const importErrorMessage = (status) => {
const known = [
'convertFailed',
'uploadFailed',
'padHasData',
'maxFileSize',
'permission',
];
const msg = html10n.get(`pad.impexp.${known.indexOf(status) !== -1 ? status : 'copypaste'}`);
const showError = (fade) => {
const popup = $('#importmessagefail').empty()
.append($('<strong>')
.css('color', 'red')
.text(`${html10n.get('pad.impexp.importfailed')}: `))
.append(document.createTextNode(msg));
popup[(fade ? 'fadeIn' : 'show')]();
};
if ($('#importexport .importmessage').is(':visible')) {
$('#importmessagesuccess').fadeOut('fast');
$('#importmessagefail').fadeOut('fast', () => showError(true));
}
else {
showError();
}
};
// /// export
function cantExport() {
let type = $(this);
if (type.hasClass('exporthrefpdf')) {
type = 'PDF';
}
else if (type.hasClass('exporthrefdoc')) {
type = 'Microsoft Word';
}
else if (type.hasClass('exporthrefodt')) {
type = 'OpenDocument';
}
else {
type = 'this file';
}
alert(html10n.get('pad.impexp.exportdisabled', { type }));
return false;
} }
}; // ///
const self = {
// /// export init: (_pad) => {
pad = _pad;
function cantExport() { // get /p/padname
let type = $(this); // if /p/ isn't available due to a rewrite we use the clientVars padId
if (type.hasClass('exporthrefpdf')) { const padRootPath = /.*\/p\/[^/]+/.exec(document.location.pathname) || clientVars.padId;
type = 'PDF'; // i10l buttom import
} else if (type.hasClass('exporthrefdoc')) { $('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));
type = 'Microsoft Word'; html10n.bind('localized', () => {
} else if (type.hasClass('exporthrefodt')) { $('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));
type = 'OpenDocument'; });
} else { // build the export links
type = 'this file'; $('#exporthtmla').attr('href', `${padRootPath}/export/html`);
} $('#exportetherpada').attr('href', `${padRootPath}/export/etherpad`);
alert(html10n.get('pad.impexp.exportdisabled', {type})); $('#exportplaina').attr('href', `${padRootPath}/export/txt`);
return false; // hide stuff thats not avaible if abiword/soffice is disabled
} if (clientVars.exportAvailable === 'no') {
$('#exportworda').remove();
// /// $('#exportpdfa').remove();
const self = { $('#exportopena').remove();
init: (_pad) => { $('#importmessageabiword').show();
pad = _pad; }
else if (clientVars.exportAvailable === 'withoutPDF') {
// get /p/padname $('#exportpdfa').remove();
// if /p/ isn't available due to a rewrite we use the clientVars padId $('#exportworda').attr('href', `${padRootPath}/export/doc`);
const padRootPath = /.*\/p\/[^/]+/.exec(document.location.pathname) || clientVars.padId; $('#exportopena').attr('href', `${padRootPath}/export/odt`);
$('#importexport').css({ height: '142px' });
// i10l buttom import $('#importexportline').css({ height: '142px' });
$('#importsubmitinput').val(html10n.get('pad.impexp.importbutton')); }
html10n.bind('localized', () => { else {
$('#importsubmitinput').val(html10n.get('pad.impexp.importbutton')); $('#exportworda').attr('href', `${padRootPath}/export/doc`);
}); $('#exportpdfa').attr('href', `${padRootPath}/export/pdf`);
$('#exportopena').attr('href', `${padRootPath}/export/odt`);
// build the export links }
$('#exporthtmla').attr('href', `${padRootPath}/export/html`); addImportFrames();
$('#exportetherpada').attr('href', `${padRootPath}/export/etherpad`); $('#importfileinput').change(fileInputUpdated);
$('#exportplaina').attr('href', `${padRootPath}/export/txt`); $('#importform').unbind('submit').submit(fileInputSubmit);
$('.disabledexport').click(cantExport);
// hide stuff thats not avaible if abiword/soffice is disabled },
if (clientVars.exportAvailable === 'no') { disable: () => {
$('#exportworda').remove(); $('#impexp-disabled-clickcatcher').show();
$('#exportpdfa').remove(); $('#import').css('opacity', 0.5);
$('#exportopena').remove(); $('#impexp-export').css('opacity', 0.5);
},
$('#importmessageabiword').show(); enable: () => {
} else if (clientVars.exportAvailable === 'withoutPDF') { $('#impexp-disabled-clickcatcher').hide();
$('#exportpdfa').remove(); $('#import').css('opacity', 1);
$('#impexp-export').css('opacity', 1);
$('#exportworda').attr('href', `${padRootPath}/export/doc`); },
$('#exportopena').attr('href', `${padRootPath}/export/odt`); };
return self;
$('#importexport').css({height: '142px'});
$('#importexportline').css({height: '142px'});
} else {
$('#exportworda').attr('href', `${padRootPath}/export/doc`);
$('#exportpdfa').attr('href', `${padRootPath}/export/pdf`);
$('#exportopena').attr('href', `${padRootPath}/export/odt`);
}
addImportFrames();
$('#importfileinput').change(fileInputUpdated);
$('#importform').unbind('submit').submit(fileInputSubmit);
$('.disabledexport').click(cantExport);
},
disable: () => {
$('#impexp-disabled-clickcatcher').show();
$('#import').css('opacity', 0.5);
$('#impexp-export').css('opacity', 0.5);
},
enable: () => {
$('#impexp-disabled-clickcatcher').hide();
$('#import').css('opacity', 1);
$('#impexp-export').css('opacity', 1);
},
};
return self;
})(); })();
export { padimpexp };
exports.padimpexp = padimpexp;

View file

@ -1,11 +1,11 @@
import { padeditbar as padeditbar$0 } from "./pad_editbar.js";
import * as automaticReconnect from "./pad_automatic_reconnect.js";
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it. * This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -21,35 +21,29 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const padeditbar = { padeditbar: padeditbar$0 }.padeditbar;
const padeditbar = require('./pad_editbar').padeditbar;
const automaticReconnect = require('./pad_automatic_reconnect');
const padmodals = (() => { const padmodals = (() => {
let pad = undefined; let pad = undefined;
const self = { const self = {
init: (_pad) => { init: (_pad) => {
pad = _pad; pad = _pad;
}, },
showModal: (messageId) => { showModal: (messageId) => {
padeditbar.toggleDropDown('none'); padeditbar.toggleDropDown('none');
$('#connectivity .visible').removeClass('visible'); $('#connectivity .visible').removeClass('visible');
$(`#connectivity .${messageId}`).addClass('visible'); $(`#connectivity .${messageId}`).addClass('visible');
const $modal = $(`#connectivity .${messageId}`);
const $modal = $(`#connectivity .${messageId}`); automaticReconnect.showCountDownTimerToReconnectOnModal($modal, pad);
automaticReconnect.showCountDownTimerToReconnectOnModal($modal, pad); padeditbar.toggleDropDown('connectivity');
},
padeditbar.toggleDropDown('connectivity'); showOverlay: () => {
}, // Prevent the user to interact with the toolbar. Useful when user is disconnected for example
showOverlay: () => { $('#toolbar-overlay').show();
// Prevent the user to interact with the toolbar. Useful when user is disconnected for example },
$('#toolbar-overlay').show(); hideOverlay: () => {
}, $('#toolbar-overlay').hide();
hideOverlay: () => { },
$('#toolbar-overlay').hide(); };
}, return self;
};
return self;
})(); })();
export { padmodals };
exports.padmodals = padmodals;

View file

@ -1,5 +1,4 @@
'use strict'; 'use strict';
/** /**
* Copyright 2012 Peter 'Pita' Martischka * Copyright 2012 Peter 'Pita' Martischka
* *
@ -15,24 +14,21 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
let pad; let pad;
export const saveNow = () => {
exports.saveNow = () => { pad.collabClient.sendMessage({ type: 'SAVE_REVISION' });
pad.collabClient.sendMessage({type: 'SAVE_REVISION'}); $.gritter.add({
$.gritter.add({ // (string | mandatory) the heading of the notification
// (string | mandatory) the heading of the notification title: html10n.get('pad.savedrevs.marked'),
title: html10n.get('pad.savedrevs.marked'), // (string | mandatory) the text inside the notification
// (string | mandatory) the text inside the notification text: html10n.get('pad.savedrevs.timeslider') ||
text: html10n.get('pad.savedrevs.timeslider') || 'You can view saved revisions in the timeslider',
'You can view saved revisions in the timeslider', // (bool | optional) if you want it to fade out on its own or just sit there
// (bool | optional) if you want it to fade out on its own or just sit there sticky: false,
sticky: false, time: 3000,
time: 3000, class_name: 'saved-revision',
class_name: 'saved-revision', });
});
}; };
export const init = (_pad) => {
exports.init = (_pad) => { pad = _pad;
pad = _pad;
}; };

File diff suppressed because it is too large Load diff

View file

@ -1,58 +1,52 @@
import * as pluginUtils from "./shared.js";
import * as defs from "./plugin_defs.js";
'use strict'; 'use strict';
const pluginUtils = require('./shared');
const defs = require('./plugin_defs');
exports.baseURL = '';
exports.ensure = (cb) => !defs.loaded ? exports.update(cb) : cb();
exports.update = (cb) => {
// It appears that this response (see #620) may interrupt the current thread
// of execution on Firefox. This schedules the response in the run-loop,
// which appears to fix the issue.
const callback = () => setTimeout(cb, 0);
jQuery.getJSON(
`${exports.baseURL}pluginfw/plugin-definitions.json?v=${clientVars.randomVersionString}`
).done((data) => {
defs.plugins = data.plugins;
defs.parts = data.parts;
defs.hooks = pluginUtils.extractHooks(defs.parts, 'client_hooks');
defs.loaded = true;
callback();
}).fail((err) => {
console.error(`Failed to load plugin-definitions: ${err}`);
callback();
});
};
const adoptPluginsFromAncestorsOf = (frame) => { const adoptPluginsFromAncestorsOf = (frame) => {
// Bind plugins with parent; // Bind plugins with parent;
let parentRequire = null; let parentRequire = null;
try { try {
while ((frame = frame.parent)) { while ((frame = frame.parent)) {
if (typeof (frame.require) !== 'undefined') { if (typeof (frame.require) !== 'undefined') {
parentRequire = frame.require; parentRequire = frame.require;
break; break;
} }
}
} }
} catch (error) { catch (error) {
// Silence (this can only be a XDomain issue). // Silence (this can only be a XDomain issue).
console.error(error); console.error(error);
} }
if (!parentRequire)
if (!parentRequire) throw new Error('Parent plugins could not be found.'); throw new Error('Parent plugins could not be found.');
const ancestorPluginDefs = parentRequire('ep_etherpad-lite/static/js/pluginfw/plugin_defs');
const ancestorPluginDefs = parentRequire('ep_etherpad-lite/static/js/pluginfw/plugin_defs'); defs.hooks = ancestorPluginDefs.hooks;
defs.hooks = ancestorPluginDefs.hooks; defs.loaded = ancestorPluginDefs.loaded;
defs.loaded = ancestorPluginDefs.loaded; defs.parts = ancestorPluginDefs.parts;
defs.parts = ancestorPluginDefs.parts; defs.plugins = ancestorPluginDefs.plugins;
defs.plugins = ancestorPluginDefs.plugins; const ancestorPlugins = parentRequire('ep_etherpad-lite/static/js/pluginfw/client_plugins');
const ancestorPlugins = parentRequire('ep_etherpad-lite/static/js/pluginfw/client_plugins'); ancestorPlugins.baseURL;
exports.baseURL = ancestorPlugins.baseURL; ancestorPlugins.ensure;
exports.ensure = ancestorPlugins.ensure; ancestorPlugins.update;
exports.update = ancestorPlugins.update;
}; };
export const baseURL = '';
exports.adoptPluginsFromAncestorsOf = adoptPluginsFromAncestorsOf; export const ensure = (cb) => !defs.loaded ? exports.update(cb) : cb();
export const update = (cb) => {
// It appears that this response (see #620) may interrupt the current thread
// of execution on Firefox. This schedules the response in the run-loop,
// which appears to fix the issue.
const callback = () => setTimeout(cb, 0);
jQuery.getJSON(`${exports.baseURL}pluginfw/plugin-definitions.json?v=${clientVars.randomVersionString}`).done((data) => {
defs.plugins = data.plugins;
defs.parts = data.parts;
defs.hooks = pluginUtils.extractHooks(defs.parts, 'client_hooks');
defs.loaded = true;
callback();
}).fail((err) => {
console.error(`Failed to load plugin-definitions: ${err}`);
callback();
});
};
export { adoptPluginsFromAncestorsOf as baseURL };
export { adoptPluginsFromAncestorsOf as ensure };
export { adoptPluginsFromAncestorsOf as update };
export { adoptPluginsFromAncestorsOf };

View file

@ -2,4 +2,4 @@
// Provides a require'able version of jQuery without leaking $ and jQuery; // Provides a require'able version of jQuery without leaking $ and jQuery;
window.$ = require('./vendors/jquery'); window.$ = require('./vendors/jquery');
const jq = window.$.noConflict(true); const jq = window.$.noConflict(true);
exports.jQuery = exports.$ = jq; export { jq as $ };

View file

@ -1,351 +1,297 @@
import * as caretPosition from "./caretPosition.js";
'use strict'; 'use strict';
/*
This file handles scroll on edition or when user presses arrow keys.
In this file we have two representations of line (browser and rep line).
Rep Line = a line in the way is represented by Etherpad(rep) (each <div> is a line)
Browser Line = each vertical line. A <div> can be break into more than one
browser line.
*/
const caretPosition = require('./caretPosition');
function Scroll(outerWin) { function Scroll(outerWin) {
// scroll settings // scroll settings
this.scrollSettings = parent.parent.clientVars.scrollWhenFocusLineIsOutOfViewport; this.scrollSettings = parent.parent.clientVars.scrollWhenFocusLineIsOutOfViewport;
// DOM reference
// DOM reference this.outerWin = outerWin;
this.outerWin = outerWin; this.doc = this.outerWin.document;
this.doc = this.outerWin.document; this.rootDocument = parent.parent.document;
this.rootDocument = parent.parent.document;
} }
Scroll.prototype.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary = Scroll.prototype.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary =
function (rep, isScrollableEvent, innerHeight) { function (rep, isScrollableEvent, innerHeight) {
// are we placing the caret on the line at the bottom of viewport? // are we placing the caret on the line at the bottom of viewport?
// And if so, do we need to scroll the editor, as defined on the settings.json? // And if so, do we need to scroll the editor, as defined on the settings.json?
const shouldScrollWhenCaretIsAtBottomOfViewport = const shouldScrollWhenCaretIsAtBottomOfViewport = this.scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport;
this.scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport; if (shouldScrollWhenCaretIsAtBottomOfViewport) {
if (shouldScrollWhenCaretIsAtBottomOfViewport) { // avoid scrolling when selection includes multiple lines --
// avoid scrolling when selection includes multiple lines -- // user can potentially be selecting more lines
// user can potentially be selecting more lines // than it fits on viewport
// than it fits on viewport const multipleLinesSelected = rep.selStart[0] !== rep.selEnd[0];
const multipleLinesSelected = rep.selStart[0] !== rep.selEnd[0]; // avoid scrolling when pad loads
if (isScrollableEvent && !multipleLinesSelected && this._isCaretAtTheBottomOfViewport(rep)) {
// avoid scrolling when pad loads // when scrollWhenFocusLineIsOutOfViewport.percentage is 0, pixelsToScroll is 0
if (isScrollableEvent && !multipleLinesSelected && this._isCaretAtTheBottomOfViewport(rep)) { const pixelsToScroll = this._getPixelsRelativeToPercentageOfViewport(innerHeight);
// when scrollWhenFocusLineIsOutOfViewport.percentage is 0, pixelsToScroll is 0 this._scrollYPage(pixelsToScroll);
const pixelsToScroll = this._getPixelsRelativeToPercentageOfViewport(innerHeight); }
this._scrollYPage(pixelsToScroll); }
} };
}
};
Scroll.prototype.scrollWhenPressArrowKeys = function (arrowUp, rep, innerHeight) { Scroll.prototype.scrollWhenPressArrowKeys = function (arrowUp, rep, innerHeight) {
// if percentageScrollArrowUp is 0, let the scroll to be handled as default, put the previous // if percentageScrollArrowUp is 0, let the scroll to be handled as default, put the previous
// rep line on the top of the viewport // rep line on the top of the viewport
if (this._arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep)) { if (this._arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep)) {
const pixelsToScroll = this._getPixelsToScrollWhenUserPressesArrowUp(innerHeight); const pixelsToScroll = this._getPixelsToScrollWhenUserPressesArrowUp(innerHeight);
// by default, the browser scrolls to the middle of the viewport. To avoid the twist made
// by default, the browser scrolls to the middle of the viewport. To avoid the twist made // when we apply a second scroll, we made it immediately (without animation)
// when we apply a second scroll, we made it immediately (without animation) this._scrollYPageWithoutAnimation(-pixelsToScroll);
this._scrollYPageWithoutAnimation(-pixelsToScroll); }
} else { else {
this.scrollNodeVerticallyIntoView(rep, innerHeight); this.scrollNodeVerticallyIntoView(rep, innerHeight);
} }
}; };
// Some plugins might set a minimum height to the editor (ex: ep_page_view), so checking // Some plugins might set a minimum height to the editor (ex: ep_page_view), so checking
// if (caretLine() === rep.lines.length() - 1) is not enough. We need to check if there are // if (caretLine() === rep.lines.length() - 1) is not enough. We need to check if there are
// other lines after caretLine(), and all of them are out of viewport. // other lines after caretLine(), and all of them are out of viewport.
Scroll.prototype._isCaretAtTheBottomOfViewport = function (rep) { Scroll.prototype._isCaretAtTheBottomOfViewport = function (rep) {
// computing a line position using getBoundingClientRect() is expensive. // computing a line position using getBoundingClientRect() is expensive.
// (obs: getBoundingClientRect() is called on caretPosition.getPosition()) // (obs: getBoundingClientRect() is called on caretPosition.getPosition())
// To avoid that, we only call this function when it is possible that the // To avoid that, we only call this function when it is possible that the
// caret is in the bottom of viewport // caret is in the bottom of viewport
const caretLine = rep.selStart[0]; const caretLine = rep.selStart[0];
const lineAfterCaretLine = caretLine + 1; const lineAfterCaretLine = caretLine + 1;
const firstLineVisibleAfterCaretLine = caretPosition.getNextVisibleLine(lineAfterCaretLine, rep); const firstLineVisibleAfterCaretLine = caretPosition.getNextVisibleLine(lineAfterCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport = const caretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(caretLine, rep);
this._isLinePartiallyVisibleOnViewport(caretLine, rep); const lineAfterCaretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);
const lineAfterCaretLineIsPartiallyVisibleOnViewport = if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {
this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep); // check if the caret is in the bottom of the viewport
if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) { const caretLinePosition = caretPosition.getPosition();
// check if the caret is in the bottom of the viewport const viewportBottom = this._getViewPortTopBottom().bottom;
const caretLinePosition = caretPosition.getPosition(); const nextLineBottom = caretPosition.getBottomOfNextBrowserLine(caretLinePosition, rep);
const viewportBottom = this._getViewPortTopBottom().bottom; const nextLineIsBelowViewportBottom = nextLineBottom > viewportBottom;
const nextLineBottom = caretPosition.getBottomOfNextBrowserLine(caretLinePosition, rep); return nextLineIsBelowViewportBottom;
const nextLineIsBelowViewportBottom = nextLineBottom > viewportBottom; }
return nextLineIsBelowViewportBottom; return false;
}
return false;
}; };
Scroll.prototype._isLinePartiallyVisibleOnViewport = function (lineNumber, rep) { Scroll.prototype._isLinePartiallyVisibleOnViewport = function (lineNumber, rep) {
const lineNode = rep.lines.atIndex(lineNumber); const lineNode = rep.lines.atIndex(lineNumber);
const linePosition = this._getLineEntryTopBottom(lineNode); const linePosition = this._getLineEntryTopBottom(lineNode);
const lineTop = linePosition.top; const lineTop = linePosition.top;
const lineBottom = linePosition.bottom; const lineBottom = linePosition.bottom;
const viewport = this._getViewPortTopBottom(); const viewport = this._getViewPortTopBottom();
const viewportBottom = viewport.bottom; const viewportBottom = viewport.bottom;
const viewportTop = viewport.top; const viewportTop = viewport.top;
const topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom;
const topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom; const bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom;
const bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom; const topOfLineIsBelowViewportTop = lineTop >= viewportTop;
const topOfLineIsBelowViewportTop = lineTop >= viewportTop; const topOfLineIsAboveViewportBottom = lineTop <= viewportBottom;
const topOfLineIsAboveViewportBottom = lineTop <= viewportBottom; const bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom;
const bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom; const bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop;
const bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop; return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) ||
(topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) ||
return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) || (bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop);
(topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) ||
(bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop);
}; };
Scroll.prototype._getViewPortTopBottom = function () { Scroll.prototype._getViewPortTopBottom = function () {
const theTop = this.getScrollY(); const theTop = this.getScrollY();
const doc = this.doc; const doc = this.doc;
const height = doc.documentElement.clientHeight; // includes padding const height = doc.documentElement.clientHeight; // includes padding
// we have to get the exactly height of the viewport.
// we have to get the exactly height of the viewport. // So it has to subtract all the values which changes
// So it has to subtract all the values which changes // the viewport height (E.g. padding, position top)
// the viewport height (E.g. padding, position top) const viewportExtraSpacesAndPosition = this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable();
const viewportExtraSpacesAndPosition = return {
this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable(); top: theTop,
return { bottom: (theTop + height - viewportExtraSpacesAndPosition),
top: theTop, };
bottom: (theTop + height - viewportExtraSpacesAndPosition),
};
}; };
Scroll.prototype._getEditorPositionTop = function () { Scroll.prototype._getEditorPositionTop = function () {
const editor = parent.document.getElementsByTagName('iframe'); const editor = parent.document.getElementsByTagName('iframe');
const editorPositionTop = editor[0].offsetTop; const editorPositionTop = editor[0].offsetTop;
return editorPositionTop; return editorPositionTop;
}; };
// ep_page_view adds padding-top, which makes the viewport smaller // ep_page_view adds padding-top, which makes the viewport smaller
Scroll.prototype._getPaddingTopAddedWhenPageViewIsEnable = function () { Scroll.prototype._getPaddingTopAddedWhenPageViewIsEnable = function () {
const aceOuter = this.rootDocument.getElementsByName('ace_outer'); const aceOuter = this.rootDocument.getElementsByName('ace_outer');
const aceOuterPaddingTop = parseInt($(aceOuter).css('padding-top')); const aceOuterPaddingTop = parseInt($(aceOuter).css('padding-top'));
return aceOuterPaddingTop; return aceOuterPaddingTop;
}; };
Scroll.prototype._getScrollXY = function () { Scroll.prototype._getScrollXY = function () {
const win = this.outerWin; const win = this.outerWin;
const odoc = this.doc; const odoc = this.doc;
if (typeof (win.pageYOffset) === 'number') { if (typeof (win.pageYOffset) === 'number') {
return { return {
x: win.pageXOffset, x: win.pageXOffset,
y: win.pageYOffset, y: win.pageYOffset,
}; };
} }
const docel = odoc.documentElement; const docel = odoc.documentElement;
if (docel && typeof (docel.scrollTop) === 'number') { if (docel && typeof (docel.scrollTop) === 'number') {
return { return {
x: docel.scrollLeft, x: docel.scrollLeft,
y: docel.scrollTop, y: docel.scrollTop,
}; };
}
};
Scroll.prototype.getScrollX = function () {
return this._getScrollXY().x;
};
Scroll.prototype.getScrollY = function () {
return this._getScrollXY().y;
};
Scroll.prototype.setScrollX = function (x) {
this.outerWin.scrollTo(x, this.getScrollY());
};
Scroll.prototype.setScrollY = function (y) {
this.outerWin.scrollTo(this.getScrollX(), y);
};
Scroll.prototype.setScrollXY = function (x, y) {
this.outerWin.scrollTo(x, y);
};
Scroll.prototype._isCaretAtTheTopOfViewport = function (rep) {
const caretLine = rep.selStart[0];
const linePrevCaretLine = caretLine - 1;
const firstLineVisibleBeforeCaretLine =
caretPosition.getPreviousVisibleLine(linePrevCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineBeforeCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
const caretLinePosition = caretPosition.getPosition(); // get the position of the browser line
const viewportPosition = this._getViewPortTopBottom();
const viewportTop = viewportPosition.top;
const viewportBottom = viewportPosition.bottom;
const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop;
const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom;
const caretLineIsInsideOfViewport =
caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
if (caretLineIsInsideOfViewport) {
const prevLineTop = caretPosition.getPositionTopOfPreviousBrowserLine(caretLinePosition, rep);
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
return previousLineIsAboveViewportTop;
} }
}
return false;
}; };
Scroll.prototype.getScrollX = function () {
return this._getScrollXY().x;
};
Scroll.prototype.getScrollY = function () {
return this._getScrollXY().y;
};
Scroll.prototype.setScrollX = function (x) {
this.outerWin.scrollTo(x, this.getScrollY());
};
Scroll.prototype.setScrollY = function (y) {
this.outerWin.scrollTo(this.getScrollX(), y);
};
Scroll.prototype.setScrollXY = function (x, y) {
this.outerWin.scrollTo(x, y);
};
Scroll.prototype._isCaretAtTheTopOfViewport = function (rep) {
const caretLine = rep.selStart[0];
const linePrevCaretLine = caretLine - 1;
const firstLineVisibleBeforeCaretLine = caretPosition.getPreviousVisibleLine(linePrevCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineBeforeCaretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
const caretLinePosition = caretPosition.getPosition(); // get the position of the browser line
const viewportPosition = this._getViewPortTopBottom();
const viewportTop = viewportPosition.top;
const viewportBottom = viewportPosition.bottom;
const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop;
const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom;
const caretLineIsInsideOfViewport = caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
if (caretLineIsInsideOfViewport) {
const prevLineTop = caretPosition.getPositionTopOfPreviousBrowserLine(caretLinePosition, rep);
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
return previousLineIsAboveViewportTop;
}
}
return false;
};
// By default, when user makes an edition in a line out of viewport, this line goes // By default, when user makes an edition in a line out of viewport, this line goes
// to the edge of viewport. This function gets the extra pixels necessary to get the // to the edge of viewport. This function gets the extra pixels necessary to get the
// caret line in a position X relative to Y% viewport. // caret line in a position X relative to Y% viewport.
Scroll.prototype._getPixelsRelativeToPercentageOfViewport = Scroll.prototype._getPixelsRelativeToPercentageOfViewport =
function (innerHeight, aboveOfViewport) { function (innerHeight, aboveOfViewport) {
let pixels = 0; let pixels = 0;
const scrollPercentageRelativeToViewport = this._getPercentageToScroll(aboveOfViewport); const scrollPercentageRelativeToViewport = this._getPercentageToScroll(aboveOfViewport);
if (scrollPercentageRelativeToViewport > 0 && scrollPercentageRelativeToViewport <= 1) { if (scrollPercentageRelativeToViewport > 0 && scrollPercentageRelativeToViewport <= 1) {
pixels = parseInt(innerHeight * scrollPercentageRelativeToViewport); pixels = parseInt(innerHeight * scrollPercentageRelativeToViewport);
} }
return pixels; return pixels;
}; };
// we use different percentages when change selection. It depends on if it is // we use different percentages when change selection. It depends on if it is
// either above the top or below the bottom of the page // either above the top or below the bottom of the page
Scroll.prototype._getPercentageToScroll = function (aboveOfViewport) { Scroll.prototype._getPercentageToScroll = function (aboveOfViewport) {
let percentageToScroll = this.scrollSettings.percentage.editionBelowViewport; let percentageToScroll = this.scrollSettings.percentage.editionBelowViewport;
if (aboveOfViewport) { if (aboveOfViewport) {
percentageToScroll = this.scrollSettings.percentage.editionAboveViewport; percentageToScroll = this.scrollSettings.percentage.editionAboveViewport;
} }
return percentageToScroll; return percentageToScroll;
}; };
Scroll.prototype._getPixelsToScrollWhenUserPressesArrowUp = function (innerHeight) { Scroll.prototype._getPixelsToScrollWhenUserPressesArrowUp = function (innerHeight) {
let pixels = 0; let pixels = 0;
const percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp; const percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
if (percentageToScrollUp > 0 && percentageToScrollUp <= 1) { if (percentageToScrollUp > 0 && percentageToScrollUp <= 1) {
pixels = parseInt(innerHeight * percentageToScrollUp); pixels = parseInt(innerHeight * percentageToScrollUp);
} }
return pixels; return pixels;
}; };
Scroll.prototype._scrollYPage = function (pixelsToScroll) { Scroll.prototype._scrollYPage = function (pixelsToScroll) {
const durationOfAnimationToShowFocusline = this.scrollSettings.duration; const durationOfAnimationToShowFocusline = this.scrollSettings.duration;
if (durationOfAnimationToShowFocusline) { if (durationOfAnimationToShowFocusline) {
this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline); this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline);
} else { }
this._scrollYPageWithoutAnimation(pixelsToScroll); else {
} this._scrollYPageWithoutAnimation(pixelsToScroll);
}
}; };
Scroll.prototype._scrollYPageWithoutAnimation = function (pixelsToScroll) { Scroll.prototype._scrollYPageWithoutAnimation = function (pixelsToScroll) {
this.outerWin.scrollBy(0, pixelsToScroll); this.outerWin.scrollBy(0, pixelsToScroll);
}; };
Scroll.prototype._scrollYPageWithAnimation = Scroll.prototype._scrollYPageWithAnimation =
function (pixelsToScroll, durationOfAnimationToShowFocusline) { function (pixelsToScroll, durationOfAnimationToShowFocusline) {
const outerDocBody = this.doc.getElementById('outerdocbody'); const outerDocBody = this.doc.getElementById('outerdocbody');
// it works on later versions of Chrome
// it works on later versions of Chrome const $outerDocBody = $(outerDocBody);
const $outerDocBody = $(outerDocBody); this._triggerScrollWithAnimation($outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline);
this._triggerScrollWithAnimation( // it works on Firefox and earlier versions of Chrome
$outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline); const $outerDocBodyParent = $outerDocBody.parent();
this._triggerScrollWithAnimation($outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline);
// it works on Firefox and earlier versions of Chrome };
const $outerDocBodyParent = $outerDocBody.parent();
this._triggerScrollWithAnimation(
$outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline);
};
// using a custom queue and clearing it, we avoid creating a queue of scroll animations. // using a custom queue and clearing it, we avoid creating a queue of scroll animations.
// So if this function is called twice quickly, only the last one runs. // So if this function is called twice quickly, only the last one runs.
Scroll.prototype._triggerScrollWithAnimation = Scroll.prototype._triggerScrollWithAnimation =
function ($elem, pixelsToScroll, durationOfAnimationToShowFocusline) { function ($elem, pixelsToScroll, durationOfAnimationToShowFocusline) {
// clear the queue of animation // clear the queue of animation
$elem.stop('scrollanimation'); $elem.stop('scrollanimation');
$elem.animate({ $elem.animate({
scrollTop: `+=${pixelsToScroll}`, scrollTop: `+=${pixelsToScroll}`,
}, { }, {
duration: durationOfAnimationToShowFocusline, duration: durationOfAnimationToShowFocusline,
queue: 'scrollanimation', queue: 'scrollanimation',
}).dequeue('scrollanimation'); }).dequeue('scrollanimation');
}; };
// scrollAmountWhenFocusLineIsOutOfViewport is set to 0 (default), scroll it the minimum distance // scrollAmountWhenFocusLineIsOutOfViewport is set to 0 (default), scroll it the minimum distance
// needed to be completely in view. If the value is greater than 0 and less than or equal to 1, // needed to be completely in view. If the value is greater than 0 and less than or equal to 1,
// besides of scrolling the minimum needed to be visible, it scrolls additionally // besides of scrolling the minimum needed to be visible, it scrolls additionally
// (viewport height * scrollAmountWhenFocusLineIsOutOfViewport) pixels // (viewport height * scrollAmountWhenFocusLineIsOutOfViewport) pixels
Scroll.prototype.scrollNodeVerticallyIntoView = function (rep, innerHeight) { Scroll.prototype.scrollNodeVerticallyIntoView = function (rep, innerHeight) {
const viewport = this._getViewPortTopBottom(); const viewport = this._getViewPortTopBottom();
// when the selection changes outside of the viewport the browser automatically scrolls the line
// when the selection changes outside of the viewport the browser automatically scrolls the line // to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now
// to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now // So, when the line scrolled gets outside of the viewport we let the browser handle it.
// So, when the line scrolled gets outside of the viewport we let the browser handle it. const linePosition = caretPosition.getPosition();
const linePosition = caretPosition.getPosition(); if (linePosition) {
if (linePosition) { const distanceOfTopOfViewport = linePosition.top - viewport.top;
const distanceOfTopOfViewport = linePosition.top - viewport.top; const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height;
const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height; const caretIsAboveOfViewport = distanceOfTopOfViewport < 0;
const caretIsAboveOfViewport = distanceOfTopOfViewport < 0; const caretIsBelowOfViewport = distanceOfBottomOfViewport < 0;
const caretIsBelowOfViewport = distanceOfBottomOfViewport < 0; if (caretIsAboveOfViewport) {
if (caretIsAboveOfViewport) { const pixelsToScroll = distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true);
const pixelsToScroll = this._scrollYPage(pixelsToScroll);
distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true); }
this._scrollYPage(pixelsToScroll); else if (caretIsBelowOfViewport) {
} else if (caretIsBelowOfViewport) { // setTimeout is required here as line might not be fully rendered onto the pad
// setTimeout is required here as line might not be fully rendered onto the pad setTimeout(() => {
setTimeout(() => { const outer = window.parent;
const outer = window.parent; // scroll to the very end of the pad outer
// scroll to the very end of the pad outer outer.scrollTo(0, outer[0].innerHeight);
outer.scrollTo(0, outer[0].innerHeight); }, 150);
}, 150); // if the above setTimeout and functionality is removed then hitting an enter
// if the above setTimeout and functionality is removed then hitting an enter // key while on the last line wont be an optimal user experience
// key while on the last line wont be an optimal user experience // Details at: https://github.com/ether/etherpad-lite/pull/4639/files
// Details at: https://github.com/ether/etherpad-lite/pull/4639/files }
} }
}
}; };
Scroll.prototype._partOfRepLineIsOutOfViewport = function (viewportPosition, rep) { Scroll.prototype._partOfRepLineIsOutOfViewport = function (viewportPosition, rep) {
const focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]); const focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]);
const line = rep.lines.atIndex(focusLine); const line = rep.lines.atIndex(focusLine);
const linePosition = this._getLineEntryTopBottom(line); const linePosition = this._getLineEntryTopBottom(line);
const lineIsAboveOfViewport = linePosition.top < viewportPosition.top; const lineIsAboveOfViewport = linePosition.top < viewportPosition.top;
const lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom; const lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom;
return lineIsBelowOfViewport || lineIsAboveOfViewport;
return lineIsBelowOfViewport || lineIsAboveOfViewport;
}; };
Scroll.prototype._getLineEntryTopBottom = function (entry, destObj) { Scroll.prototype._getLineEntryTopBottom = function (entry, destObj) {
const dom = entry.lineNode; const dom = entry.lineNode;
const top = dom.offsetTop; const top = dom.offsetTop;
const height = dom.offsetHeight; const height = dom.offsetHeight;
const obj = (destObj || {}); const obj = (destObj || {});
obj.top = top; obj.top = top;
obj.bottom = (top + height); obj.bottom = (top + height);
return obj; return obj;
}; };
Scroll.prototype._arrowUpWasPressedInTheFirstLineOfTheViewport = function (arrowUp, rep) { Scroll.prototype._arrowUpWasPressedInTheFirstLineOfTheViewport = function (arrowUp, rep) {
const percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp; const percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep); return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep);
}; };
Scroll.prototype.getVisibleLineRange = function (rep) { Scroll.prototype.getVisibleLineRange = function (rep) {
const viewport = this._getViewPortTopBottom(); const viewport = this._getViewPortTopBottom();
// console.log("viewport top/bottom: %o", viewport); // console.log("viewport top/bottom: %o", viewport);
const obj = {}; const obj = {};
const self = this; const self = this;
const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).bottom > viewport.top); const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).bottom > viewport.top);
// return the first line that the top position is greater or equal than // return the first line that the top position is greater or equal than
// the viewport. That is the first line that is below the viewport bottom. // the viewport. That is the first line that is below the viewport bottom.
// So the line that is in the bottom of the viewport is the very previous one. // So the line that is in the bottom of the viewport is the very previous one.
let end = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top >= viewport.bottom); let end = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top >= viewport.bottom);
if (end < start) end = start; // unlikely if (end < start)
// top.console.log(start+","+(end -1)); end = start; // unlikely
return [start, end - 1]; // top.console.log(start+","+(end -1));
return [start, end - 1];
}; };
Scroll.prototype.getVisibleCharRange = function (rep) { Scroll.prototype.getVisibleCharRange = function (rep) {
const lineRange = this.getVisibleLineRange(rep); const lineRange = this.getVisibleLineRange(rep);
return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])]; return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])];
}; };
export const init = (outerWin) => new Scroll(outerWin);
exports.init = (outerWin) => new Scroll(outerWin);

View file

@ -1,19 +1,2 @@
'use strict'; 'use strict';
export * from "security";
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
module.exports = require('security');

View file

@ -1,55 +1,45 @@
'use strict'; 'use strict';
// Specific hash to display the skin variants builder popup // Specific hash to display the skin variants builder popup
if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') { if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') {
$('#skin-variants').addClass('popup-show'); $('#skin-variants').addClass('popup-show');
const containers = ['editor', 'background', 'toolbar'];
const containers = ['editor', 'background', 'toolbar']; const colors = ['super-light', 'light', 'dark', 'super-dark'];
const colors = ['super-light', 'light', 'dark', 'super-dark']; // add corresponding classes when config change
const updateSkinVariantsClasses = () => {
// add corresponding classes when config change const domsToUpdate = [
const updateSkinVariantsClasses = () => { $('html'),
const domsToUpdate = [ $('iframe[name=ace_outer]').contents().find('html'),
$('html'), $('iframe[name=ace_outer]').contents().find('iframe[name=ace_inner]').contents().find('html'),
$('iframe[name=ace_outer]').contents().find('html'), ];
$('iframe[name=ace_outer]').contents().find('iframe[name=ace_inner]').contents().find('html'), colors.forEach((color) => {
]; containers.forEach((container) => {
colors.forEach((color) => { domsToUpdate.forEach((el) => { el.removeClass(`${color}-${container}`); });
containers.forEach((container) => { });
domsToUpdate.forEach((el) => { el.removeClass(`${color}-${container}`); }); });
}); domsToUpdate.forEach((el) => { el.removeClass('full-width-editor'); });
const newClasses = [];
$('select.skin-variant-color').each(function () {
newClasses.push(`${$(this).val()}-${$(this).data('container')}`);
});
if ($('#skin-variant-full-width').is(':checked'))
newClasses.push('full-width-editor');
domsToUpdate.forEach((el) => { el.addClass(newClasses.join(' ')); });
$('#skin-variants-result').val(`"skinVariants": "${newClasses.join(' ')}",`);
};
// run on init
const updateCheckboxFromSkinClasses = () => {
$('html').attr('class').split(' ').forEach((classItem) => {
const container = classItem.substring(classItem.lastIndexOf('-') + 1, classItem.length);
if (containers.indexOf(container) > -1) {
const color = classItem.substring(0, classItem.lastIndexOf('-'));
$(`.skin-variant-color[data-container="${container}"`).val(color);
}
});
$('#skin-variant-full-width').prop('checked', $('html').hasClass('full-width-editor'));
};
$('.skin-variant').change(() => {
updateSkinVariantsClasses();
}); });
updateCheckboxFromSkinClasses();
domsToUpdate.forEach((el) => { el.removeClass('full-width-editor'); });
const newClasses = [];
$('select.skin-variant-color').each(function () {
newClasses.push(`${$(this).val()}-${$(this).data('container')}`);
});
if ($('#skin-variant-full-width').is(':checked')) newClasses.push('full-width-editor');
domsToUpdate.forEach((el) => { el.addClass(newClasses.join(' ')); });
$('#skin-variants-result').val(`"skinVariants": "${newClasses.join(' ')}",`);
};
// run on init
const updateCheckboxFromSkinClasses = () => {
$('html').attr('class').split(' ').forEach((classItem) => {
const container = classItem.substring(classItem.lastIndexOf('-') + 1, classItem.length);
if (containers.indexOf(container) > -1) {
const color = classItem.substring(0, classItem.lastIndexOf('-'));
$(`.skin-variant-color[data-container="${container}"`).val(color);
}
});
$('#skin-variant-full-width').prop('checked', $('html').hasClass('full-width-editor'));
};
$('.skin-variant').change(() => {
updateSkinVariantsClasses(); updateSkinVariantsClasses();
});
updateCheckboxFromSkinClasses();
updateSkinVariantsClasses();
} }

View file

@ -1,11 +1,9 @@
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it. * This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -21,310 +19,310 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const _entryWidth = (e) => (e && e.width) || 0; const _entryWidth = (e) => (e && e.width) || 0;
class Node { class Node {
constructor(entry, levels = 0, downSkips = 1, downSkipWidths = 0) { constructor(entry, levels = 0, downSkips = 1, downSkipWidths = 0) {
this.key = entry != null ? entry.key : null; this.key = entry != null ? entry.key : null;
this.entry = entry; this.entry = entry;
this.levels = levels; this.levels = levels;
this.upPtrs = Array(levels).fill(null); this.upPtrs = Array(levels).fill(null);
this.downPtrs = Array(levels).fill(null); this.downPtrs = Array(levels).fill(null);
this.downSkips = Array(levels).fill(downSkips); this.downSkips = Array(levels).fill(downSkips);
this.downSkipWidths = Array(levels).fill(downSkipWidths); this.downSkipWidths = Array(levels).fill(downSkipWidths);
} }
propagateWidthChange() {
propagateWidthChange() { const oldWidth = this.downSkipWidths[0];
const oldWidth = this.downSkipWidths[0]; const newWidth = _entryWidth(this.entry);
const newWidth = _entryWidth(this.entry); const widthChange = newWidth - oldWidth;
const widthChange = newWidth - oldWidth; let n = this;
let n = this; let lvl = 0;
let lvl = 0; while (lvl < n.levels) {
while (lvl < n.levels) { n.downSkipWidths[lvl] += widthChange;
n.downSkipWidths[lvl] += widthChange; lvl++;
lvl++; while (lvl >= n.levels && n.upPtrs[lvl - 1]) {
while (lvl >= n.levels && n.upPtrs[lvl - 1]) { n = n.upPtrs[lvl - 1];
n = n.upPtrs[lvl - 1]; }
} }
return widthChange;
} }
return widthChange;
}
} }
// A "point" object at index x allows modifications immediately after the first x elements of the // A "point" object at index x allows modifications immediately after the first x elements of the
// skiplist, such as multiple inserts or deletes. After an insert or delete using point P, the point // skiplist, such as multiple inserts or deletes. After an insert or delete using point P, the point
// is still valid and points to the same index in the skiplist. Other operations with other points // is still valid and points to the same index in the skiplist. Other operations with other points
// invalidate this point. // invalidate this point.
class Point { class Point {
constructor(skipList, loc) { constructor(skipList, loc) {
this._skipList = skipList; this._skipList = skipList;
this.loc = loc; this.loc = loc;
const numLevels = this._skipList._start.levels; const numLevels = this._skipList._start.levels;
let lvl = numLevels - 1; let lvl = numLevels - 1;
let i = -1; let i = -1;
let ws = 0; let ws = 0;
const nodes = new Array(numLevels); const nodes = new Array(numLevels);
const idxs = new Array(numLevels); const idxs = new Array(numLevels);
const widthSkips = new Array(numLevels); const widthSkips = new Array(numLevels);
nodes[lvl] = this._skipList._start; nodes[lvl] = this._skipList._start;
idxs[lvl] = -1; idxs[lvl] = -1;
widthSkips[lvl] = 0; widthSkips[lvl] = 0;
while (lvl >= 0) { while (lvl >= 0) {
let n = nodes[lvl]; let n = nodes[lvl];
while (n.downPtrs[lvl] && (i + n.downSkips[lvl] < this.loc)) { while (n.downPtrs[lvl] && (i + n.downSkips[lvl] < this.loc)) {
i += n.downSkips[lvl]; i += n.downSkips[lvl];
ws += n.downSkipWidths[lvl]; ws += n.downSkipWidths[lvl];
n = n.downPtrs[lvl]; n = n.downPtrs[lvl];
} }
nodes[lvl] = n; nodes[lvl] = n;
idxs[lvl] = i; idxs[lvl] = i;
widthSkips[lvl] = ws; widthSkips[lvl] = ws;
lvl--; lvl--;
if (lvl >= 0) { if (lvl >= 0) {
nodes[lvl] = n; nodes[lvl] = n;
} }
}
this.idxs = idxs;
this.nodes = nodes;
this.widthSkips = widthSkips;
} }
this.idxs = idxs; toString() {
this.nodes = nodes; return `Point(${this.loc})`;
this.widthSkips = widthSkips;
}
toString() {
return `Point(${this.loc})`;
}
insert(entry) {
if (entry.key == null) throw new Error('entry.key must not be null');
if (this._skipList.containsKey(entry.key)) {
throw new Error(`an entry with key ${entry.key} already exists`);
} }
insert(entry) {
const newNode = new Node(entry); if (entry.key == null)
const pNodes = this.nodes; throw new Error('entry.key must not be null');
const pIdxs = this.idxs; if (this._skipList.containsKey(entry.key)) {
const pLoc = this.loc; throw new Error(`an entry with key ${entry.key} already exists`);
const widthLoc = this.widthSkips[0] + this.nodes[0].downSkipWidths[0]; }
const newWidth = _entryWidth(entry); const newNode = new Node(entry);
const pNodes = this.nodes;
// The new node will have at least level 1 const pIdxs = this.idxs;
// With a proability of 0.01^(n-1) the nodes level will be >= n const pLoc = this.loc;
while (newNode.levels === 0 || Math.random() < 0.01) { const widthLoc = this.widthSkips[0] + this.nodes[0].downSkipWidths[0];
const lvl = newNode.levels; const newWidth = _entryWidth(entry);
newNode.levels++; // The new node will have at least level 1
if (lvl === pNodes.length) { // With a proability of 0.01^(n-1) the nodes level will be >= n
// assume we have just passed the end of this.nodes, and reached one level greater while (newNode.levels === 0 || Math.random() < 0.01) {
// than the skiplist currently supports const lvl = newNode.levels;
pNodes[lvl] = this._skipList._start; newNode.levels++;
pIdxs[lvl] = -1; if (lvl === pNodes.length) {
this._skipList._start.levels++; // assume we have just passed the end of this.nodes, and reached one level greater
this._skipList._end.levels++; // than the skiplist currently supports
this._skipList._start.downPtrs[lvl] = this._skipList._end; pNodes[lvl] = this._skipList._start;
this._skipList._end.upPtrs[lvl] = this._skipList._start; pIdxs[lvl] = -1;
this._skipList._start.downSkips[lvl] = this._skipList._keyToNodeMap.size + 1; this._skipList._start.levels++;
this._skipList._start.downSkipWidths[lvl] = this._skipList._totalWidth; this._skipList._end.levels++;
this.widthSkips[lvl] = 0; this._skipList._start.downPtrs[lvl] = this._skipList._end;
} this._skipList._end.upPtrs[lvl] = this._skipList._start;
const me = newNode; this._skipList._start.downSkips[lvl] = this._skipList._keyToNodeMap.size + 1;
const up = pNodes[lvl]; this._skipList._start.downSkipWidths[lvl] = this._skipList._totalWidth;
const down = up.downPtrs[lvl]; this.widthSkips[lvl] = 0;
const skip1 = pLoc - pIdxs[lvl]; }
const skip2 = up.downSkips[lvl] + 1 - skip1; const me = newNode;
up.downSkips[lvl] = skip1; const up = pNodes[lvl];
up.downPtrs[lvl] = me; const down = up.downPtrs[lvl];
me.downSkips[lvl] = skip2; const skip1 = pLoc - pIdxs[lvl];
me.upPtrs[lvl] = up; const skip2 = up.downSkips[lvl] + 1 - skip1;
me.downPtrs[lvl] = down; up.downSkips[lvl] = skip1;
down.upPtrs[lvl] = me; up.downPtrs[lvl] = me;
const widthSkip1 = widthLoc - this.widthSkips[lvl]; me.downSkips[lvl] = skip2;
const widthSkip2 = up.downSkipWidths[lvl] + newWidth - widthSkip1; me.upPtrs[lvl] = up;
up.downSkipWidths[lvl] = widthSkip1; me.downPtrs[lvl] = down;
me.downSkipWidths[lvl] = widthSkip2; down.upPtrs[lvl] = me;
const widthSkip1 = widthLoc - this.widthSkips[lvl];
const widthSkip2 = up.downSkipWidths[lvl] + newWidth - widthSkip1;
up.downSkipWidths[lvl] = widthSkip1;
me.downSkipWidths[lvl] = widthSkip2;
}
for (let lvl = newNode.levels; lvl < pNodes.length; lvl++) {
const up = pNodes[lvl];
up.downSkips[lvl]++;
up.downSkipWidths[lvl] += newWidth;
}
this._skipList._keyToNodeMap.set(newNode.key, newNode);
this._skipList._totalWidth += newWidth;
} }
for (let lvl = newNode.levels; lvl < pNodes.length; lvl++) { delete() {
const up = pNodes[lvl]; const elem = this.nodes[0].downPtrs[0];
up.downSkips[lvl]++; const elemWidth = _entryWidth(elem.entry);
up.downSkipWidths[lvl] += newWidth; for (let i = 0; i < this.nodes.length; i++) {
if (i < elem.levels) {
const up = elem.upPtrs[i];
const down = elem.downPtrs[i];
const totalSkip = up.downSkips[i] + elem.downSkips[i] - 1;
up.downPtrs[i] = down;
down.upPtrs[i] = up;
up.downSkips[i] = totalSkip;
const totalWidthSkip = up.downSkipWidths[i] + elem.downSkipWidths[i] - elemWidth;
up.downSkipWidths[i] = totalWidthSkip;
}
else {
const up = this.nodes[i];
up.downSkips[i]--;
up.downSkipWidths[i] -= elemWidth;
}
}
this._skipList._keyToNodeMap.delete(elem.key);
this._skipList._totalWidth -= elemWidth;
} }
this._skipList._keyToNodeMap.set(newNode.key, newNode); getNode() {
this._skipList._totalWidth += newWidth; return this.nodes[0].downPtrs[0];
}
delete() {
const elem = this.nodes[0].downPtrs[0];
const elemWidth = _entryWidth(elem.entry);
for (let i = 0; i < this.nodes.length; i++) {
if (i < elem.levels) {
const up = elem.upPtrs[i];
const down = elem.downPtrs[i];
const totalSkip = up.downSkips[i] + elem.downSkips[i] - 1;
up.downPtrs[i] = down;
down.upPtrs[i] = up;
up.downSkips[i] = totalSkip;
const totalWidthSkip = up.downSkipWidths[i] + elem.downSkipWidths[i] - elemWidth;
up.downSkipWidths[i] = totalWidthSkip;
} else {
const up = this.nodes[i];
up.downSkips[i]--;
up.downSkipWidths[i] -= elemWidth;
}
} }
this._skipList._keyToNodeMap.delete(elem.key);
this._skipList._totalWidth -= elemWidth;
}
getNode() {
return this.nodes[0].downPtrs[0];
}
} }
/** /**
* The skip-list contains "entries", JavaScript objects that each must have a unique "key" * The skip-list contains "entries", JavaScript objects that each must have a unique "key"
* property that is a string. * property that is a string.
*/ */
class SkipList { class SkipList {
constructor() { constructor() {
// if there are N elements in the skiplist, "start" is element -1 and "end" is element N // if there are N elements in the skiplist, "start" is element -1 and "end" is element N
this._start = new Node(null, 1); this._start = new Node(null, 1);
this._end = new Node(null, 1, null, null); this._end = new Node(null, 1, null, null);
this._totalWidth = 0; this._totalWidth = 0;
this._keyToNodeMap = new Map(); this._keyToNodeMap = new Map();
this._start.downPtrs[0] = this._end; this._start.downPtrs[0] = this._end;
this._end.upPtrs[0] = this._start; this._end.upPtrs[0] = this._start;
}
_getNodeAtOffset(targetOffset) {
let i = 0;
let n = this._start;
let lvl = this._start.levels - 1;
while (lvl >= 0 && n.downPtrs[lvl]) {
while (n.downPtrs[lvl] && (i + n.downSkipWidths[lvl] <= targetOffset)) {
i += n.downSkipWidths[lvl];
n = n.downPtrs[lvl];
}
lvl--;
} }
if (n === this._start) return (this._start.downPtrs[0] || null); _getNodeAtOffset(targetOffset) {
if (n === this._end) { let i = 0;
return targetOffset === this._totalWidth ? (this._end.upPtrs[0] || null) : null; let n = this._start;
let lvl = this._start.levels - 1;
while (lvl >= 0 && n.downPtrs[lvl]) {
while (n.downPtrs[lvl] && (i + n.downSkipWidths[lvl] <= targetOffset)) {
i += n.downSkipWidths[lvl];
n = n.downPtrs[lvl];
}
lvl--;
}
if (n === this._start)
return (this._start.downPtrs[0] || null);
if (n === this._end) {
return targetOffset === this._totalWidth ? (this._end.upPtrs[0] || null) : null;
}
return n;
} }
return n; _getNodeIndex(node, byWidth) {
} let dist = (byWidth ? 0 : -1);
let n = node;
_getNodeIndex(node, byWidth) { while (n !== this._start) {
let dist = (byWidth ? 0 : -1); const lvl = n.levels - 1;
let n = node; n = n.upPtrs[lvl];
while (n !== this._start) { if (byWidth)
const lvl = n.levels - 1; dist += n.downSkipWidths[lvl];
n = n.upPtrs[lvl]; else
if (byWidth) dist += n.downSkipWidths[lvl]; dist += n.downSkips[lvl];
else dist += n.downSkips[lvl]; }
return dist;
} }
return dist; // Returns index of first entry such that entryFunc(entry) is truthy,
} // or length() if no such entry. Assumes all falsy entries come before
// all truthy entries.
// Returns index of first entry such that entryFunc(entry) is truthy, search(entryFunc) {
// or length() if no such entry. Assumes all falsy entries come before let low = this._start;
// all truthy entries. let lvl = this._start.levels - 1;
search(entryFunc) { let lowIndex = -1;
let low = this._start; const f = (node) => {
let lvl = this._start.levels - 1; if (node === this._start)
let lowIndex = -1; return false;
else if (node === this._end)
const f = (node) => { return true;
if (node === this._start) return false; else
else if (node === this._end) return true; return entryFunc(node.entry);
else return entryFunc(node.entry); };
}; while (lvl >= 0) {
let nextLow = low.downPtrs[lvl];
while (lvl >= 0) { while (!f(nextLow)) {
let nextLow = low.downPtrs[lvl]; lowIndex += low.downSkips[lvl];
while (!f(nextLow)) { low = nextLow;
lowIndex += low.downSkips[lvl]; nextLow = low.downPtrs[lvl];
low = nextLow; }
nextLow = low.downPtrs[lvl]; lvl--;
} }
lvl--; return lowIndex + 1;
} }
return lowIndex + 1; length() { return this._keyToNodeMap.size; }
} atIndex(i) {
if (i < 0)
length() { return this._keyToNodeMap.size; } console.warn(`atIndex(${i})`);
if (i >= this._keyToNodeMap.size)
atIndex(i) { console.warn(`atIndex(${i}>=${this._keyToNodeMap.size})`);
if (i < 0) console.warn(`atIndex(${i})`); return (new Point(this, i)).getNode().entry;
if (i >= this._keyToNodeMap.size) console.warn(`atIndex(${i}>=${this._keyToNodeMap.size})`);
return (new Point(this, i)).getNode().entry;
}
// differs from Array.splice() in that new elements are in an array, not varargs
splice(start, deleteCount, newEntryArray) {
if (start < 0) console.warn(`splice(${start}, ...)`);
if (start + deleteCount > this._keyToNodeMap.size) {
console.warn(`splice(${start}, ${deleteCount}, ...), N=${this._keyToNodeMap.size}`);
console.warn('%s %s %s', typeof start, typeof deleteCount, typeof this._keyToNodeMap.size);
console.trace();
} }
// differs from Array.splice() in that new elements are in an array, not varargs
if (!newEntryArray) newEntryArray = []; splice(start, deleteCount, newEntryArray) {
const pt = new Point(this, start); if (start < 0)
for (let i = 0; i < deleteCount; i++) pt.delete(); console.warn(`splice(${start}, ...)`);
for (let i = (newEntryArray.length - 1); i >= 0; i--) { if (start + deleteCount > this._keyToNodeMap.size) {
const entry = newEntryArray[i]; console.warn(`splice(${start}, ${deleteCount}, ...), N=${this._keyToNodeMap.size}`);
pt.insert(entry); console.warn('%s %s %s', typeof start, typeof deleteCount, typeof this._keyToNodeMap.size);
console.trace();
}
if (!newEntryArray)
newEntryArray = [];
const pt = new Point(this, start);
for (let i = 0; i < deleteCount; i++)
pt.delete();
for (let i = (newEntryArray.length - 1); i >= 0; i--) {
const entry = newEntryArray[i];
pt.insert(entry);
}
} }
} next(entry) { return this._keyToNodeMap.get(entry.key).downPtrs[0].entry || null; }
prev(entry) { return this._keyToNodeMap.get(entry.key).upPtrs[0].entry || null; }
next(entry) { return this._keyToNodeMap.get(entry.key).downPtrs[0].entry || null; } push(entry) { this.splice(this._keyToNodeMap.size, 0, [entry]); }
prev(entry) { return this._keyToNodeMap.get(entry.key).upPtrs[0].entry || null; } slice(start, end) {
push(entry) { this.splice(this._keyToNodeMap.size, 0, [entry]); } // act like Array.slice()
if (start === undefined)
slice(start, end) { start = 0;
// act like Array.slice() else if (start < 0)
if (start === undefined) start = 0; start += this._keyToNodeMap.size;
else if (start < 0) start += this._keyToNodeMap.size; if (end === undefined)
if (end === undefined) end = this._keyToNodeMap.size; end = this._keyToNodeMap.size;
else if (end < 0) end += this._keyToNodeMap.size; else if (end < 0)
end += this._keyToNodeMap.size;
if (start < 0) start = 0; if (start < 0)
if (start > this._keyToNodeMap.size) start = this._keyToNodeMap.size; start = 0;
if (end < 0) end = 0; if (start > this._keyToNodeMap.size)
if (end > this._keyToNodeMap.size) end = this._keyToNodeMap.size; start = this._keyToNodeMap.size;
if (end < 0)
if (end <= start) return []; end = 0;
let n = this.atIndex(start); if (end > this._keyToNodeMap.size)
const array = [n]; end = this._keyToNodeMap.size;
for (let i = 1; i < (end - start); i++) { if (end <= start)
n = this.next(n); return [];
array.push(n); let n = this.atIndex(start);
const array = [n];
for (let i = 1; i < (end - start); i++) {
n = this.next(n);
array.push(n);
}
return array;
}
atKey(key) { return this._keyToNodeMap.get(key).entry; }
indexOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key)); }
indexOfEntry(entry) { return this.indexOfKey(entry.key); }
containsKey(key) { return this._keyToNodeMap.has(key); }
// gets the last entry starting at or before the offset
atOffset(offset) { return this._getNodeAtOffset(offset).entry; }
keyAtOffset(offset) { return this.atOffset(offset).key; }
offsetOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key), true); }
offsetOfEntry(entry) { return this.offsetOfKey(entry.key); }
setEntryWidth(entry, width) {
entry.width = width;
this._totalWidth += this._keyToNodeMap.get(entry.key).propagateWidthChange();
}
totalWidth() { return this._totalWidth; }
offsetOfIndex(i) {
if (i < 0)
return 0;
if (i >= this._keyToNodeMap.size)
return this._totalWidth;
return this.offsetOfEntry(this.atIndex(i));
}
indexOfOffset(offset) {
if (offset <= 0)
return 0;
if (offset >= this._totalWidth)
return this._keyToNodeMap.size;
return this.indexOfEntry(this.atOffset(offset));
} }
return array;
}
atKey(key) { return this._keyToNodeMap.get(key).entry; }
indexOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key)); }
indexOfEntry(entry) { return this.indexOfKey(entry.key); }
containsKey(key) { return this._keyToNodeMap.has(key); }
// gets the last entry starting at or before the offset
atOffset(offset) { return this._getNodeAtOffset(offset).entry; }
keyAtOffset(offset) { return this.atOffset(offset).key; }
offsetOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key), true); }
offsetOfEntry(entry) { return this.offsetOfKey(entry.key); }
setEntryWidth(entry, width) {
entry.width = width;
this._totalWidth += this._keyToNodeMap.get(entry.key).propagateWidthChange();
}
totalWidth() { return this._totalWidth; }
offsetOfIndex(i) {
if (i < 0) return 0;
if (i >= this._keyToNodeMap.size) return this._totalWidth;
return this.offsetOfEntry(this.atIndex(i));
}
indexOfOffset(offset) {
if (offset <= 0) return 0;
if (offset >= this._totalWidth) return this._keyToNodeMap.size;
return this.indexOfEntry(this.atOffset(offset));
}
} }
export default SkipList;
module.exports = SkipList;

View file

@ -1,5 +1,4 @@
'use strict'; 'use strict';
/** /**
* Creates a socket.io connection. * Creates a socket.io connection.
* @param etherpadBaseUrl - Etherpad URL. If relative, it is assumed to be relative to * @param etherpadBaseUrl - Etherpad URL. If relative, it is assumed to be relative to
@ -10,20 +9,20 @@
* @return socket.io Socket object * @return socket.io Socket object
*/ */
const connect = (etherpadBaseUrl, namespace = '/', options = {}) => { const connect = (etherpadBaseUrl, namespace = '/', options = {}) => {
// The API for socket.io's io() function is awkward. The documentation says that the first // The API for socket.io's io() function is awkward. The documentation says that the first
// argument is a URL, but it is not the URL of the socket.io endpoint. The URL's path part is used // argument is a URL, but it is not the URL of the socket.io endpoint. The URL's path part is used
// as the name of the socket.io namespace to join, and the rest of the URL (including query // as the name of the socket.io namespace to join, and the rest of the URL (including query
// parameters, if present) is combined with the `path` option (which defaults to '/socket.io', but // parameters, if present) is combined with the `path` option (which defaults to '/socket.io', but
// is overridden here to allow users to host Etherpad at something like '/etherpad') to get the // is overridden here to allow users to host Etherpad at something like '/etherpad') to get the
// URL of the socket.io endpoint. // URL of the socket.io endpoint.
const baseUrl = new URL(etherpadBaseUrl, window.location); const baseUrl = new URL(etherpadBaseUrl, window.location);
const socketioUrl = new URL('socket.io', baseUrl); const socketioUrl = new URL('socket.io', baseUrl);
const namespaceUrl = new URL(namespace, new URL('/', baseUrl)); const namespaceUrl = new URL(namespace, new URL('/', baseUrl));
return io(namespaceUrl.href, Object.assign({path: socketioUrl.pathname}, options)); return io(namespaceUrl.href, Object.assign({ path: socketioUrl.pathname }, options));
}; };
if (typeof exports === 'object') { if (typeof exports === 'object') {
exports.connect = connect;
} else {
window.socketio = {connect};
} }
else {
window.socketio = { connect };
}
export { connect };

View file

@ -1,168 +1,124 @@
import "./vendors/jquery.js";
import * as padUtils from "./pad_utils.js";
import * as hooks from "./pluginfw/hooks.js";
import * as socketio from "./socketio.js";
import { loadBroadcastRevisionsJS } from "./broadcast_revisions.js";
import { padimpexp } from "./pad_impexp.js";
'use strict'; 'use strict';
const Cookies = { Cookies: padUtils }.Cookies;
/** const randomString = { randomString: padUtils }.randomString;
* This code is mostly from the old Etherpad. Please help us to comment this code. const padutils = { padutils: padUtils }.padutils;
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// These jQuery things should create local references, but for now `require()`
// assigns to the global `$` and augments it with plugins.
require('./vendors/jquery');
const Cookies = require('./pad_utils').Cookies;
const randomString = require('./pad_utils').randomString;
const hooks = require('./pluginfw/hooks');
const padutils = require('./pad_utils').padutils;
const socketio = require('./socketio');
let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider; let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider;
const init = () => { const init = () => {
padutils.setupGlobalExceptionHandler(); padutils.setupGlobalExceptionHandler();
$(document).ready(() => { $(document).ready(() => {
// start the custom js // start the custom js
if (typeof customStart === 'function') customStart(); // eslint-disable-line no-undef if (typeof customStart === 'function')
customStart(); // eslint-disable-line no-undef
// get the padId out of the url // get the padId out of the url
const urlParts = document.location.pathname.split('/'); const urlParts = document.location.pathname.split('/');
padId = decodeURIComponent(urlParts[urlParts.length - 2]); padId = decodeURIComponent(urlParts[urlParts.length - 2]);
// set the title
// set the title document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`;
document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`; // ensure we have a token
token = Cookies.get('token');
// ensure we have a token if (token == null) {
token = Cookies.get('token'); token = `t.${randomString()}`;
if (token == null) { Cookies.set('token', token, { expires: 60 });
token = `t.${randomString()}`; }
Cookies.set('token', token, {expires: 60}); socket = socketio.connect(exports.baseURL, '/', { query: { padId } });
} // send the ready message once we're connected
socket.on('connect', () => {
socket = socketio.connect(exports.baseURL, '/', {query: {padId}}); sendSocketMsg('CLIENT_READY', {});
});
// send the ready message once we're connected socket.on('disconnect', (reason) => {
socket.on('connect', () => { BroadcastSlider.showReconnectUI();
sendSocketMsg('CLIENT_READY', {}); // The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
if (reason === 'io server disconnect')
socket.connect();
});
// route the incoming messages
socket.on('message', (message) => {
if (message.type === 'CLIENT_VARS') {
handleClientVars(message);
}
else if (message.accessStatus) {
$('body').html('<h2>You have no permission to access this pad</h2>');
}
else if (message.type === 'CHANGESET_REQ' || message.type === 'COLLABROOM') {
changesetLoader.handleMessageFromServer(message);
}
});
// get all the export links
exportLinks = $('#export > .exportlink');
$('button#forcereconnect').click(() => {
window.location.reload();
});
socket; // make the socket available
BroadcastSlider; // Make the slider available
hooks.aCallAll('postTimesliderInit');
}); });
socket.on('disconnect', (reason) => {
BroadcastSlider.showReconnectUI();
// The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
if (reason === 'io server disconnect') socket.connect();
});
// route the incoming messages
socket.on('message', (message) => {
if (message.type === 'CLIENT_VARS') {
handleClientVars(message);
} else if (message.accessStatus) {
$('body').html('<h2>You have no permission to access this pad</h2>');
} else if (message.type === 'CHANGESET_REQ' || message.type === 'COLLABROOM') {
changesetLoader.handleMessageFromServer(message);
}
});
// get all the export links
exportLinks = $('#export > .exportlink');
$('button#forcereconnect').click(() => {
window.location.reload();
});
exports.socket = socket; // make the socket available
exports.BroadcastSlider = BroadcastSlider; // Make the slider available
hooks.aCallAll('postTimesliderInit');
});
}; };
// sends a message over the socket // sends a message over the socket
const sendSocketMsg = (type, data) => { const sendSocketMsg = (type, data) => {
socket.json.send({ socket.json.send({
component: 'pad', // FIXME: Remove this stupidity! component: 'pad',
type, type,
data, data,
padId, padId,
token, token,
sessionID: Cookies.get('sessionID'), sessionID: Cookies.get('sessionID'),
});
};
const fireWhenAllScriptsAreLoaded = [];
const handleClientVars = (message) => {
// save the client Vars
window.clientVars = message.data;
if (window.clientVars.sessionRefreshInterval) {
const ping =
() => $.ajax('../../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {});
setInterval(ping, window.clientVars.sessionRefreshInterval);
}
// load all script that doesn't work without the clientVars
BroadcastSlider = require('./broadcast_slider')
.loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded);
require('./broadcast_revisions').loadBroadcastRevisionsJS();
changesetLoader = require('./broadcast')
.loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider);
// initialize export ui
require('./pad_impexp').padimpexp.init();
// Create a base URI used for timeslider exports
const baseURI = document.location.pathname;
// change export urls when the slider moves
BroadcastSlider.onSlider((revno) => {
// exportLinks is a jQuery Array, so .each is allowed.
exportLinks.each(function () {
// Modified from regular expression to fix:
// https://github.com/ether/etherpad-lite/issues/4071
// Where a padId that was numeric would create the wrong export link
if (this.href) {
const type = this.href.split('export/')[1];
let href = baseURI.split('timeslider')[0];
href += `${revno}/export/${type}`;
this.setAttribute('href', href);
}
}); });
});
// fire all start functions of these scripts, formerly fired with window.load
for (let i = 0; i < fireWhenAllScriptsAreLoaded.length; i++) {
fireWhenAllScriptsAreLoaded[i]();
}
$('#ui-slider-handle').css('left', $('#ui-slider-bar').width() - 2);
// Translate some strings where we only want to set the title not the actual values
$('#playpause_button_icon').attr('title', html10n.get('timeslider.playPause'));
$('#leftstep').attr('title', html10n.get('timeslider.backRevision'));
$('#rightstep').attr('title', html10n.get('timeslider.forwardRevision'));
// font family change
$('#viewfontmenu').change(function () {
$('#innerdocbody').css('font-family', $(this).val() || '');
});
}; };
const fireWhenAllScriptsAreLoaded = [];
exports.baseURL = ''; const handleClientVars = (message) => {
exports.init = init; // save the client Vars
window.clientVars = message.data;
if (window.clientVars.sessionRefreshInterval) {
const ping = () => $.ajax('../../_extendExpressSessionLifetime', { method: 'PUT' }).catch(() => { });
setInterval(ping, window.clientVars.sessionRefreshInterval);
}
// load all script that doesn't work without the clientVars
BroadcastSlider = require('./broadcast_slider')
.loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded);
({ loadBroadcastRevisionsJS }.loadBroadcastRevisionsJS());
changesetLoader = require('./broadcast')
.loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider);
// initialize export ui
({ padimpexp }.padimpexp.init());
// Create a base URI used for timeslider exports
const baseURI = document.location.pathname;
// change export urls when the slider moves
BroadcastSlider.onSlider((revno) => {
// exportLinks is a jQuery Array, so .each is allowed.
exportLinks.each(function () {
// Modified from regular expression to fix:
// https://github.com/ether/etherpad-lite/issues/4071
// Where a padId that was numeric would create the wrong export link
if (this.href) {
const type = this.href.split('export/')[1];
let href = baseURI.split('timeslider')[0];
href += `${revno}/export/${type}`;
this.setAttribute('href', href);
}
});
});
// fire all start functions of these scripts, formerly fired with window.load
for (let i = 0; i < fireWhenAllScriptsAreLoaded.length; i++) {
fireWhenAllScriptsAreLoaded[i]();
}
$('#ui-slider-handle').css('left', $('#ui-slider-bar').width() - 2);
// Translate some strings where we only want to set the title not the actual values
$('#playpause_button_icon').attr('title', html10n.get('timeslider.playPause'));
$('#leftstep').attr('title', html10n.get('timeslider.backRevision'));
$('#rightstep').attr('title', html10n.get('timeslider.forwardRevision'));
// font family change
$('#viewfontmenu').change(function () {
$('#innerdocbody').css('font-family', $(this).val() || '');
});
};
export const baseURL = '';
export { init as socket };
export { init as BroadcastSlider };
export { init };

View file

@ -1,3 +1,2 @@
'use strict'; 'use strict';
export { default } from "underscore";
module.exports = require('underscore');

View file

@ -1,285 +1,245 @@
import * as Changeset from "./Changeset.js";
import * as _ from "./underscore.js";
'use strict'; 'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const Changeset = require('./Changeset');
const _ = require('./underscore');
const undoModule = (() => { const undoModule = (() => {
const stack = (() => { const stack = (() => {
const stackElements = []; const stackElements = [];
// two types of stackElements: // two types of stackElements:
// 1) { elementType: UNDOABLE_EVENT, eventType: "anything", [backset: <changeset>,] // 1) { elementType: UNDOABLE_EVENT, eventType: "anything", [backset: <changeset>,]
// [selStart: <char number>, selEnd: <char number>, selFocusAtStart: <boolean>] } // [selStart: <char number>, selEnd: <char number>, selFocusAtStart: <boolean>] }
// 2) { elementType: EXTERNAL_CHANGE, changeset: <changeset> } // 2) { elementType: EXTERNAL_CHANGE, changeset: <changeset> }
// invariant: no two consecutive EXTERNAL_CHANGEs // invariant: no two consecutive EXTERNAL_CHANGEs
let numUndoableEvents = 0; let numUndoableEvents = 0;
const UNDOABLE_EVENT = 'undoableEvent';
const UNDOABLE_EVENT = 'undoableEvent'; const EXTERNAL_CHANGE = 'externalChange';
const EXTERNAL_CHANGE = 'externalChange'; const clearStack = () => {
stackElements.length = 0;
const clearStack = () => { stackElements.push({
stackElements.length = 0; elementType: UNDOABLE_EVENT,
stackElements.push( eventType: 'bottom',
{
elementType: UNDOABLE_EVENT,
eventType: 'bottom',
});
numUndoableEvents = 1;
};
clearStack();
const pushEvent = (event) => {
const e = _.extend(
{}, event);
e.elementType = UNDOABLE_EVENT;
stackElements.push(e);
numUndoableEvents++;
};
const pushExternalChange = (cs) => {
const idx = stackElements.length - 1;
if (stackElements[idx].elementType === EXTERNAL_CHANGE) {
stackElements[idx].changeset =
Changeset.compose(stackElements[idx].changeset, cs, getAPool());
} else {
stackElements.push(
{
elementType: EXTERNAL_CHANGE,
changeset: cs,
}); });
} numUndoableEvents = 1;
}; };
clearStack();
const _exposeEvent = (nthFromTop) => { const pushEvent = (event) => {
// precond: 0 <= nthFromTop < numUndoableEvents const e = _.extend({}, event);
const targetIndex = stackElements.length - 1 - nthFromTop; e.elementType = UNDOABLE_EVENT;
let idx = stackElements.length - 1; stackElements.push(e);
while (idx > targetIndex || stackElements[idx].elementType === EXTERNAL_CHANGE) { numUndoableEvents++;
if (stackElements[idx].elementType === EXTERNAL_CHANGE) { };
const ex = stackElements[idx]; const pushExternalChange = (cs) => {
const un = stackElements[idx - 1]; const idx = stackElements.length - 1;
if (un.backset) { if (stackElements[idx].elementType === EXTERNAL_CHANGE) {
const excs = ex.changeset; stackElements[idx].changeset =
const unbs = un.backset; Changeset.compose(stackElements[idx].changeset, cs, getAPool());
un.backset = Changeset.follow(excs, un.backset, false, getAPool()); }
ex.changeset = Changeset.follow(unbs, ex.changeset, true, getAPool()); else {
if ((typeof un.selStart) === 'number') { stackElements.push({
const newSel = Changeset.characterRangeFollow(excs, un.selStart, un.selEnd); elementType: EXTERNAL_CHANGE,
un.selStart = newSel[0]; changeset: cs,
un.selEnd = newSel[1]; });
if (un.selStart === un.selEnd) { }
un.selFocusAtStart = false; };
} const _exposeEvent = (nthFromTop) => {
// precond: 0 <= nthFromTop < numUndoableEvents
const targetIndex = stackElements.length - 1 - nthFromTop;
let idx = stackElements.length - 1;
while (idx > targetIndex || stackElements[idx].elementType === EXTERNAL_CHANGE) {
if (stackElements[idx].elementType === EXTERNAL_CHANGE) {
const ex = stackElements[idx];
const un = stackElements[idx - 1];
if (un.backset) {
const excs = ex.changeset;
const unbs = un.backset;
un.backset = Changeset.follow(excs, un.backset, false, getAPool());
ex.changeset = Changeset.follow(unbs, ex.changeset, true, getAPool());
if ((typeof un.selStart) === 'number') {
const newSel = Changeset.characterRangeFollow(excs, un.selStart, un.selEnd);
un.selStart = newSel[0];
un.selEnd = newSel[1];
if (un.selStart === un.selEnd) {
un.selFocusAtStart = false;
}
}
}
stackElements[idx - 1] = ex;
stackElements[idx] = un;
if (idx >= 2 && stackElements[idx - 2].elementType === EXTERNAL_CHANGE) {
ex.changeset =
Changeset.compose(stackElements[idx - 2].changeset, ex.changeset, getAPool());
stackElements.splice(idx - 2, 1);
idx--;
}
}
else {
idx--;
}
}
};
const getNthFromTop = (n) => {
// precond: 0 <= n < numEvents()
_exposeEvent(n);
return stackElements[stackElements.length - 1 - n];
};
const numEvents = () => numUndoableEvents;
const popEvent = () => {
// precond: numEvents() > 0
_exposeEvent(0);
numUndoableEvents--;
return stackElements.pop();
};
return {
numEvents,
popEvent,
pushEvent,
pushExternalChange,
clearStack,
getNthFromTop,
};
})();
// invariant: stack always has at least one undoable event
let undoPtr = 0; // zero-index from top of stack, 0 == top
const clearHistory = () => {
stack.clearStack();
undoPtr = 0;
};
const _charOccurrences = (str, c) => {
let i = 0;
let count = 0;
while (i >= 0 && i < str.length) {
i = str.indexOf(c, i);
if (i >= 0) {
count++;
i++;
} }
}
stackElements[idx - 1] = ex;
stackElements[idx] = un;
if (idx >= 2 && stackElements[idx - 2].elementType === EXTERNAL_CHANGE) {
ex.changeset =
Changeset.compose(stackElements[idx - 2].changeset, ex.changeset, getAPool());
stackElements.splice(idx - 2, 1);
idx--;
}
} else {
idx--;
} }
} return count;
}; };
const _opcodeOccurrences = (cs, opcode) => _charOccurrences(Changeset.unpack(cs).ops, opcode);
const getNthFromTop = (n) => { const _mergeChangesets = (cs1, cs2) => {
// precond: 0 <= n < numEvents() if (!cs1)
_exposeEvent(n); return cs2;
return stackElements[stackElements.length - 1 - n]; if (!cs2)
return cs1;
// Rough heuristic for whether changesets should be considered one action:
// each does exactly one insertion, no dels, and the composition does also; or
// each does exactly one deletion, no ins, and the composition does also.
// A little weird in that it won't merge "make bold" with "insert char"
// but will merge "make bold and insert char" with "insert char",
// though that isn't expected to come up.
const plusCount1 = _opcodeOccurrences(cs1, '+');
const plusCount2 = _opcodeOccurrences(cs2, '+');
const minusCount1 = _opcodeOccurrences(cs1, '-');
const minusCount2 = _opcodeOccurrences(cs2, '-');
if (plusCount1 === 1 && plusCount2 === 1 && minusCount1 === 0 && minusCount2 === 0) {
const merge = Changeset.compose(cs1, cs2, getAPool());
const plusCount3 = _opcodeOccurrences(merge, '+');
const minusCount3 = _opcodeOccurrences(merge, '-');
if (plusCount3 === 1 && minusCount3 === 0) {
return merge;
}
}
else if (plusCount1 === 0 && plusCount2 === 0 && minusCount1 === 1 && minusCount2 === 1) {
const merge = Changeset.compose(cs1, cs2, getAPool());
const plusCount3 = _opcodeOccurrences(merge, '+');
const minusCount3 = _opcodeOccurrences(merge, '-');
if (plusCount3 === 0 && minusCount3 === 1) {
return merge;
}
}
return null;
}; };
const reportEvent = (event) => {
const numEvents = () => numUndoableEvents; const topEvent = stack.getNthFromTop(0);
const applySelectionToTop = () => {
const popEvent = () => { if ((typeof event.selStart) === 'number') {
// precond: numEvents() > 0 topEvent.selStart = event.selStart;
_exposeEvent(0); topEvent.selEnd = event.selEnd;
numUndoableEvents--; topEvent.selFocusAtStart = event.selFocusAtStart;
return stackElements.pop(); }
};
if ((!event.backset) || Changeset.isIdentity(event.backset)) {
applySelectionToTop();
}
else {
let merged = false;
if (topEvent.eventType === event.eventType) {
const merge = _mergeChangesets(event.backset, topEvent.backset);
if (merge) {
topEvent.backset = merge;
applySelectionToTop();
merged = true;
}
}
if (!merged) {
/*
* Push the event on the undo stack only if it exists, and if it's
* not a "clearauthorship". This disallows undoing the removal of the
* authorship colors, but is a necessary stopgap measure against
* https://github.com/ether/etherpad-lite/issues/2802
*/
if (event && (event.eventType !== 'clearauthorship')) {
stack.pushEvent(event);
}
}
undoPtr = 0;
}
}; };
const reportExternalChange = (changeset) => {
if (changeset && !Changeset.isIdentity(changeset)) {
stack.pushExternalChange(changeset);
}
};
const _getSelectionInfo = (event) => {
if ((typeof event.selStart) !== 'number') {
return null;
}
else {
return {
selStart: event.selStart,
selEnd: event.selEnd,
selFocusAtStart: event.selFocusAtStart,
};
}
};
// For "undo" and "redo", the change event must be returned
// by eventFunc and NOT reported through the normal mechanism.
// "eventFunc" should take a changeset and an optional selection info object,
// or can be called with no arguments to mean that no undo is possible.
// "eventFunc" will be called exactly once.
const performUndo = (eventFunc) => {
if (undoPtr < stack.numEvents() - 1) {
const backsetEvent = stack.getNthFromTop(undoPtr);
const selectionEvent = stack.getNthFromTop(undoPtr + 1);
const undoEvent = eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent));
stack.pushEvent(undoEvent);
undoPtr += 2;
}
else {
eventFunc();
}
};
const performRedo = (eventFunc) => {
if (undoPtr >= 2) {
const backsetEvent = stack.getNthFromTop(0);
const selectionEvent = stack.getNthFromTop(1);
eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent));
stack.popEvent();
undoPtr -= 2;
}
else {
eventFunc();
}
};
const getAPool = () => undoModule.apool;
return { return {
numEvents, clearHistory,
popEvent, reportEvent,
pushEvent, reportExternalChange,
pushExternalChange, performUndo,
clearStack, performRedo,
getNthFromTop, enabled: true,
}; apool: null,
})(); }; // apool is filled in by caller
// invariant: stack always has at least one undoable event
let undoPtr = 0; // zero-index from top of stack, 0 == top
const clearHistory = () => {
stack.clearStack();
undoPtr = 0;
};
const _charOccurrences = (str, c) => {
let i = 0;
let count = 0;
while (i >= 0 && i < str.length) {
i = str.indexOf(c, i);
if (i >= 0) {
count++;
i++;
}
}
return count;
};
const _opcodeOccurrences = (cs, opcode) => _charOccurrences(Changeset.unpack(cs).ops, opcode);
const _mergeChangesets = (cs1, cs2) => {
if (!cs1) return cs2;
if (!cs2) return cs1;
// Rough heuristic for whether changesets should be considered one action:
// each does exactly one insertion, no dels, and the composition does also; or
// each does exactly one deletion, no ins, and the composition does also.
// A little weird in that it won't merge "make bold" with "insert char"
// but will merge "make bold and insert char" with "insert char",
// though that isn't expected to come up.
const plusCount1 = _opcodeOccurrences(cs1, '+');
const plusCount2 = _opcodeOccurrences(cs2, '+');
const minusCount1 = _opcodeOccurrences(cs1, '-');
const minusCount2 = _opcodeOccurrences(cs2, '-');
if (plusCount1 === 1 && plusCount2 === 1 && minusCount1 === 0 && minusCount2 === 0) {
const merge = Changeset.compose(cs1, cs2, getAPool());
const plusCount3 = _opcodeOccurrences(merge, '+');
const minusCount3 = _opcodeOccurrences(merge, '-');
if (plusCount3 === 1 && minusCount3 === 0) {
return merge;
}
} else if (plusCount1 === 0 && plusCount2 === 0 && minusCount1 === 1 && minusCount2 === 1) {
const merge = Changeset.compose(cs1, cs2, getAPool());
const plusCount3 = _opcodeOccurrences(merge, '+');
const minusCount3 = _opcodeOccurrences(merge, '-');
if (plusCount3 === 0 && minusCount3 === 1) {
return merge;
}
}
return null;
};
const reportEvent = (event) => {
const topEvent = stack.getNthFromTop(0);
const applySelectionToTop = () => {
if ((typeof event.selStart) === 'number') {
topEvent.selStart = event.selStart;
topEvent.selEnd = event.selEnd;
topEvent.selFocusAtStart = event.selFocusAtStart;
}
};
if ((!event.backset) || Changeset.isIdentity(event.backset)) {
applySelectionToTop();
} else {
let merged = false;
if (topEvent.eventType === event.eventType) {
const merge = _mergeChangesets(event.backset, topEvent.backset);
if (merge) {
topEvent.backset = merge;
applySelectionToTop();
merged = true;
}
}
if (!merged) {
/*
* Push the event on the undo stack only if it exists, and if it's
* not a "clearauthorship". This disallows undoing the removal of the
* authorship colors, but is a necessary stopgap measure against
* https://github.com/ether/etherpad-lite/issues/2802
*/
if (event && (event.eventType !== 'clearauthorship')) {
stack.pushEvent(event);
}
}
undoPtr = 0;
}
};
const reportExternalChange = (changeset) => {
if (changeset && !Changeset.isIdentity(changeset)) {
stack.pushExternalChange(changeset);
}
};
const _getSelectionInfo = (event) => {
if ((typeof event.selStart) !== 'number') {
return null;
} else {
return {
selStart: event.selStart,
selEnd: event.selEnd,
selFocusAtStart: event.selFocusAtStart,
};
}
};
// For "undo" and "redo", the change event must be returned
// by eventFunc and NOT reported through the normal mechanism.
// "eventFunc" should take a changeset and an optional selection info object,
// or can be called with no arguments to mean that no undo is possible.
// "eventFunc" will be called exactly once.
const performUndo = (eventFunc) => {
if (undoPtr < stack.numEvents() - 1) {
const backsetEvent = stack.getNthFromTop(undoPtr);
const selectionEvent = stack.getNthFromTop(undoPtr + 1);
const undoEvent = eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent));
stack.pushEvent(undoEvent);
undoPtr += 2;
} else { eventFunc(); }
};
const performRedo = (eventFunc) => {
if (undoPtr >= 2) {
const backsetEvent = stack.getNthFromTop(0);
const selectionEvent = stack.getNthFromTop(1);
eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent));
stack.popEvent();
undoPtr -= 2;
} else { eventFunc(); }
};
const getAPool = () => undoModule.apool;
return {
clearHistory,
reportEvent,
reportExternalChange,
performUndo,
performRedo,
enabled: true,
apool: null,
}; // apool is filled in by caller
})(); })();
export { undoModule };
exports.undoModule = undoModule;

View file

@ -1,310 +1,294 @@
// WARNING: This file may have been modified from original. // WARNING: This file may have been modified from original.
// TODO: Check requirement of this file, this afaik was to cover weird edge cases // TODO: Check requirement of this file, this afaik was to cover weird edge cases
// that have probably been fixed in browsers. // that have probably been fixed in browsers.
/*! /*!
* Bowser - a browser detector * Bowser - a browser detector
* https://github.com/ded/bowser * https://github.com/ded/bowser
* MIT License | (c) Dustin Diaz 2015 * MIT License | (c) Dustin Diaz 2015
*/ */
!function (name, definition) { !function (name, definition) {
if (typeof module != 'undefined' && module.exports) module.exports = definition() if (typeof module != 'undefined' && module.exports)
else if (typeof define == 'function' && define.amd) define(definition) ;
else this[name] = definition() else if (typeof define == 'function' && define.amd)
define(definition);
else
this[name] = definition();
}('bowser', function () { }('bowser', function () {
/** /**
* See useragents.js for examples of navigator.userAgent * See useragents.js for examples of navigator.userAgent
*/ */
var t = true;
var t = true function detect(ua) {
function getFirstMatch(regex) {
function detect(ua) { var match = ua.match(regex);
return (match && match.length > 1 && match[1]) || '';
function getFirstMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[1]) || '';
}
function getSecondMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[2]) || '';
}
var iosdevice = getFirstMatch(/(ipod|iphone|ipad)/i).toLowerCase()
, likeAndroid = /like android/i.test(ua)
, android = !likeAndroid && /android/i.test(ua)
, chromeos = /CrOS/.test(ua)
, silk = /silk/i.test(ua)
, sailfish = /sailfish/i.test(ua)
, tizen = /tizen/i.test(ua)
, webos = /(web|hpw)os/i.test(ua)
, windowsphone = /windows phone/i.test(ua)
, windows = !windowsphone && /windows/i.test(ua)
, mac = !iosdevice && !silk && /macintosh/i.test(ua)
, linux = !android && !sailfish && !tizen && !webos && /linux/i.test(ua)
, edgeVersion = getFirstMatch(/edge\/(\d+(\.\d+)?)/i)
, versionIdentifier = getFirstMatch(/version\/(\d+(\.\d+)?)/i)
, tablet = /tablet/i.test(ua)
, mobile = !tablet && /[^-]mobi/i.test(ua)
, result
if (/opera|opr/i.test(ua)) {
result = {
name: 'Opera'
, opera: t
, version: versionIdentifier || getFirstMatch(/(?:opera|opr)[\s\/](\d+(\.\d+)?)/i)
}
}
else if (/yabrowser/i.test(ua)) {
result = {
name: 'Yandex Browser'
, yandexbrowser: t
, version: versionIdentifier || getFirstMatch(/(?:yabrowser)[\s\/](\d+(\.\d+)?)/i)
}
}
else if (windowsphone) {
result = {
name: 'Windows Phone'
, windowsphone: t
}
if (edgeVersion) {
result.msedge = t
result.version = edgeVersion
}
else {
result.msie = t
result.version = getFirstMatch(/iemobile\/(\d+(\.\d+)?)/i)
}
}
else if (/msie|trident/i.test(ua)) {
result = {
name: 'Internet Explorer'
, msie: t
, version: getFirstMatch(/(?:msie |rv:)(\d+(\.\d+)?)/i)
}
} else if (chromeos) {
result = {
name: 'Chrome'
, chromeos: t
, chromeBook: t
, chrome: t
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
}
} else if (/chrome.+? edge/i.test(ua)) {
result = {
name: 'Microsoft Edge'
, msedge: t
, version: edgeVersion
}
}
else if (/chrome|crios|crmo/i.test(ua)) {
result = {
name: 'Chrome'
, chrome: t
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
}
}
else if (iosdevice) {
result = {
name : iosdevice == 'iphone' ? 'iPhone' : iosdevice == 'ipad' ? 'iPad' : 'iPod'
}
// WTF: version is not part of user agent in web apps
if (versionIdentifier) {
result.version = versionIdentifier
}
}
else if (sailfish) {
result = {
name: 'Sailfish'
, sailfish: t
, version: getFirstMatch(/sailfish\s?browser\/(\d+(\.\d+)?)/i)
}
}
else if (/seamonkey\//i.test(ua)) {
result = {
name: 'SeaMonkey'
, seamonkey: t
, version: getFirstMatch(/seamonkey\/(\d+(\.\d+)?)/i)
}
}
else if (/firefox|iceweasel/i.test(ua)) {
result = {
name: 'Firefox'
, firefox: t
, version: getFirstMatch(/(?:firefox|iceweasel)[ \/](\d+(\.\d+)?)/i)
}
if (/\((mobile|tablet);[^\)]*rv:[\d\.]+\)/i.test(ua)) {
result.firefoxos = t
}
}
else if (silk) {
result = {
name: 'Amazon Silk'
, silk: t
, version : getFirstMatch(/silk\/(\d+(\.\d+)?)/i)
}
}
else if (android) {
result = {
name: 'Android'
, version: versionIdentifier
}
}
else if (/phantom/i.test(ua)) {
result = {
name: 'PhantomJS'
, phantom: t
, version: getFirstMatch(/phantomjs\/(\d+(\.\d+)?)/i)
}
}
else if (/blackberry|\bbb\d+/i.test(ua) || /rim\stablet/i.test(ua)) {
result = {
name: 'BlackBerry'
, blackberry: t
, version: versionIdentifier || getFirstMatch(/blackberry[\d]+\/(\d+(\.\d+)?)/i)
}
}
else if (webos) {
result = {
name: 'WebOS'
, webos: t
, version: versionIdentifier || getFirstMatch(/w(?:eb)?osbrowser\/(\d+(\.\d+)?)/i)
};
/touchpad\//i.test(ua) && (result.touchpad = t)
}
else if (/bada/i.test(ua)) {
result = {
name: 'Bada'
, bada: t
, version: getFirstMatch(/dolfin\/(\d+(\.\d+)?)/i)
};
}
else if (tizen) {
result = {
name: 'Tizen'
, tizen: t
, version: getFirstMatch(/(?:tizen\s?)?browser\/(\d+(\.\d+)?)/i) || versionIdentifier
};
}
else if (/safari/i.test(ua)) {
result = {
name: 'Safari'
, safari: t
, version: versionIdentifier
}
}
else {
result = {
name: getFirstMatch(/^(.*)\/(.*) /),
version: getSecondMatch(/^(.*)\/(.*) /)
};
}
// set webkit or gecko flag for browsers based on these engines
if (!result.msedge && /(apple)?webkit/i.test(ua)) {
result.name = result.name || "Webkit"
result.webkit = t
if (!result.version && versionIdentifier) {
result.version = versionIdentifier
}
} else if (!result.opera && /gecko\//i.test(ua)) {
result.name = result.name || "Gecko"
result.gecko = t
result.version = result.version || getFirstMatch(/gecko\/(\d+(\.\d+)?)/i)
}
// set OS flags for platforms that have multiple browsers
if (!result.msedge && (android || result.silk)) {
result.android = t
} else if (iosdevice) {
result[iosdevice] = t
result.ios = t
} else if (windows) {
result.windows = t
} else if (mac) {
result.mac = t
} else if (linux) {
result.linux = t
}
// OS version extraction
var osVersion = '';
if (result.windowsphone) {
osVersion = getFirstMatch(/windows phone (?:os)?\s?(\d+(\.\d+)*)/i);
} else if (iosdevice) {
osVersion = getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i);
osVersion = osVersion.replace(/[_\s]/g, '.');
} else if (android) {
osVersion = getFirstMatch(/android[ \/-](\d+(\.\d+)*)/i);
} else if (result.webos) {
osVersion = getFirstMatch(/(?:web|hpw)os\/(\d+(\.\d+)*)/i);
} else if (result.blackberry) {
osVersion = getFirstMatch(/rim\stablet\sos\s(\d+(\.\d+)*)/i);
} else if (result.bada) {
osVersion = getFirstMatch(/bada\/(\d+(\.\d+)*)/i);
} else if (result.tizen) {
osVersion = getFirstMatch(/tizen[\/\s](\d+(\.\d+)*)/i);
}
if (osVersion) {
result.osversion = osVersion;
}
// device type extraction
var osMajorVersion = osVersion.split('.')[0];
if (tablet || iosdevice == 'ipad' || (android && (osMajorVersion == 3 || (osMajorVersion == 4 && !mobile))) || result.silk) {
result.tablet = t
} else if (mobile || iosdevice == 'iphone' || iosdevice == 'ipod' || android || result.blackberry || result.webos || result.bada) {
result.mobile = t
}
// Graded Browser Support
// http://developer.yahoo.com/yui/articles/gbs
if (result.msedge ||
(result.msie && result.version >= 10) ||
(result.yandexbrowser && result.version >= 15) ||
(result.chrome && result.version >= 20) ||
(result.firefox && result.version >= 20.0) ||
(result.safari && result.version >= 6) ||
(result.opera && result.version >= 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] >= 6) ||
(result.blackberry && result.version >= 10.1)
) {
result.a = t;
}
else if ((result.msie && result.version < 10) ||
(result.chrome && result.version < 20) ||
(result.firefox && result.version < 20.0) ||
(result.safari && result.version < 6) ||
(result.opera && result.version < 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] < 6)
) {
result.c = t
} else result.x = t
return result
}
var bowser = detect(typeof navigator !== 'undefined' ? navigator.userAgent : '')
bowser.test = function (browserList) {
for (var i = 0; i < browserList.length; ++i) {
var browserItem = browserList[i];
if (typeof browserItem=== 'string') {
if (browserItem in bowser) {
return true;
} }
} function getSecondMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[2]) || '';
}
var iosdevice = getFirstMatch(/(ipod|iphone|ipad)/i).toLowerCase(), likeAndroid = /like android/i.test(ua), android = !likeAndroid && /android/i.test(ua), chromeos = /CrOS/.test(ua), silk = /silk/i.test(ua), sailfish = /sailfish/i.test(ua), tizen = /tizen/i.test(ua), webos = /(web|hpw)os/i.test(ua), windowsphone = /windows phone/i.test(ua), windows = !windowsphone && /windows/i.test(ua), mac = !iosdevice && !silk && /macintosh/i.test(ua), linux = !android && !sailfish && !tizen && !webos && /linux/i.test(ua), edgeVersion = getFirstMatch(/edge\/(\d+(\.\d+)?)/i), versionIdentifier = getFirstMatch(/version\/(\d+(\.\d+)?)/i), tablet = /tablet/i.test(ua), mobile = !tablet && /[^-]mobi/i.test(ua), result;
if (/opera|opr/i.test(ua)) {
result = {
name: 'Opera',
opera: t,
version: versionIdentifier || getFirstMatch(/(?:opera|opr)[\s\/](\d+(\.\d+)?)/i)
};
}
else if (/yabrowser/i.test(ua)) {
result = {
name: 'Yandex Browser',
yandexbrowser: t,
version: versionIdentifier || getFirstMatch(/(?:yabrowser)[\s\/](\d+(\.\d+)?)/i)
};
}
else if (windowsphone) {
result = {
name: 'Windows Phone',
windowsphone: t
};
if (edgeVersion) {
result.msedge = t;
result.version = edgeVersion;
}
else {
result.msie = t;
result.version = getFirstMatch(/iemobile\/(\d+(\.\d+)?)/i);
}
}
else if (/msie|trident/i.test(ua)) {
result = {
name: 'Internet Explorer',
msie: t,
version: getFirstMatch(/(?:msie |rv:)(\d+(\.\d+)?)/i)
};
}
else if (chromeos) {
result = {
name: 'Chrome',
chromeos: t,
chromeBook: t,
chrome: t,
version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
};
}
else if (/chrome.+? edge/i.test(ua)) {
result = {
name: 'Microsoft Edge',
msedge: t,
version: edgeVersion
};
}
else if (/chrome|crios|crmo/i.test(ua)) {
result = {
name: 'Chrome',
chrome: t,
version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
};
}
else if (iosdevice) {
result = {
name: iosdevice == 'iphone' ? 'iPhone' : iosdevice == 'ipad' ? 'iPad' : 'iPod'
};
// WTF: version is not part of user agent in web apps
if (versionIdentifier) {
result.version = versionIdentifier;
}
}
else if (sailfish) {
result = {
name: 'Sailfish',
sailfish: t,
version: getFirstMatch(/sailfish\s?browser\/(\d+(\.\d+)?)/i)
};
}
else if (/seamonkey\//i.test(ua)) {
result = {
name: 'SeaMonkey',
seamonkey: t,
version: getFirstMatch(/seamonkey\/(\d+(\.\d+)?)/i)
};
}
else if (/firefox|iceweasel/i.test(ua)) {
result = {
name: 'Firefox',
firefox: t,
version: getFirstMatch(/(?:firefox|iceweasel)[ \/](\d+(\.\d+)?)/i)
};
if (/\((mobile|tablet);[^\)]*rv:[\d\.]+\)/i.test(ua)) {
result.firefoxos = t;
}
}
else if (silk) {
result = {
name: 'Amazon Silk',
silk: t,
version: getFirstMatch(/silk\/(\d+(\.\d+)?)/i)
};
}
else if (android) {
result = {
name: 'Android',
version: versionIdentifier
};
}
else if (/phantom/i.test(ua)) {
result = {
name: 'PhantomJS',
phantom: t,
version: getFirstMatch(/phantomjs\/(\d+(\.\d+)?)/i)
};
}
else if (/blackberry|\bbb\d+/i.test(ua) || /rim\stablet/i.test(ua)) {
result = {
name: 'BlackBerry',
blackberry: t,
version: versionIdentifier || getFirstMatch(/blackberry[\d]+\/(\d+(\.\d+)?)/i)
};
}
else if (webos) {
result = {
name: 'WebOS',
webos: t,
version: versionIdentifier || getFirstMatch(/w(?:eb)?osbrowser\/(\d+(\.\d+)?)/i)
};
/touchpad\//i.test(ua) && (result.touchpad = t);
}
else if (/bada/i.test(ua)) {
result = {
name: 'Bada',
bada: t,
version: getFirstMatch(/dolfin\/(\d+(\.\d+)?)/i)
};
}
else if (tizen) {
result = {
name: 'Tizen',
tizen: t,
version: getFirstMatch(/(?:tizen\s?)?browser\/(\d+(\.\d+)?)/i) || versionIdentifier
};
}
else if (/safari/i.test(ua)) {
result = {
name: 'Safari',
safari: t,
version: versionIdentifier
};
}
else {
result = {
name: getFirstMatch(/^(.*)\/(.*) /),
version: getSecondMatch(/^(.*)\/(.*) /)
};
}
// set webkit or gecko flag for browsers based on these engines
if (!result.msedge && /(apple)?webkit/i.test(ua)) {
result.name = result.name || "Webkit";
result.webkit = t;
if (!result.version && versionIdentifier) {
result.version = versionIdentifier;
}
}
else if (!result.opera && /gecko\//i.test(ua)) {
result.name = result.name || "Gecko";
result.gecko = t;
result.version = result.version || getFirstMatch(/gecko\/(\d+(\.\d+)?)/i);
}
// set OS flags for platforms that have multiple browsers
if (!result.msedge && (android || result.silk)) {
result.android = t;
}
else if (iosdevice) {
result[iosdevice] = t;
result.ios = t;
}
else if (windows) {
result.windows = t;
}
else if (mac) {
result.mac = t;
}
else if (linux) {
result.linux = t;
}
// OS version extraction
var osVersion = '';
if (result.windowsphone) {
osVersion = getFirstMatch(/windows phone (?:os)?\s?(\d+(\.\d+)*)/i);
}
else if (iosdevice) {
osVersion = getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i);
osVersion = osVersion.replace(/[_\s]/g, '.');
}
else if (android) {
osVersion = getFirstMatch(/android[ \/-](\d+(\.\d+)*)/i);
}
else if (result.webos) {
osVersion = getFirstMatch(/(?:web|hpw)os\/(\d+(\.\d+)*)/i);
}
else if (result.blackberry) {
osVersion = getFirstMatch(/rim\stablet\sos\s(\d+(\.\d+)*)/i);
}
else if (result.bada) {
osVersion = getFirstMatch(/bada\/(\d+(\.\d+)*)/i);
}
else if (result.tizen) {
osVersion = getFirstMatch(/tizen[\/\s](\d+(\.\d+)*)/i);
}
if (osVersion) {
result.osversion = osVersion;
}
// device type extraction
var osMajorVersion = osVersion.split('.')[0];
if (tablet || iosdevice == 'ipad' || (android && (osMajorVersion == 3 || (osMajorVersion == 4 && !mobile))) || result.silk) {
result.tablet = t;
}
else if (mobile || iosdevice == 'iphone' || iosdevice == 'ipod' || android || result.blackberry || result.webos || result.bada) {
result.mobile = t;
}
// Graded Browser Support
// http://developer.yahoo.com/yui/articles/gbs
if (result.msedge ||
(result.msie && result.version >= 10) ||
(result.yandexbrowser && result.version >= 15) ||
(result.chrome && result.version >= 20) ||
(result.firefox && result.version >= 20.0) ||
(result.safari && result.version >= 6) ||
(result.opera && result.version >= 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] >= 6) ||
(result.blackberry && result.version >= 10.1)) {
result.a = t;
}
else if ((result.msie && result.version < 10) ||
(result.chrome && result.version < 20) ||
(result.firefox && result.version < 20.0) ||
(result.safari && result.version < 6) ||
(result.opera && result.version < 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] < 6)) {
result.c = t;
}
else
result.x = t;
return result;
} }
return false; var bowser = detect(typeof navigator !== 'undefined' ? navigator.userAgent : '');
} bowser.test = function (browserList) {
for (var i = 0; i < browserList.length; ++i) {
/* var browserItem = browserList[i];
* Set our detect method to the main bowser object so we can if (typeof browserItem === 'string') {
* reuse it to test other user agents. if (browserItem in bowser) {
* This is needed to implement future tests. return true;
*/ }
bowser._detect = detect; }
}
return bowser return false;
};
/*
* Set our detect method to the main bowser object so we can
* reuse it to test other user agents.
* This is needed to implement future tests.
*/
bowser._detect = detect;
return bowser;
}); });
export default definition();

View file

@ -1,6 +1,5 @@
// WARNING: This file has been modified from original. // WARNING: This file has been modified from original.
// TODO: Replace with https://github.com/Simonwep/pickr // TODO: Replace with https://github.com/Simonwep/pickr
// Farbtastic 2.0 alpha // Farbtastic 2.0 alpha
// Original can be found at: // Original can be found at:
// https://github.com/mattfarina/farbtastic/blob/71ca15f4a09c8e5a08a1b0d1cf37ef028adf22f0/src/farbtastic.js // https://github.com/mattfarina/farbtastic/blob/71ca15f4a09c8e5a08a1b0d1cf37ef028adf22f0/src/farbtastic.js
@ -8,525 +7,456 @@
// https://github.com/mattfarina/farbtastic/blob/71ca15f4a09c8e5a08a1b0d1cf37ef028adf22f0/LICENSE.txt // https://github.com/mattfarina/farbtastic/blob/71ca15f4a09c8e5a08a1b0d1cf37ef028adf22f0/LICENSE.txt
// edited by Sebastian Castro <sebastian.castro@protonmail.com> on 2020-04-06 // edited by Sebastian Castro <sebastian.castro@protonmail.com> on 2020-04-06
(function ($) { (function ($) {
var __debug = false;
var __debug = false; var __factor = 1;
var __factor = 1; $.fn.farbtastic = function (options) {
$.farbtastic(this, options);
$.fn.farbtastic = function (options) { return this;
$.farbtastic(this, options);
return this;
};
$.farbtastic = function (container, options) {
var container = $(container)[0];
return container.farbtastic || (container.farbtastic = new $._farbtastic(container, options));
}
$._farbtastic = function (container, options) {
var fb = this;
/////////////////////////////////////////////////////
/**
* Link to the given element(s) or callback.
*/
fb.linkTo = function (callback) {
// Unbind previous nodes
if (typeof fb.callback == 'object') {
$(fb.callback).unbind('keyup', fb.updateValue);
}
// Reset color
fb.color = null;
// Bind callback or elements
if (typeof callback == 'function') {
fb.callback = callback;
}
else if (typeof callback == 'object' || typeof callback == 'string') {
fb.callback = $(callback);
fb.callback.bind('keyup', fb.updateValue);
if (fb.callback[0].value) {
fb.setColor(fb.callback[0].value);
}
}
return this;
}
fb.updateValue = function (event) {
if (this.value && this.value != fb.color) {
fb.setColor(this.value);
}
}
/**
* Change color with HTML syntax #123456
*/
fb.setColor = function (color) {
var unpack = fb.unpack(color);
if (fb.color != color && unpack) {
fb.color = color;
fb.rgb = unpack;
fb.hsl = fb.RGBToHSL(fb.rgb);
fb.updateDisplay();
}
return this;
}
/**
* Change color with HSL triplet [0..1, 0..1, 0..1]
*/
fb.setHSL = function (hsl) {
fb.hsl = hsl;
var convertedHSL = [hsl[0]]
convertedHSL[1] = hsl[1]*__factor+((1-__factor)/2);
convertedHSL[2] = hsl[2]*__factor+((1-__factor)/2);
fb.rgb = fb.HSLToRGB(convertedHSL);
fb.color = fb.pack(fb.rgb);
fb.updateDisplay();
return this;
}
/////////////////////////////////////////////////////
/**
* Initialize the color picker widget.
*/
fb.initWidget = function () {
// Insert markup and size accordingly.
var dim = {
width: options.width,
height: options.width
}; };
$(container) $.farbtastic = function (container, options) {
.html( var container = $(container)[0];
'<div class="farbtastic" style="position: relative">' + return container.farbtastic || (container.farbtastic = new $._farbtastic(container, options));
'<div class="farbtastic-solid"></div>' + };
'<canvas class="farbtastic-mask"></canvas>' + $._farbtastic = function (container, options) {
'<canvas class="farbtastic-overlay"></canvas>' + var fb = this;
'</div>' /////////////////////////////////////////////////////
) /**
.find('*').attr(dim).css(dim).end() * Link to the given element(s) or callback.
.find('div>*').css('position', 'absolute'); */
fb.linkTo = function (callback) {
// IE Fix: Recreate canvas elements with doc.createElement and excanvas. // Unbind previous nodes
browser.msie && $('canvas', container).each(function () { if (typeof fb.callback == 'object') {
// Fetch info. $(fb.callback).unbind('keyup', fb.updateValue);
var attr = { 'class': $(this).attr('class'), style: this.getAttribute('style') }, }
e = document.createElement('canvas'); // Reset color
// Replace element. fb.color = null;
$(this).before($(e).attr(attr)).remove(); // Bind callback or elements
// Init with explorerCanvas. if (typeof callback == 'function') {
G_vmlCanvasManager && G_vmlCanvasManager.initElement(e); fb.callback = callback;
// Set explorerCanvas elements dimensions and absolute positioning. }
$(e).attr(dim).css(dim).css('position', 'absolute') else if (typeof callback == 'object' || typeof callback == 'string') {
.find('*').attr(dim).css(dim); fb.callback = $(callback);
}); fb.callback.bind('keyup', fb.updateValue);
if (fb.callback[0].value) {
// Determine layout fb.setColor(fb.callback[0].value);
fb.radius = (options.width - options.wheelWidth) / 2 - 1; }
fb.square = Math.floor((fb.radius - options.wheelWidth / 2) * 0.7) - 1; }
fb.mid = Math.floor(options.width / 2); return this;
fb.markerSize = options.wheelWidth * 0.3; };
fb.solidFill = $('.farbtastic-solid', container).css({ fb.updateValue = function (event) {
width: fb.square * 2 - 1, if (this.value && this.value != fb.color) {
height: fb.square * 2 - 1, fb.setColor(this.value);
left: fb.mid - fb.square, }
top: fb.mid - fb.square };
}); /**
* Change color with HTML syntax #123456
// Set up drawing context. */
fb.cnvMask = $('.farbtastic-mask', container); fb.setColor = function (color) {
fb.ctxMask = fb.cnvMask[0].getContext('2d'); var unpack = fb.unpack(color);
fb.cnvOverlay = $('.farbtastic-overlay', container); if (fb.color != color && unpack) {
fb.ctxOverlay = fb.cnvOverlay[0].getContext('2d'); fb.color = color;
fb.ctxMask.translate(fb.mid, fb.mid); fb.rgb = unpack;
fb.ctxOverlay.translate(fb.mid, fb.mid); fb.hsl = fb.RGBToHSL(fb.rgb);
fb.updateDisplay();
// Draw widget base layers. }
fb.drawCircle(); return this;
fb.drawMask(); };
} /**
* Change color with HSL triplet [0..1, 0..1, 0..1]
/** */
* Draw the color wheel. fb.setHSL = function (hsl) {
*/ fb.hsl = hsl;
fb.drawCircle = function () { var convertedHSL = [hsl[0]];
var tm = +(new Date()); convertedHSL[1] = hsl[1] * __factor + ((1 - __factor) / 2);
// Draw a hue circle with a bunch of gradient-stroked beziers. convertedHSL[2] = hsl[2] * __factor + ((1 - __factor) / 2);
// Have to use beziers, as gradient-stroked arcs don't work. fb.rgb = fb.HSLToRGB(convertedHSL);
var n = 24, fb.color = fb.pack(fb.rgb);
r = fb.radius, fb.updateDisplay();
w = options.wheelWidth, return this;
nudge = 8 / r / n * Math.PI, // Fudge factor for seams. };
m = fb.ctxMask, /////////////////////////////////////////////////////
angle1 = 0, color1, d1; /**
m.save(); * Initialize the color picker widget.
m.lineWidth = w / r; */
m.scale(r, r); fb.initWidget = function () {
// Each segment goes from angle1 to angle2. // Insert markup and size accordingly.
for (var i = 0; i <= n; ++i) { var dim = {
var d2 = i / n, width: options.width,
angle2 = d2 * Math.PI * 2, height: options.width
// Endpoints };
x1 = Math.sin(angle1), y1 = -Math.cos(angle1); $(container)
x2 = Math.sin(angle2), y2 = -Math.cos(angle2), .html('<div class="farbtastic" style="position: relative">' +
// Midpoint chosen so that the endpoints are tangent to the circle. '<div class="farbtastic-solid"></div>' +
am = (angle1 + angle2) / 2, '<canvas class="farbtastic-mask"></canvas>' +
tan = 1 / Math.cos((angle2 - angle1) / 2), '<canvas class="farbtastic-overlay"></canvas>' +
xm = Math.sin(am) * tan, ym = -Math.cos(am) * tan, '</div>')
// New color .find('*').attr(dim).css(dim).end()
color2 = fb.pack(fb.HSLToRGB([d2, 1, 0.5])); .find('div>*').css('position', 'absolute');
if (i > 0) { // IE Fix: Recreate canvas elements with doc.createElement and excanvas.
if (browser.msie) { browser.msie && $('canvas', container).each(function () {
// IE's gradient calculations mess up the colors. Correct along the diagonals. // Fetch info.
var corr = (1 + Math.min(Math.abs(Math.tan(angle1)), Math.abs(Math.tan(Math.PI / 2 - angle1)))) / n; var attr = { 'class': $(this).attr('class'), style: this.getAttribute('style') }, e = document.createElement('canvas');
color1 = fb.pack(fb.HSLToRGB([d1 - 0.15 * corr, 1, 0.5])); // Replace element.
color2 = fb.pack(fb.HSLToRGB([d2 + 0.15 * corr, 1, 0.5])); $(this).before($(e).attr(attr)).remove();
// Create gradient fill between the endpoints. // Init with explorerCanvas.
var grad = m.createLinearGradient(x1, y1, x2, y2); G_vmlCanvasManager && G_vmlCanvasManager.initElement(e);
grad.addColorStop(0, color1); // Set explorerCanvas elements dimensions and absolute positioning.
grad.addColorStop(1, color2); $(e).attr(dim).css(dim).css('position', 'absolute')
m.fillStyle = grad; .find('*').attr(dim).css(dim);
// Draw quadratic curve segment as a fill. });
var r1 = (r + w / 2) / r, r2 = (r - w / 2) / r; // inner/outer radius. // Determine layout
m.beginPath(); fb.radius = (options.width - options.wheelWidth) / 2 - 1;
m.moveTo(x1 * r1, y1 * r1); fb.square = Math.floor((fb.radius - options.wheelWidth / 2) * 0.7) - 1;
m.quadraticCurveTo(xm * r1, ym * r1, x2 * r1, y2 * r1); fb.mid = Math.floor(options.width / 2);
m.lineTo(x2 * r2, y2 * r2); fb.markerSize = options.wheelWidth * 0.3;
m.quadraticCurveTo(xm * r2, ym * r2, x1 * r2, y1 * r2); fb.solidFill = $('.farbtastic-solid', container).css({
m.fill(); width: fb.square * 2 - 1,
} height: fb.square * 2 - 1,
else { left: fb.mid - fb.square,
// Create gradient fill between the endpoints. top: fb.mid - fb.square
var grad = m.createLinearGradient(x1, y1, x2, y2); });
grad.addColorStop(0, color1); // Set up drawing context.
grad.addColorStop(1, color2); fb.cnvMask = $('.farbtastic-mask', container);
m.strokeStyle = grad; fb.ctxMask = fb.cnvMask[0].getContext('2d');
// Draw quadratic curve segment. fb.cnvOverlay = $('.farbtastic-overlay', container);
m.beginPath(); fb.ctxOverlay = fb.cnvOverlay[0].getContext('2d');
m.moveTo(x1, y1); fb.ctxMask.translate(fb.mid, fb.mid);
m.quadraticCurveTo(xm, ym, x2, y2); fb.ctxOverlay.translate(fb.mid, fb.mid);
m.stroke(); // Draw widget base layers.
} fb.drawCircle();
} fb.drawMask();
// Prevent seams where curves join. };
angle1 = angle2 - nudge; color1 = color2; d1 = d2; /**
} * Draw the color wheel.
m.restore(); */
__debug && $('body').append('<div>drawCircle '+ (+(new Date()) - tm) +'ms'); fb.drawCircle = function () {
}; var tm = +(new Date());
// Draw a hue circle with a bunch of gradient-stroked beziers.
/** // Have to use beziers, as gradient-stroked arcs don't work.
* Draw the saturation/luminance mask. var n = 24, r = fb.radius, w = options.wheelWidth, nudge = 8 / r / n * Math.PI, // Fudge factor for seams.
*/ m = fb.ctxMask, angle1 = 0, color1, d1;
fb.drawMask = function () { m.save();
var tm = +(new Date()); m.lineWidth = w / r;
m.scale(r, r);
// Iterate over sat/lum space and calculate appropriate mask pixel values. // Each segment goes from angle1 to angle2.
var size = fb.square * 2, sq = fb.square; for (var i = 0; i <= n; ++i) {
function calculateMask(sizex, sizey, outputPixel) { var d2 = i / n, angle2 = d2 * Math.PI * 2,
var isx = 1 / sizex, isy = 1 / sizey; // Endpoints
for (var y = 0; y <= sizey; ++y) { x1 = Math.sin(angle1), y1 = -Math.cos(angle1);
var l = 1 - y * isy; x2 = Math.sin(angle2), y2 = -Math.cos(angle2),
for (var x = 0; x <= sizex; ++x) { // Midpoint chosen so that the endpoints are tangent to the circle.
var s = 1 - x * isx; am = (angle1 + angle2) / 2,
// From sat/lum to alpha and color (grayscale) tan = 1 / Math.cos((angle2 - angle1) / 2),
var a = 1 - 2 * Math.min(l * s, (1 - l) * s); xm = Math.sin(am) * tan, ym = -Math.cos(am) * tan,
var c = (a > 0) ? ((2 * l - 1 + a) * .5 / a) : 0; // New color
color2 = fb.pack(fb.HSLToRGB([d2, 1, 0.5]));
a = a*__factor+(1-__factor)/2; if (i > 0) {
c = c*__factor+(1-__factor)/2; if (browser.msie) {
// IE's gradient calculations mess up the colors. Correct along the diagonals.
outputPixel(x, y, c, a); var corr = (1 + Math.min(Math.abs(Math.tan(angle1)), Math.abs(Math.tan(Math.PI / 2 - angle1)))) / n;
} color1 = fb.pack(fb.HSLToRGB([d1 - 0.15 * corr, 1, 0.5]));
} color2 = fb.pack(fb.HSLToRGB([d2 + 0.15 * corr, 1, 0.5]));
} // Create gradient fill between the endpoints.
var grad = m.createLinearGradient(x1, y1, x2, y2);
// Method #1: direct pixel access (new Canvas). grad.addColorStop(0, color1);
if (fb.ctxMask.getImageData) { grad.addColorStop(1, color2);
// Create half-resolution buffer. m.fillStyle = grad;
var sz = Math.floor(size / 2); // Draw quadratic curve segment as a fill.
var buffer = document.createElement('canvas'); var r1 = (r + w / 2) / r, r2 = (r - w / 2) / r; // inner/outer radius.
buffer.width = buffer.height = sz + 1; m.beginPath();
var ctx = buffer.getContext('2d'); m.moveTo(x1 * r1, y1 * r1);
var frame = ctx.getImageData(0, 0, sz + 1, sz + 1); m.quadraticCurveTo(xm * r1, ym * r1, x2 * r1, y2 * r1);
m.lineTo(x2 * r2, y2 * r2);
var i = 0; m.quadraticCurveTo(xm * r2, ym * r2, x1 * r2, y1 * r2);
calculateMask(sz, sz, function (x, y, c, a) { m.fill();
frame.data[i++] = frame.data[i++] = frame.data[i++] = c * 255; }
frame.data[i++] = a * 255; else {
}); // Create gradient fill between the endpoints.
var grad = m.createLinearGradient(x1, y1, x2, y2);
ctx.putImageData(frame, 0, 0); grad.addColorStop(0, color1);
fb.ctxMask.drawImage(buffer, 0, 0, sz + 1, sz + 1, -sq, -sq, sq * 2, sq * 2); grad.addColorStop(1, color2);
} m.strokeStyle = grad;
// Method #2: drawing commands (old Canvas). // Draw quadratic curve segment.
else if (!browser.msie) { m.beginPath();
// Render directly at half-resolution m.moveTo(x1, y1);
var sz = Math.floor(size / 2); m.quadraticCurveTo(xm, ym, x2, y2);
calculateMask(sz, sz, function (x, y, c, a) { m.stroke();
c = Math.round(c * 255); }
fb.ctxMask.fillStyle = 'rgba(' + c + ', ' + c + ', ' + c + ', ' + a +')'; }
fb.ctxMask.fillRect(x * 2 - sq - 1, y * 2 - sq - 1, 2, 2); // Prevent seams where curves join.
}); angle1 = angle2 - nudge;
} color1 = color2;
// Method #3: vertical DXImageTransform gradient strips (IE). d1 = d2;
else { }
var cache_last, cache, w = 6; // Each strip is 6 pixels wide. m.restore();
var sizex = Math.floor(size / w); __debug && $('body').append('<div>drawCircle ' + (+(new Date()) - tm) + 'ms');
// 6 vertical pieces of gradient per strip. };
calculateMask(sizex, 6, function (x, y, c, a) { /**
if (x == 0) { * Draw the saturation/luminance mask.
cache_last = cache; */
cache = []; fb.drawMask = function () {
} var tm = +(new Date());
c = Math.round(c * 255); // Iterate over sat/lum space and calculate appropriate mask pixel values.
a = Math.round(a * 255); var size = fb.square * 2, sq = fb.square;
// We can only start outputting gradients once we have two rows of pixels. function calculateMask(sizex, sizey, outputPixel) {
if (y > 0) { var isx = 1 / sizex, isy = 1 / sizey;
var c_last = cache_last[x][0], for (var y = 0; y <= sizey; ++y) {
a_last = cache_last[x][1], var l = 1 - y * isy;
color1 = fb.packDX(c_last, a_last), for (var x = 0; x <= sizex; ++x) {
color2 = fb.packDX(c, a), var s = 1 - x * isx;
y1 = Math.round(fb.mid + ((y - 1) * .333 - 1) * sq), // From sat/lum to alpha and color (grayscale)
y2 = Math.round(fb.mid + (y * .333 - 1) * sq); var a = 1 - 2 * Math.min(l * s, (1 - l) * s);
$('<div>').css({ var c = (a > 0) ? ((2 * l - 1 + a) * .5 / a) : 0;
position: 'absolute', a = a * __factor + (1 - __factor) / 2;
filter: "progid:DXImageTransform.Microsoft.Gradient(StartColorStr="+ color1 +", EndColorStr="+ color2 +", GradientType=0)", c = c * __factor + (1 - __factor) / 2;
top: y1, outputPixel(x, y, c, a);
height: y2 - y1, }
// Avoid right-edge sticking out. }
left: fb.mid + (x * w - sq - 1), }
width: w - (x == sizex ? Math.round(w / 2) : 0) // Method #1: direct pixel access (new Canvas).
}).appendTo(fb.cnvMask); if (fb.ctxMask.getImageData) {
} // Create half-resolution buffer.
cache.push([c, a]); var sz = Math.floor(size / 2);
}); var buffer = document.createElement('canvas');
} buffer.width = buffer.height = sz + 1;
__debug && $('body').append('<div>drawMask '+ (+(new Date()) - tm) +'ms'); var ctx = buffer.getContext('2d');
} var frame = ctx.getImageData(0, 0, sz + 1, sz + 1);
var i = 0;
/** calculateMask(sz, sz, function (x, y, c, a) {
* Draw the selection markers. frame.data[i++] = frame.data[i++] = frame.data[i++] = c * 255;
*/ frame.data[i++] = a * 255;
fb.drawMarkers = function () { });
// Determine marker dimensions ctx.putImageData(frame, 0, 0);
var sz = options.width; fb.ctxMask.drawImage(buffer, 0, 0, sz + 1, sz + 1, -sq, -sq, sq * 2, sq * 2);
var angle = fb.hsl[0] * 6.28, }
x1 = Math.sin(angle) * fb.radius, // Method #2: drawing commands (old Canvas).
y1 = -Math.cos(angle) * fb.radius, else if (!browser.msie) {
x2 = 2 * fb.square * (.5 - fb.hsl[1]), // Render directly at half-resolution
y2 = 2 * fb.square * (.5 - fb.hsl[2]); var sz = Math.floor(size / 2);
var circles = [ calculateMask(sz, sz, function (x, y, c, a) {
{ x: x1, y: y1, r: fb.markerSize + 1, c: 'rgb(0,0,0,.4)', lw: 2 }, c = Math.round(c * 255);
{ x: x1, y: y1, r: fb.markerSize, c: '#fff', lw: 2 }, fb.ctxMask.fillStyle = 'rgba(' + c + ', ' + c + ', ' + c + ', ' + a + ')';
{ x: x2, y: y2, r: fb.markerSize + 1, c: 'rgb(0,0,0,.4)', lw: 2 }, fb.ctxMask.fillRect(x * 2 - sq - 1, y * 2 - sq - 1, 2, 2);
{ x: x2, y: y2, r: fb.markerSize, c: '#fff', lw: 2 }, });
]; }
// Method #3: vertical DXImageTransform gradient strips (IE).
// Update the overlay canvas. else {
fb.ctxOverlay.clearRect(-fb.mid, -fb.mid, sz, sz); var cache_last, cache, w = 6; // Each strip is 6 pixels wide.
for (i in circles) { var sizex = Math.floor(size / w);
var c = circles[i]; // 6 vertical pieces of gradient per strip.
fb.ctxOverlay.lineWidth = c.lw; calculateMask(sizex, 6, function (x, y, c, a) {
fb.ctxOverlay.strokeStyle = c.c; if (x == 0) {
fb.ctxOverlay.beginPath(); cache_last = cache;
fb.ctxOverlay.arc(c.x, c.y, c.r, 0, Math.PI * 2, true); cache = [];
fb.ctxOverlay.stroke(); }
} c = Math.round(c * 255);
} a = Math.round(a * 255);
// We can only start outputting gradients once we have two rows of pixels.
/** if (y > 0) {
* Update the markers and styles var c_last = cache_last[x][0], a_last = cache_last[x][1], color1 = fb.packDX(c_last, a_last), color2 = fb.packDX(c, a), y1 = Math.round(fb.mid + ((y - 1) * .333 - 1) * sq), y2 = Math.round(fb.mid + (y * .333 - 1) * sq);
*/ $('<div>').css({
fb.updateDisplay = function () { position: 'absolute',
// Determine whether labels/markers should invert. filter: "progid:DXImageTransform.Microsoft.Gradient(StartColorStr=" + color1 + ", EndColorStr=" + color2 + ", GradientType=0)",
fb.invert = (fb.rgb[0] * 0.3 + fb.rgb[1] * .59 + fb.rgb[2] * .11) <= 0.6; top: y1,
height: y2 - y1,
// Update the solid background fill. // Avoid right-edge sticking out.
fb.solidFill.css('backgroundColor', fb.pack(fb.HSLToRGB([fb.hsl[0], 1, 0.5]))); left: fb.mid + (x * w - sq - 1),
width: w - (x == sizex ? Math.round(w / 2) : 0)
// Draw markers }).appendTo(fb.cnvMask);
fb.drawMarkers(); }
cache.push([c, a]);
// Linked elements or callback });
if (typeof fb.callback == 'object') { }
// Set background/foreground color __debug && $('body').append('<div>drawMask ' + (+(new Date()) - tm) + 'ms');
$(fb.callback).css({ };
backgroundColor: fb.color, /**
color: fb.invert ? '#fff' : '#000' * Draw the selection markers.
}); */
fb.drawMarkers = function () {
// Change linked value // Determine marker dimensions
$(fb.callback).each(function() { var sz = options.width;
if ((typeof this.value == 'string') && this.value != fb.color) { var angle = fb.hsl[0] * 6.28, x1 = Math.sin(angle) * fb.radius, y1 = -Math.cos(angle) * fb.radius, x2 = 2 * fb.square * (.5 - fb.hsl[1]), y2 = 2 * fb.square * (.5 - fb.hsl[2]);
this.value = fb.color; var circles = [
} { x: x1, y: y1, r: fb.markerSize + 1, c: 'rgb(0,0,0,.4)', lw: 2 },
}); { x: x1, y: y1, r: fb.markerSize, c: '#fff', lw: 2 },
} { x: x2, y: y2, r: fb.markerSize + 1, c: 'rgb(0,0,0,.4)', lw: 2 },
else if (typeof fb.callback == 'function') { { x: x2, y: y2, r: fb.markerSize, c: '#fff', lw: 2 },
fb.callback.call(fb, fb.color); ];
} // Update the overlay canvas.
} fb.ctxOverlay.clearRect(-fb.mid, -fb.mid, sz, sz);
for (i in circles) {
/** var c = circles[i];
* Helper for returning coordinates relative to the center. fb.ctxOverlay.lineWidth = c.lw;
*/ fb.ctxOverlay.strokeStyle = c.c;
fb.widgetCoords = function (event) { fb.ctxOverlay.beginPath();
return { fb.ctxOverlay.arc(c.x, c.y, c.r, 0, Math.PI * 2, true);
x: event.pageX - fb.offset.left - fb.mid, fb.ctxOverlay.stroke();
y: event.pageY - fb.offset.top - fb.mid }
};
/**
* Update the markers and styles
*/
fb.updateDisplay = function () {
// Determine whether labels/markers should invert.
fb.invert = (fb.rgb[0] * 0.3 + fb.rgb[1] * .59 + fb.rgb[2] * .11) <= 0.6;
// Update the solid background fill.
fb.solidFill.css('backgroundColor', fb.pack(fb.HSLToRGB([fb.hsl[0], 1, 0.5])));
// Draw markers
fb.drawMarkers();
// Linked elements or callback
if (typeof fb.callback == 'object') {
// Set background/foreground color
$(fb.callback).css({
backgroundColor: fb.color,
color: fb.invert ? '#fff' : '#000'
});
// Change linked value
$(fb.callback).each(function () {
if ((typeof this.value == 'string') && this.value != fb.color) {
this.value = fb.color;
}
});
}
else if (typeof fb.callback == 'function') {
fb.callback.call(fb, fb.color);
}
};
/**
* Helper for returning coordinates relative to the center.
*/
fb.widgetCoords = function (event) {
return {
x: event.pageX - fb.offset.left - fb.mid,
y: event.pageY - fb.offset.top - fb.mid
};
};
/**
* Mousedown handler
*/
fb.mousedown = function (event) {
// Capture mouse
if (!$._farbtastic.dragging) {
$(document).bind('mousemove', fb.mousemove).bind('mouseup', fb.mouseup);
$._farbtastic.dragging = true;
}
// Update the stored offset for the widget.
fb.offset = $(container).offset();
// Check which area is being dragged
var pos = fb.widgetCoords(event);
fb.circleDrag = Math.max(Math.abs(pos.x), Math.abs(pos.y)) > (fb.square + 2);
// Process
fb.mousemove(event);
return false;
};
/**
* Mousemove handler
*/
fb.mousemove = function (event) {
// Get coordinates relative to color picker center
var pos = fb.widgetCoords(event);
// Set new HSL parameters
if (fb.circleDrag) {
var hue = Math.atan2(pos.x, -pos.y) / 6.28;
fb.setHSL([(hue + 1) % 1, fb.hsl[1], fb.hsl[2]]);
}
else {
var sat = Math.max(0, Math.min(1, -(pos.x / fb.square / 2) + .5));
var lum = Math.max(0, Math.min(1, -(pos.y / fb.square / 2) + .5));
fb.setHSL([fb.hsl[0], sat, lum]);
}
return false;
};
/**
* Mouseup handler
*/
fb.mouseup = function () {
// Uncapture mouse
$(document).unbind('mousemove', fb.mousemove);
$(document).unbind('mouseup', fb.mouseup);
$._farbtastic.dragging = false;
};
/* Various color utility functions */
fb.dec2hex = function (x) {
return (x < 16 ? '0' : '') + x.toString(16);
};
fb.packDX = function (c, a) {
return '#' + fb.dec2hex(a) + fb.dec2hex(c) + fb.dec2hex(c) + fb.dec2hex(c);
};
fb.pack = function (rgb) {
var r = Math.round(rgb[0] * 255);
var g = Math.round(rgb[1] * 255);
var b = Math.round(rgb[2] * 255);
return '#' + fb.dec2hex(r) + fb.dec2hex(g) + fb.dec2hex(b);
};
fb.unpack = function (color) {
if (color.length == 7) {
function x(i) {
return parseInt(color.substring(i, i + 2), 16) / 255;
}
return [x(1), x(3), x(5)];
}
else if (color.length == 4) {
function x(i) {
return parseInt(color.substring(i, i + 1), 16) / 15;
}
return [x(1), x(2), x(3)];
}
};
fb.HSLToRGB = function (hsl) {
var m1, m2, r, g, b;
var h = hsl[0], s = hsl[1], l = hsl[2];
m2 = (l <= 0.5) ? l * (s + 1) : l + s - l * s;
m1 = l * 2 - m2;
return [
this.hueToRGB(m1, m2, h + 0.33333),
this.hueToRGB(m1, m2, h),
this.hueToRGB(m1, m2, h - 0.33333)
];
};
fb.hueToRGB = function (m1, m2, h) {
h = (h + 1) % 1;
if (h * 6 < 1)
return m1 + (m2 - m1) * h * 6;
if (h * 2 < 1)
return m2;
if (h * 3 < 2)
return m1 + (m2 - m1) * (0.66666 - h) * 6;
return m1;
};
fb.RGBToHSL = function (rgb) {
var r = rgb[0], g = rgb[1], b = rgb[2], min = Math.min(r, g, b), max = Math.max(r, g, b), delta = max - min, h = 0, s = 0, l = (min + max) / 2;
if (l > 0 && l < 1) {
s = delta / (l < 0.5 ? (2 * l) : (2 - 2 * l));
}
if (delta > 0) {
if (max == r && max != g)
h += (g - b) / delta;
if (max == g && max != b)
h += (2 + (b - r) / delta);
if (max == b && max != r)
h += (4 + (r - g) / delta);
h /= 6;
}
return [h, s, l];
};
// Parse options.
if (!options.callback) {
options = { callback: options };
}
options = $.extend({
width: 300,
wheelWidth: (options.width || 300) / 10,
callback: null
}, options);
// Initialize.
fb.initWidget();
// Install mousedown handler (the others are set on the document on-demand)
$('canvas.farbtastic-overlay', container).mousedown(fb.mousedown);
// Set linked elements/callback
if (options.callback) {
fb.linkTo(options.callback);
}
// Set to gray.
fb.setColor('#808080');
}; };
}
/**
* Mousedown handler
*/
fb.mousedown = function (event) {
// Capture mouse
if (!$._farbtastic.dragging) {
$(document).bind('mousemove', fb.mousemove).bind('mouseup', fb.mouseup);
$._farbtastic.dragging = true;
}
// Update the stored offset for the widget.
fb.offset = $(container).offset();
// Check which area is being dragged
var pos = fb.widgetCoords(event);
fb.circleDrag = Math.max(Math.abs(pos.x), Math.abs(pos.y)) > (fb.square + 2);
// Process
fb.mousemove(event);
return false;
}
/**
* Mousemove handler
*/
fb.mousemove = function (event) {
// Get coordinates relative to color picker center
var pos = fb.widgetCoords(event);
// Set new HSL parameters
if (fb.circleDrag) {
var hue = Math.atan2(pos.x, -pos.y) / 6.28;
fb.setHSL([(hue + 1) % 1, fb.hsl[1], fb.hsl[2]]);
}
else {
var sat = Math.max(0, Math.min(1, -(pos.x / fb.square / 2) + .5));
var lum = Math.max(0, Math.min(1, -(pos.y / fb.square / 2) + .5));
fb.setHSL([fb.hsl[0], sat, lum]);
}
return false;
}
/**
* Mouseup handler
*/
fb.mouseup = function () {
// Uncapture mouse
$(document).unbind('mousemove', fb.mousemove);
$(document).unbind('mouseup', fb.mouseup);
$._farbtastic.dragging = false;
}
/* Various color utility functions */
fb.dec2hex = function (x) {
return (x < 16 ? '0' : '') + x.toString(16);
}
fb.packDX = function (c, a) {
return '#' + fb.dec2hex(a) + fb.dec2hex(c) + fb.dec2hex(c) + fb.dec2hex(c);
};
fb.pack = function (rgb) {
var r = Math.round(rgb[0] * 255);
var g = Math.round(rgb[1] * 255);
var b = Math.round(rgb[2] * 255);
return '#' + fb.dec2hex(r) + fb.dec2hex(g) + fb.dec2hex(b);
};
fb.unpack = function (color) {
if (color.length == 7) {
function x(i) {
return parseInt(color.substring(i, i + 2), 16) / 255;
}
return [ x(1), x(3), x(5) ];
}
else if (color.length == 4) {
function x(i) {
return parseInt(color.substring(i, i + 1), 16) / 15;
}
return [ x(1), x(2), x(3) ];
}
};
fb.HSLToRGB = function (hsl) {
var m1, m2, r, g, b;
var h = hsl[0], s = hsl[1], l = hsl[2];
m2 = (l <= 0.5) ? l * (s + 1) : l + s - l * s;
m1 = l * 2 - m2;
return [
this.hueToRGB(m1, m2, h + 0.33333),
this.hueToRGB(m1, m2, h),
this.hueToRGB(m1, m2, h - 0.33333)
];
};
fb.hueToRGB = function (m1, m2, h) {
h = (h + 1) % 1;
if (h * 6 < 1) return m1 + (m2 - m1) * h * 6;
if (h * 2 < 1) return m2;
if (h * 3 < 2) return m1 + (m2 - m1) * (0.66666 - h) * 6;
return m1;
};
fb.RGBToHSL = function (rgb) {
var r = rgb[0], g = rgb[1], b = rgb[2],
min = Math.min(r, g, b),
max = Math.max(r, g, b),
delta = max - min,
h = 0,
s = 0,
l = (min + max) / 2;
if (l > 0 && l < 1) {
s = delta / (l < 0.5 ? (2 * l) : (2 - 2 * l));
}
if (delta > 0) {
if (max == r && max != g) h += (g - b) / delta;
if (max == g && max != b) h += (2 + (b - r) / delta);
if (max == b && max != r) h += (4 + (r - g) / delta);
h /= 6;
}
return [h, s, l];
};
// Parse options.
if (!options.callback) {
options = { callback: options };
}
options = $.extend({
width: 300,
wheelWidth: (options.width || 300) / 10,
callback: null
}, options);
// Initialize.
fb.initWidget();
// Install mousedown handler (the others are set on the document on-demand)
$('canvas.farbtastic-overlay', container).mousedown(fb.mousedown);
// Set linked elements/callback
if (options.callback) {
fb.linkTo(options.callback);
}
// Set to gray.
fb.setColor('#808080');
}
})(jQuery); })(jQuery);

View file

@ -1,5 +1,4 @@
// WARNING: This file has been modified from the Original // WARNING: This file has been modified from the Original
/* /*
* Gritter for jQuery * Gritter for jQuery
* http://www.boedesign.com/ * http://www.boedesign.com/
@ -16,342 +15,267 @@
* notification title and text, and to treat plain strings as text instead of HTML (to avoid XSS * notification title and text, and to treat plain strings as text instead of HTML (to avoid XSS
* vunlerabilities). * vunlerabilities).
*/ */
(function ($) {
(function($){ /**
/** * Set it up as an object under the jQuery namespace
* Set it up as an object under the jQuery namespace */
*/ $.gritter = {};
$.gritter = {}; /**
* Set up global options that the user can over-ride
/** */
* Set up global options that the user can over-ride $.gritter.options = {
*/ position: '',
$.gritter.options = { class_name: '',
position: '', time: 3000 // hang on the screen for...
class_name: '', // could be set to 'gritter-light' to use white notifications };
time: 3000 // hang on the screen for... /**
} * Add a gritter notification to the screen
* @see Gritter#add();
/** */
* Add a gritter notification to the screen $.gritter.add = function (params) {
* @see Gritter#add(); try {
*/ return Gritter.add(params || {});
$.gritter.add = function(params){ }
catch (e) {
try { var err = 'Gritter Error: ' + e;
return Gritter.add(params || {}); (typeof (console) != 'undefined' && console.error) ?
} catch(e) { console.error(err, params) :
alert(err);
var err = 'Gritter Error: ' + e; }
(typeof(console) != 'undefined' && console.error) ? };
console.error(err, params) : /**
alert(err); * Remove a gritter notification from the screen
* @see Gritter#removeSpecific();
} */
$.gritter.remove = function (id, params) {
} Gritter.removeSpecific(id.split('gritter-item-')[1], params || {});
};
/** /**
* Remove a gritter notification from the screen * Remove all notifications
* @see Gritter#removeSpecific(); * @see Gritter#stop();
*/ */
$.gritter.remove = function(id, params){ $.gritter.removeAll = function (params) {
Gritter.removeSpecific(id.split('gritter-item-')[1], params || {}); Gritter.stop(params || {});
} };
/**
/** * Big fat Gritter object
* Remove all notifications * @constructor (not really since its object literal)
* @see Gritter#stop(); */
*/ var Gritter = {
$.gritter.removeAll = function(params){ // Public - options to over-ride with $.gritter.options in "add"
Gritter.stop(params || {}); time: '',
} // Private - no touchy the private parts
_custom_timer: 0,
/** _item_count: 0,
* Big fat Gritter object _is_setup: 0,
* @constructor (not really since its object literal) _tpl_wrap_top: '<div id="gritter-container" class="top"></div>',
*/ _tpl_wrap_bottom: '<div id="gritter-container" class="bottom"></div>',
var Gritter = { _tpl_close: '',
_tpl_title: $('<h3>').addClass('gritter-title'),
// Public - options to over-ride with $.gritter.options in "add" _tpl_item: ($('<div>').addClass('popup gritter-item')
time: '', .append($('<div>').addClass('popup-content')
.append($('<div>').addClass('gritter-content'))
// Private - no touchy the private parts .append($('<div>').addClass('gritter-close')
_custom_timer: 0, .append($('<i>').addClass('buttonicon buttonicon-times'))))),
_item_count: 0, /**
_is_setup: 0, * Add a gritter notification to the screen
_tpl_wrap_top: '<div id="gritter-container" class="top"></div>', * @param {Object} params The object that contains all the options for drawing the notification
_tpl_wrap_bottom: '<div id="gritter-container" class="bottom"></div>', * @return {Integer} The specific numeric id to that gritter notification
_tpl_close: '', */
_tpl_title: $('<h3>').addClass('gritter-title'), add: function (params) {
_tpl_item: ($('<div>').addClass('popup gritter-item') // Handle straight text
.append($('<div>').addClass('popup-content') if (typeof (params) == 'string') {
.append($('<div>').addClass('gritter-content')) params = { text: params };
.append($('<div>').addClass('gritter-close') }
.append($('<i>').addClass('buttonicon buttonicon-times'))))), // We might have some issues if we don't have a title or text!
if (!params.text) {
throw 'You must supply "text" parameter.';
/** }
* Add a gritter notification to the screen // Check the options and set them once
* @param {Object} params The object that contains all the options for drawing the notification if (!this._is_setup) {
* @return {Integer} The specific numeric id to that gritter notification this._runSetup();
*/ }
add: function(params){ // Basics
// Handle straight text var title = params.title, text = params.text, image = params.image || '', position = params.position || 'top', sticky = params.sticky || false, item_class = params.class_name || $.gritter.options.class_name, time_alive = params.time || '';
if(typeof(params) == 'string'){ this._verifyWrapper();
params = {text:params}; if (sticky) {
} item_class += " sticky";
}
// We might have some issues if we don't have a title or text! this._item_count++;
if(!params.text){ var number = this._item_count;
throw 'You must supply "text" parameter.'; // Assign callbacks
} $(['before_open', 'after_open', 'before_close', 'after_close']).each(function (i, val) {
Gritter['_' + val + '_' + number] = ($.isFunction(params[val])) ? params[val] : function () { };
// Check the options and set them once });
if(!this._is_setup){ // Reset
this._runSetup(); this._custom_timer = 0;
} // A custom fade time set
if (time_alive) {
// Basics this._custom_timer = time_alive;
var title = params.title, }
text = params.text, // String replacements on the template
image = params.image || '', if (title) {
position = params.position || 'top', title = this._tpl_title.clone().append(typeof title === 'string' ? document.createTextNode(title) : title);
sticky = params.sticky || false, }
item_class = params.class_name || $.gritter.options.class_name, else {
time_alive = params.time || ''; title = '';
}
this._verifyWrapper(); const tmp = this._tpl_item.clone();
tmp.attr('id', `gritter-item-${number}`);
if (sticky) { tmp.addClass(item_class);
item_class += " sticky"; tmp.find('.gritter-content')
} .append(title)
.append(typeof text === 'string' ? $('<p>').text(text) : text);
this._item_count++; // If it's false, don't show another gritter message
var number = this._item_count; if (this['_before_open_' + number]() === false) {
return false;
// Assign callbacks }
$(['before_open', 'after_open', 'before_close', 'after_close']).each(function(i, val){ if (['top', 'bottom'].indexOf(position) == -1) {
Gritter['_' + val + '_' + number] = ($.isFunction(params[val])) ? params[val] : function(){} position = 'top';
}); }
$('#gritter-container.' + position).append(tmp);
// Reset var item = $('#gritter-item-' + this._item_count);
this._custom_timer = 0; setTimeout(function () { item.addClass('popup-show'); }, 0);
Gritter['_after_open_' + number](item);
// A custom fade time set if (!sticky) {
if(time_alive){ this._setFadeTimer(item, number);
this._custom_timer = time_alive; // Bind the hover/unhover states
} $(item).on('mouseenter', function (event) {
Gritter._restoreItemIfFading($(this), number);
// String replacements on the template });
if(title){ $(item).on('mouseleave', function (event) {
title = this._tpl_title.clone().append( Gritter._setFadeTimer($(this), number);
typeof title === 'string' ? document.createTextNode(title) : title); });
}else{ }
title = ''; // Clicking (X) makes the perdy thing close
} $(item).find('.gritter-close').click(function () {
Gritter.removeSpecific(number, {}, null, true);
const tmp = this._tpl_item.clone(); });
tmp.attr('id', `gritter-item-${number}`); return number;
tmp.addClass(item_class); },
tmp.find('.gritter-content') /**
.append(title) * If we don't have any more gritter notifications, get rid of the wrapper using this check
.append(typeof text === 'string' ? $('<p>').text(text) : text); * @private
* @param {Integer} unique_id The ID of the element that was just deleted, use it for a callback
// If it's false, don't show another gritter message * @param {Object} e The jQuery element that we're going to perform the remove() action on
if(this['_before_open_' + number]() === false){ * @param {Boolean} manual_close Did we close the gritter dialog with the (X) button
return false; */
} _countRemoveWrapper: function (unique_id, e, manual_close) {
// Remove it then run the callback function
if (['top', 'bottom'].indexOf(position) == -1) { e.remove();
position = 'top'; this['_after_close_' + unique_id](e, manual_close);
} // Remove container if empty
$('#gritter-container').each(function () {
$('#gritter-container.' + position).append(tmp); if ($(this).find('.gritter-item').length == 0) {
$(this).remove();
var item = $('#gritter-item-' + this._item_count); }
});
setTimeout(function() { item.addClass('popup-show'); }, 0); },
Gritter['_after_open_' + number](item); /**
* Fade out an element after it's been on the screen for x amount of time
if(!sticky){ * @private
this._setFadeTimer(item, number); * @param {Object} e The jQuery element to get rid of
// Bind the hover/unhover states * @param {Integer} unique_id The id of the element to remove
$(item).on('mouseenter', function(event) { * @param {Object} params An optional list of params.
Gritter._restoreItemIfFading($(this), number); * @param {Boolean} unbind_events Unbind the mouseenter/mouseleave events if they click (X)
}); */
$(item).on('mouseleave', function(event) { _fade: function (e, unique_id, params, unbind_events) {
Gritter._setFadeTimer($(this), number); var params = params || {}, fade = (typeof (params.fade) != 'undefined') ? params.fade : true, manual_close = unbind_events;
}); this['_before_close_' + unique_id](e, manual_close);
} // If this is true, then we are coming from clicking the (X)
if (unbind_events) {
// Clicking (X) makes the perdy thing close e.unbind('mouseenter mouseleave');
$(item).find('.gritter-close').click(function(){ }
Gritter.removeSpecific(number, {}, null, true); // Fade it out or remove it
}); if (fade) {
e.removeClass('popup-show');
return number; setTimeout(function () {
Gritter._countRemoveWrapper(unique_id, e, manual_close);
}, }, 300);
}
/** else {
* If we don't have any more gritter notifications, get rid of the wrapper using this check this._countRemoveWrapper(unique_id, e);
* @private }
* @param {Integer} unique_id The ID of the element that was just deleted, use it for a callback },
* @param {Object} e The jQuery element that we're going to perform the remove() action on /**
* @param {Boolean} manual_close Did we close the gritter dialog with the (X) button * Remove a specific notification based on an ID
*/ * @param {Integer} unique_id The ID used to delete a specific notification
_countRemoveWrapper: function(unique_id, e, manual_close){ * @param {Object} params A set of options passed in to determine how to get rid of it
* @param {Object} e The jQuery element that we're "fading" then removing
// Remove it then run the callback function * @param {Boolean} unbind_events If we clicked on the (X) we set this to true to unbind mouseenter/mouseleave
e.remove(); */
this['_after_close_' + unique_id](e, manual_close); removeSpecific: function (unique_id, params, e, unbind_events) {
if (!e) {
// Remove container if empty var e = $('#gritter-item-' + unique_id);
$('#gritter-container').each(function() { }
if ($(this).find('.gritter-item').length == 0) { // We set the fourth param to let the _fade function know to
$(this).remove(); // unbind the "mouseleave" event. Once you click (X) there's no going back!
} this._fade(e, unique_id, params || {}, unbind_events);
}) },
}, /**
* If the item is fading out and we hover over it, restore it!
/** * @private
* Fade out an element after it's been on the screen for x amount of time * @param {Object} e The HTML element to remove
* @private * @param {Integer} unique_id The ID of the element
* @param {Object} e The jQuery element to get rid of */
* @param {Integer} unique_id The id of the element to remove _restoreItemIfFading: function (e, unique_id) {
* @param {Object} params An optional list of params. clearTimeout(this['_int_id_' + unique_id]);
* @param {Boolean} unbind_events Unbind the mouseenter/mouseleave events if they click (X) e.stop().css({ opacity: '', height: '' });
*/ },
_fade: function(e, unique_id, params, unbind_events){ /**
* Setup the global options - only once
var params = params || {}, * @private
fade = (typeof(params.fade) != 'undefined') ? params.fade : true, */
manual_close = unbind_events; _runSetup: function () {
for (opt in $.gritter.options) {
this['_before_close_' + unique_id](e, manual_close); this[opt] = $.gritter.options[opt];
}
// If this is true, then we are coming from clicking the (X) this._is_setup = 1;
if(unbind_events){ },
e.unbind('mouseenter mouseleave'); /**
} * Set the notification to fade out after a certain amount of time
* @private
// Fade it out or remove it * @param {Object} item The HTML element we're dealing with
if(fade){ * @param {Integer} unique_id The ID of the element
e.removeClass('popup-show'); */
setTimeout(function() { _setFadeTimer: function (item, unique_id) {
Gritter._countRemoveWrapper(unique_id, e, manual_close); var timer_str = (this._custom_timer) ? this._custom_timer : this.time;
}, 300) this['_int_id_' + unique_id] = setTimeout(function () {
} Gritter._fade(item, unique_id);
else { }, timer_str);
},
this._countRemoveWrapper(unique_id, e); /**
* Bring everything to a halt
} * @param {Object} params A list of callback functions to pass when all notifications are removed
*/
}, stop: function (params) {
// callbacks (if passed)
/** var before_close = ($.isFunction(params.before_close)) ? params.before_close : function () { };
* Remove a specific notification based on an ID var after_close = ($.isFunction(params.after_close)) ? params.after_close : function () { };
* @param {Integer} unique_id The ID used to delete a specific notification var wrap = $('#gritter-container');
* @param {Object} params A set of options passed in to determine how to get rid of it before_close(wrap);
* @param {Object} e The jQuery element that we're "fading" then removing wrap.fadeOut(function () {
* @param {Boolean} unbind_events If we clicked on the (X) we set this to true to unbind mouseenter/mouseleave $(this).remove();
*/ after_close();
removeSpecific: function(unique_id, params, e, unbind_events){ });
},
if(!e){ /**
var e = $('#gritter-item-' + unique_id); * A check to make sure we have something to wrap our notices with
} * @private
*/
// We set the fourth param to let the _fade function know to _verifyWrapper: function () {
// unbind the "mouseleave" event. Once you click (X) there's no going back! if ($('#gritter-container.top').length === 0) {
this._fade(e, unique_id, params || {}, unbind_events); $('#editorcontainerbox').append(this._tpl_wrap_top);
}
}, if ($('#gritter-container.bottom').length === 0) {
$('#editorcontainerbox').append(this._tpl_wrap_bottom);
/** }
* If the item is fading out and we hover over it, restore it! }
* @private };
* @param {Object} e The HTML element to remove
* @param {Integer} unique_id The ID of the element
*/
_restoreItemIfFading: function(e, unique_id){
clearTimeout(this['_int_id_' + unique_id]);
e.stop().css({ opacity: '', height: '' });
},
/**
* Setup the global options - only once
* @private
*/
_runSetup: function(){
for(opt in $.gritter.options){
this[opt] = $.gritter.options[opt];
}
this._is_setup = 1;
},
/**
* Set the notification to fade out after a certain amount of time
* @private
* @param {Object} item The HTML element we're dealing with
* @param {Integer} unique_id The ID of the element
*/
_setFadeTimer: function(item, unique_id){
var timer_str = (this._custom_timer) ? this._custom_timer : this.time;
this['_int_id_' + unique_id] = setTimeout(function(){
Gritter._fade(item, unique_id);
}, timer_str);
},
/**
* Bring everything to a halt
* @param {Object} params A list of callback functions to pass when all notifications are removed
*/
stop: function(params){
// callbacks (if passed)
var before_close = ($.isFunction(params.before_close)) ? params.before_close : function(){};
var after_close = ($.isFunction(params.after_close)) ? params.after_close : function(){};
var wrap = $('#gritter-container');
before_close(wrap);
wrap.fadeOut(function(){
$(this).remove();
after_close();
});
},
/**
* A check to make sure we have something to wrap our notices with
* @private
*/
_verifyWrapper: function(){
if ($('#gritter-container.top').length === 0) {
$('#editorcontainerbox').append(this._tpl_wrap_top);
}
if ($('#gritter-container.bottom').length === 0) {
$('#editorcontainerbox').append(this._tpl_wrap_bottom);
}
}
}
})(jQuery); })(jQuery);
// For Emacs: // For Emacs:
// Local Variables: // Local Variables:
// tab-width: 2 // tab-width: 2
// indent-tabs-mode: t // indent-tabs-mode: t
// End: // End:
// vi: ts=2:noet:sw=2 // vi: ts=2:noet:sw=2

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,212 +1,187 @@
// WARNING: This file has been modified from the Original // WARNING: This file has been modified from the Original
// TODO: Nice Select seems relatively abandoned, we should consider other options. // TODO: Nice Select seems relatively abandoned, we should consider other options.
/* jQuery Nice Select - v1.1.0 /* jQuery Nice Select - v1.1.0
https://github.com/hernansartorio/jquery-nice-select https://github.com/hernansartorio/jquery-nice-select
Made by Hernán Sartorio */ Made by Hernán Sartorio */
(function ($) {
(function($) { $.fn.niceSelect = function (method) {
// Methods
$.fn.niceSelect = function(method) { if (typeof method == 'string') {
if (method == 'update') {
// Methods this.each(function () {
if (typeof method == 'string') { var $select = $(this);
if (method == 'update') { var $dropdown = $(this).next('.nice-select');
this.each(function() { var open = $dropdown.hasClass('open');
var $select = $(this); if ($dropdown.length) {
var $dropdown = $(this).next('.nice-select'); $dropdown.remove();
var open = $dropdown.hasClass('open'); create_nice_select($select);
if (open) {
if ($dropdown.length) { $select.next().trigger('click');
$dropdown.remove(); }
create_nice_select($select); }
});
if (open) { }
$select.next().trigger('click'); else if (method == 'destroy') {
this.each(function () {
var $select = $(this);
var $dropdown = $(this).next('.nice-select');
if ($dropdown.length) {
$dropdown.remove();
$select.css('display', '');
}
});
if ($('.nice-select').length == 0) {
$(document).off('.nice_select');
}
}
else {
console.log('Method "' + method + '" does not exist.');
}
return this;
}
// Hide native select
this.hide();
// Create custom markup
this.each(function () {
var $select = $(this);
if (!$select.next().hasClass('nice-select')) {
create_nice_select($select);
} }
}
}); });
} else if (method == 'destroy') { function create_nice_select($select) {
this.each(function() { $select.after($('<div></div>')
var $select = $(this); .addClass('nice-select')
var $dropdown = $(this).next('.nice-select'); .addClass($select.attr('class') || '')
.addClass($select.attr('disabled') ? 'disabled' : '')
if ($dropdown.length) { .attr('tabindex', $select.attr('disabled') ? null : '0')
$dropdown.remove(); .html('<span class="current"></span><ul class="list thin-scrollbar"></ul>'));
$select.css('display', ''); var $dropdown = $select.next();
} var $options = $select.find('option');
var $selected = $select.find('option:selected');
$dropdown.find('.current').html($selected.data('display') || $selected.text());
$options.each(function (i) {
var $option = $(this);
var display = $option.data('display');
$dropdown.find('ul').append($('<li></li>')
.attr('data-value', $option.val())
.attr('data-display', (display || null))
.addClass('option' +
($option.is(':selected') ? ' selected' : '') +
($option.is(':disabled') ? ' disabled' : ''))
.html($option.text()));
});
}
/* Event listeners */
// Unbind existing events in case that the plugin has been initialized before
$(document).off('.nice_select');
// Open/close
$(document).on('click.nice_select', '.nice-select', function (event) {
var $dropdown = $(this);
$('.nice-select').not($dropdown).removeClass('open');
$dropdown.toggleClass('open');
if ($dropdown.hasClass('open')) {
$dropdown.find('.option');
$dropdown.find('.focus').removeClass('focus');
$dropdown.find('.selected').addClass('focus');
if ($dropdown.closest('.toolbar').length > 0) {
$dropdown.find('.list').css('left', $dropdown.offset().left);
$dropdown.find('.list').css('top', $dropdown.offset().top + $dropdown.outerHeight());
$dropdown.find('.list').css('min-width', $dropdown.outerWidth() + 'px');
}
$listHeight = $dropdown.find('.list').outerHeight();
$top = $dropdown.parent().offset().top;
$bottom = $('body').height() - $top;
$maxListHeight = $bottom - $dropdown.outerHeight() - 20;
if ($maxListHeight < 200) {
$dropdown.addClass('reverse');
$maxListHeight = 250;
}
else {
$dropdown.removeClass('reverse');
}
$dropdown.find('.list').css('max-height', $maxListHeight + 'px');
}
else {
$dropdown.focus();
}
}); });
if ($('.nice-select').length == 0) { // Close when clicking outside
$(document).off('.nice_select'); $(document).on('click.nice_select', function (event) {
if ($(event.target).closest('.nice-select').length === 0) {
$('.nice-select').removeClass('open').find('.option');
}
});
// Option click
$(document).on('click.nice_select', '.nice-select .option:not(.disabled)', function (event) {
var $option = $(this);
var $dropdown = $option.closest('.nice-select');
$dropdown.find('.selected').removeClass('selected');
$option.addClass('selected');
var text = $option.data('display') || $option.text();
$dropdown.find('.current').text(text);
$dropdown.prev('select').val($option.data('value')).trigger('change');
});
// Keyboard events
$(document).on('keydown.nice_select', '.nice-select', function (event) {
var $dropdown = $(this);
var $focused_option = $($dropdown.find('.focus') || $dropdown.find('.list .option.selected'));
// Space or Enter
if (event.keyCode == 32 || event.keyCode == 13) {
if ($dropdown.hasClass('open')) {
$focused_option.trigger('click');
}
else {
$dropdown.trigger('click');
}
return false;
// Down
}
else if (event.keyCode == 40) {
if (!$dropdown.hasClass('open')) {
$dropdown.trigger('click');
}
else {
var $next = $focused_option.nextAll('.option:not(.disabled)').first();
if ($next.length > 0) {
$dropdown.find('.focus').removeClass('focus');
$next.addClass('focus');
}
}
return false;
// Up
}
else if (event.keyCode == 38) {
if (!$dropdown.hasClass('open')) {
$dropdown.trigger('click');
}
else {
var $prev = $focused_option.prevAll('.option:not(.disabled)').first();
if ($prev.length > 0) {
$dropdown.find('.focus').removeClass('focus');
$prev.addClass('focus');
}
}
return false;
// Esc
}
else if (event.keyCode == 27) {
if ($dropdown.hasClass('open')) {
$dropdown.trigger('click');
}
// Tab
}
else if (event.keyCode == 9) {
if ($dropdown.hasClass('open')) {
return false;
}
}
});
// Detect CSS pointer-events support, for IE <= 10. From Modernizr.
var style = document.createElement('a').style;
style.cssText = 'pointer-events:auto';
if (style.pointerEvents !== 'auto') {
$('html').addClass('no-csspointerevents');
} }
} else { return this;
console.log('Method "' + method + '" does not exist.') };
}
return this;
}
// Hide native select
this.hide();
// Create custom markup
this.each(function() {
var $select = $(this);
if (!$select.next().hasClass('nice-select')) {
create_nice_select($select);
}
});
function create_nice_select($select) {
$select.after($('<div></div>')
.addClass('nice-select')
.addClass($select.attr('class') || '')
.addClass($select.attr('disabled') ? 'disabled' : '')
.attr('tabindex', $select.attr('disabled') ? null : '0')
.html('<span class="current"></span><ul class="list thin-scrollbar"></ul>')
);
var $dropdown = $select.next();
var $options = $select.find('option');
var $selected = $select.find('option:selected');
$dropdown.find('.current').html($selected.data('display') || $selected.text());
$options.each(function(i) {
var $option = $(this);
var display = $option.data('display');
$dropdown.find('ul').append($('<li></li>')
.attr('data-value', $option.val())
.attr('data-display', (display || null))
.addClass('option' +
($option.is(':selected') ? ' selected' : '') +
($option.is(':disabled') ? ' disabled' : ''))
.html($option.text())
);
});
}
/* Event listeners */
// Unbind existing events in case that the plugin has been initialized before
$(document).off('.nice_select');
// Open/close
$(document).on('click.nice_select', '.nice-select', function(event) {
var $dropdown = $(this);
$('.nice-select').not($dropdown).removeClass('open');
$dropdown.toggleClass('open');
if ($dropdown.hasClass('open')) {
$dropdown.find('.option');
$dropdown.find('.focus').removeClass('focus');
$dropdown.find('.selected').addClass('focus');
if ($dropdown.closest('.toolbar').length > 0) {
$dropdown.find('.list').css('left', $dropdown.offset().left);
$dropdown.find('.list').css('top', $dropdown.offset().top + $dropdown.outerHeight());
$dropdown.find('.list').css('min-width', $dropdown.outerWidth() + 'px');
}
$listHeight = $dropdown.find('.list').outerHeight();
$top = $dropdown.parent().offset().top;
$bottom = $('body').height() - $top;
$maxListHeight = $bottom - $dropdown.outerHeight() - 20;
if ($maxListHeight < 200) {
$dropdown.addClass('reverse');
$maxListHeight = 250;
} else {
$dropdown.removeClass('reverse')
}
$dropdown.find('.list').css('max-height', $maxListHeight + 'px');
} else {
$dropdown.focus();
}
});
// Close when clicking outside
$(document).on('click.nice_select', function(event) {
if ($(event.target).closest('.nice-select').length === 0) {
$('.nice-select').removeClass('open').find('.option');
}
});
// Option click
$(document).on('click.nice_select', '.nice-select .option:not(.disabled)', function(event) {
var $option = $(this);
var $dropdown = $option.closest('.nice-select');
$dropdown.find('.selected').removeClass('selected');
$option.addClass('selected');
var text = $option.data('display') || $option.text();
$dropdown.find('.current').text(text);
$dropdown.prev('select').val($option.data('value')).trigger('change');
});
// Keyboard events
$(document).on('keydown.nice_select', '.nice-select', function(event) {
var $dropdown = $(this);
var $focused_option = $($dropdown.find('.focus') || $dropdown.find('.list .option.selected'));
// Space or Enter
if (event.keyCode == 32 || event.keyCode == 13) {
if ($dropdown.hasClass('open')) {
$focused_option.trigger('click');
} else {
$dropdown.trigger('click');
}
return false;
// Down
} else if (event.keyCode == 40) {
if (!$dropdown.hasClass('open')) {
$dropdown.trigger('click');
} else {
var $next = $focused_option.nextAll('.option:not(.disabled)').first();
if ($next.length > 0) {
$dropdown.find('.focus').removeClass('focus');
$next.addClass('focus');
}
}
return false;
// Up
} else if (event.keyCode == 38) {
if (!$dropdown.hasClass('open')) {
$dropdown.trigger('click');
} else {
var $prev = $focused_option.prevAll('.option:not(.disabled)').first();
if ($prev.length > 0) {
$dropdown.find('.focus').removeClass('focus');
$prev.addClass('focus');
}
}
return false;
// Esc
} else if (event.keyCode == 27) {
if ($dropdown.hasClass('open')) {
$dropdown.trigger('click');
}
// Tab
} else if (event.keyCode == 9) {
if ($dropdown.hasClass('open')) {
return false;
}
}
});
// Detect CSS pointer-events support, for IE <= 10. From Modernizr.
var style = document.createElement('a').style;
style.cssText = 'pointer-events:auto';
if (style.pointerEvents !== 'auto') {
$('html').addClass('no-csspointerevents');
}
return this;
};
}(jQuery)); }(jQuery));

View file

@ -1,7 +1,6 @@
'use strict'; 'use strict';
window.customStart = () => { window.customStart = () => {
// define your javascript here // define your javascript here
// jquery is available - except index.js // jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/ // you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
}; };

View file

@ -1,7 +1,6 @@
'use strict'; 'use strict';
window.customStart = () => { window.customStart = () => {
$('#pad_title').show(); $('#pad_title').show();
$('.buttonicon').mousedown(function () { $(this).parent().addClass('pressed'); }); $('.buttonicon').mousedown(function () { $(this).parent().addClass('pressed'); });
$('.buttonicon').mouseup(function () { $(this).parent().removeClass('pressed'); }); $('.buttonicon').mouseup(function () { $(this).parent().removeClass('pressed'); });
}; };

View file

@ -1,4 +1,3 @@
'use strict'; 'use strict';
window.customStart = () => { window.customStart = () => {
}; };

View file

@ -1,7 +1,6 @@
'use strict'; 'use strict';
window.customStart = () => { window.customStart = () => {
// define your javascript here // define your javascript here
// jquery is available - except index.js // jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/ // you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
}; };

View file

@ -1,7 +1,6 @@
'use strict'; 'use strict';
window.customStart = () => { window.customStart = () => {
// define your javascript here // define your javascript here
// jquery is available - except index.js // jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/ // you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
}; };

View file

@ -1,7 +1,6 @@
'use strict'; 'use strict';
window.customStart = () => { window.customStart = () => {
// define your javascript here // define your javascript here
// jquery is available - except index.js // jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/ // you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
}; };

View file

@ -1,67 +1,59 @@
import assert$0 from "assert";
import * as common from "../common.js";
import * as exportEtherpad from "../../../node/utils/ExportEtherpad.js";
import * as padManager from "../../../node/db/PadManager.js";
import * as plugins from "../../../static/js/pluginfw/plugin_defs.js";
import * as readOnlyManager from "../../../node/db/ReadOnlyManager.js";
'use strict'; 'use strict';
const assert = assert$0.strict;
const assert = require('assert').strict;
const common = require('../common');
const exportEtherpad = require('../../../node/utils/ExportEtherpad');
const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const readOnlyManager = require('../../../node/db/ReadOnlyManager');
describe(__filename, function () { describe(__filename, function () {
let padId; let padId;
beforeEach(async function () {
beforeEach(async function () { padId = common.randomString();
padId = common.randomString(); assert(!await padManager.doesPadExist(padId));
assert(!await padManager.doesPadExist(padId));
});
describe('exportEtherpadAdditionalContent', function () {
let hookBackup;
before(async function () {
hookBackup = plugins.hooks.exportEtherpadAdditionalContent || [];
plugins.hooks.exportEtherpadAdditionalContent = [{hook_fn: () => ['custom']}];
}); });
describe('exportEtherpadAdditionalContent', function () {
after(async function () { let hookBackup;
plugins.hooks.exportEtherpadAdditionalContent = hookBackup; before(async function () {
hookBackup = plugins.hooks.exportEtherpadAdditionalContent || [];
plugins.hooks.exportEtherpadAdditionalContent = [{ hook_fn: () => ['custom'] }];
});
after(async function () {
plugins.hooks.exportEtherpadAdditionalContent = hookBackup;
});
it('exports custom records', async function () {
const pad = await padManager.getPad(padId);
await pad.db.set(`custom:${padId}`, 'a');
await pad.db.set(`custom:${padId}:`, 'b');
await pad.db.set(`custom:${padId}:foo`, 'c');
const data = await exportEtherpad.getPadRaw(pad.id, null);
assert.equal(data[`custom:${padId}`], 'a');
assert.equal(data[`custom:${padId}:`], 'b');
assert.equal(data[`custom:${padId}:foo`], 'c');
});
it('export from read-only pad uses read-only ID', async function () {
const pad = await padManager.getPad(padId);
const readOnlyId = await readOnlyManager.getReadOnlyId(padId);
await pad.db.set(`custom:${padId}`, 'a');
await pad.db.set(`custom:${padId}:`, 'b');
await pad.db.set(`custom:${padId}:foo`, 'c');
const data = await exportEtherpad.getPadRaw(padId, readOnlyId);
assert.equal(data[`custom:${readOnlyId}`], 'a');
assert.equal(data[`custom:${readOnlyId}:`], 'b');
assert.equal(data[`custom:${readOnlyId}:foo`], 'c');
assert(!(`custom:${padId}` in data));
assert(!(`custom:${padId}:` in data));
assert(!(`custom:${padId}:foo` in data));
});
it('does not export records from pad with similar ID', async function () {
const pad = await padManager.getPad(padId);
await pad.db.set(`custom:${padId}x`, 'a');
await pad.db.set(`custom:${padId}x:`, 'b');
await pad.db.set(`custom:${padId}x:foo`, 'c');
const data = await exportEtherpad.getPadRaw(pad.id, null);
assert(!(`custom:${padId}x` in data));
assert(!(`custom:${padId}x:` in data));
assert(!(`custom:${padId}x:foo` in data));
});
}); });
it('exports custom records', async function () {
const pad = await padManager.getPad(padId);
await pad.db.set(`custom:${padId}`, 'a');
await pad.db.set(`custom:${padId}:`, 'b');
await pad.db.set(`custom:${padId}:foo`, 'c');
const data = await exportEtherpad.getPadRaw(pad.id, null);
assert.equal(data[`custom:${padId}`], 'a');
assert.equal(data[`custom:${padId}:`], 'b');
assert.equal(data[`custom:${padId}:foo`], 'c');
});
it('export from read-only pad uses read-only ID', async function () {
const pad = await padManager.getPad(padId);
const readOnlyId = await readOnlyManager.getReadOnlyId(padId);
await pad.db.set(`custom:${padId}`, 'a');
await pad.db.set(`custom:${padId}:`, 'b');
await pad.db.set(`custom:${padId}:foo`, 'c');
const data = await exportEtherpad.getPadRaw(padId, readOnlyId);
assert.equal(data[`custom:${readOnlyId}`], 'a');
assert.equal(data[`custom:${readOnlyId}:`], 'b');
assert.equal(data[`custom:${readOnlyId}:foo`], 'c');
assert(!(`custom:${padId}` in data));
assert(!(`custom:${padId}:` in data));
assert(!(`custom:${padId}:foo` in data));
});
it('does not export records from pad with similar ID', async function () {
const pad = await padManager.getPad(padId);
await pad.db.set(`custom:${padId}x`, 'a');
await pad.db.set(`custom:${padId}x:`, 'b');
await pad.db.set(`custom:${padId}x:foo`, 'c');
const data = await exportEtherpad.getPadRaw(pad.id, null);
assert(!(`custom:${padId}x` in data));
assert(!(`custom:${padId}x:` in data));
assert(!(`custom:${padId}x:foo` in data));
});
});
}); });

View file

@ -1,208 +1,185 @@
import assert$0 from "assert";
import * as authorManager from "../../../node/db/AuthorManager.js";
import * as db from "../../../node/db/DB.js";
import * as importEtherpad from "../../../node/utils/ImportEtherpad.js";
import * as padManager from "../../../node/db/PadManager.js";
import * as plugins from "../../../static/js/pluginfw/plugin_defs.js";
import * as padUtils from "../../../static/js/pad_utils.js";
'use strict'; 'use strict';
const assert = assert$0.strict;
const assert = require('assert').strict; const { randomString } = padUtils;
const authorManager = require('../../../node/db/AuthorManager');
const db = require('../../../node/db/DB');
const importEtherpad = require('../../../node/utils/ImportEtherpad');
const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const {randomString} = require('../../../static/js/pad_utils');
describe(__filename, function () { describe(__filename, function () {
let padId; let padId;
const makeAuthorId = () => `a.${randomString(16)}`;
const makeAuthorId = () => `a.${randomString(16)}`; const makeExport = (authorId) => ({
'pad:testing': {
const makeExport = (authorId) => ({ atext: {
'pad:testing': { text: 'foo\n',
atext: { attribs: '|1+4',
text: 'foo\n', },
attribs: '|1+4', pool: {
}, numToAttrib: {},
pool: { nextNum: 0,
numToAttrib: {}, },
nextNum: 0, head: 0,
}, savedRevisions: [],
head: 0,
savedRevisions: [],
},
[`globalAuthor:${authorId}`]: {
colorId: '#000000',
name: 'new',
timestamp: 1598747784631,
padIDs: 'testing',
},
'pad:testing:revs:0': {
changeset: 'Z:1>3+3$foo',
meta: {
author: '',
timestamp: 1597632398288,
pool: {
numToAttrib: {},
nextNum: 0,
}, },
atext: { [`globalAuthor:${authorId}`]: {
text: 'foo\n', colorId: '#000000',
attribs: '|1+4', name: 'new',
timestamp: 1598747784631,
padIDs: 'testing',
}, },
}, 'pad:testing:revs:0': {
}, changeset: 'Z:1>3+3$foo',
}); meta: {
author: '',
beforeEach(async function () { timestamp: 1597632398288,
padId = randomString(10); pool: {
assert(!await padManager.doesPadExist(padId)); numToAttrib: {},
}); nextNum: 0,
},
it('unknown db records are ignored', async function () { atext: {
const badKey = `maliciousDbKey${randomString(10)}`; text: 'foo\n',
await importEtherpad.setPadRaw(padId, JSON.stringify({ attribs: '|1+4',
[badKey]: 'value', },
...makeExport(makeAuthorId()), },
})); },
assert(await db.get(badKey) == null); });
});
it('changes are all or nothing', async function () {
const authorId = makeAuthorId();
const data = makeExport(authorId);
data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0'];
delete data['pad:testing:revs:0'];
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
assert(!await authorManager.doesAuthorExist(authorId));
assert(!await padManager.doesPadExist(padId));
});
describe('author pad IDs', function () {
let existingAuthorId;
let newAuthorId;
beforeEach(async function () { beforeEach(async function () {
existingAuthorId = (await authorManager.createAuthor('existing')).authorID; padId = randomString(10);
assert(await authorManager.doesAuthorExist(existingAuthorId)); assert(!await padManager.doesPadExist(padId));
assert.deepEqual((await authorManager.listPadsOfAuthor(existingAuthorId)).padIDs, []);
newAuthorId = makeAuthorId();
assert.notEqual(newAuthorId, existingAuthorId);
assert(!await authorManager.doesAuthorExist(newAuthorId));
}); });
it('unknown db records are ignored', async function () {
it('author does not yet exist', async function () { const badKey = `maliciousDbKey${randomString(10)}`;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId))); await importEtherpad.setPadRaw(padId, JSON.stringify({
assert(await authorManager.doesAuthorExist(newAuthorId)); [badKey]: 'value',
const author = await authorManager.getAuthor(newAuthorId); ...makeExport(makeAuthorId()),
assert.equal(author.name, 'new'); }));
assert.equal(author.colorId, '#000000'); assert(await db.get(badKey) == null);
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
}); });
it('changes are all or nothing', async function () {
it('author already exists, no pads', async function () {
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
});
it('author already exists, on different pad', async function () {
const otherPadId = randomString(10);
await authorManager.addPad(existingAuthorId, otherPadId);
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual(
(await authorManager.listPadsOfAuthor(newAuthorId)).padIDs.sort(),
[otherPadId, padId].sort());
});
it('author already exists, on same pad', async function () {
await authorManager.addPad(existingAuthorId, padId);
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
});
});
describe('enforces consistent pad ID', function () {
it('pad record has different pad ID', async function () {
const data = makeExport(makeAuthorId());
data['pad:differentPadId'] = data['pad:testing'];
delete data['pad:testing'];
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
it('globalAuthor record has different pad ID', async function () {
const authorId = makeAuthorId();
const data = makeExport(authorId);
data[`globalAuthor:${authorId}`].padIDs = 'differentPadId';
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
it('pad rev record has different pad ID', async function () {
const data = makeExport(makeAuthorId());
data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0'];
delete data['pad:testing:revs:0'];
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
});
describe('order of records does not matter', function () {
for (const perm of [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]) {
it(JSON.stringify(perm), async function () {
const authorId = makeAuthorId(); const authorId = makeAuthorId();
const records = Object.entries(makeExport(authorId)); const data = makeExport(authorId);
assert.equal(records.length, 3); data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0'];
await importEtherpad.setPadRaw( delete data['pad:testing:revs:0'];
padId, JSON.stringify(Object.fromEntries(perm.map((i) => records[i])))); assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
assert.deepEqual((await authorManager.listPadsOfAuthor(authorId)).padIDs, [padId]); assert(!await authorManager.doesAuthorExist(authorId));
const pad = await padManager.getPad(padId); assert(!await padManager.doesPadExist(padId));
assert.equal(pad.text(), 'foo\n');
});
}
});
describe('exportEtherpadAdditionalContent', function () {
let hookBackup;
before(async function () {
hookBackup = plugins.hooks.exportEtherpadAdditionalContent || [];
plugins.hooks.exportEtherpadAdditionalContent = [{hook_fn: () => ['custom']}];
}); });
describe('author pad IDs', function () {
after(async function () { let existingAuthorId;
plugins.hooks.exportEtherpadAdditionalContent = hookBackup; let newAuthorId;
beforeEach(async function () {
existingAuthorId = (await authorManager.createAuthor('existing')).authorID;
assert(await authorManager.doesAuthorExist(existingAuthorId));
assert.deepEqual((await authorManager.listPadsOfAuthor(existingAuthorId)).padIDs, []);
newAuthorId = makeAuthorId();
assert.notEqual(newAuthorId, existingAuthorId);
assert(!await authorManager.doesAuthorExist(newAuthorId));
});
it('author does not yet exist', async function () {
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'new');
assert.equal(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
});
it('author already exists, no pads', async function () {
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
});
it('author already exists, on different pad', async function () {
const otherPadId = randomString(10);
await authorManager.addPad(existingAuthorId, otherPadId);
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs.sort(), [otherPadId, padId].sort());
});
it('author already exists, on same pad', async function () {
await authorManager.addPad(existingAuthorId, padId);
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
});
}); });
describe('enforces consistent pad ID', function () {
it('imports from custom prefix', async function () { it('pad record has different pad ID', async function () {
await importEtherpad.setPadRaw(padId, JSON.stringify({ const data = makeExport(makeAuthorId());
...makeExport(makeAuthorId()), data['pad:differentPadId'] = data['pad:testing'];
'custom:testing': 'a', delete data['pad:testing'];
'custom:testing:foo': 'b', assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
})); });
const pad = await padManager.getPad(padId); it('globalAuthor record has different pad ID', async function () {
assert.equal(await pad.db.get(`custom:${padId}`), 'a'); const authorId = makeAuthorId();
assert.equal(await pad.db.get(`custom:${padId}:foo`), 'b'); const data = makeExport(authorId);
data[`globalAuthor:${authorId}`].padIDs = 'differentPadId';
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
it('pad rev record has different pad ID', async function () {
const data = makeExport(makeAuthorId());
data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0'];
delete data['pad:testing:revs:0'];
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
}); });
describe('order of records does not matter', function () {
it('rejects records for pad with similar ID', async function () { for (const perm of [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]) {
await assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify({ it(JSON.stringify(perm), async function () {
...makeExport(makeAuthorId()), const authorId = makeAuthorId();
'custom:testingx': 'x', const records = Object.entries(makeExport(authorId));
})), /unexpected pad ID/); assert.equal(records.length, 3);
assert(await db.get(`custom:${padId}x`) == null); await importEtherpad.setPadRaw(padId, JSON.stringify(Object.fromEntries(perm.map((i) => records[i]))));
await assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify({ assert.deepEqual((await authorManager.listPadsOfAuthor(authorId)).padIDs, [padId]);
...makeExport(makeAuthorId()), const pad = await padManager.getPad(padId);
'custom:testingx:foo': 'x', assert.equal(pad.text(), 'foo\n');
})), /unexpected pad ID/); });
assert(await db.get(`custom:${padId}x:foo`) == null); }
});
describe('exportEtherpadAdditionalContent', function () {
let hookBackup;
before(async function () {
hookBackup = plugins.hooks.exportEtherpadAdditionalContent || [];
plugins.hooks.exportEtherpadAdditionalContent = [{ hook_fn: () => ['custom'] }];
});
after(async function () {
plugins.hooks.exportEtherpadAdditionalContent = hookBackup;
});
it('imports from custom prefix', async function () {
await importEtherpad.setPadRaw(padId, JSON.stringify({
...makeExport(makeAuthorId()),
'custom:testing': 'a',
'custom:testing:foo': 'b',
}));
const pad = await padManager.getPad(padId);
assert.equal(await pad.db.get(`custom:${padId}`), 'a');
assert.equal(await pad.db.get(`custom:${padId}:foo`), 'b');
});
it('rejects records for pad with similar ID', async function () {
await assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify({
...makeExport(makeAuthorId()),
'custom:testingx': 'x',
})), /unexpected pad ID/);
assert(await db.get(`custom:${padId}x`) == null);
await assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify({
...makeExport(makeAuthorId()),
'custom:testingx:foo': 'x',
})), /unexpected pad ID/);
assert(await db.get(`custom:${padId}x:foo`) == null);
});
}); });
});
}); });

View file

@ -1,134 +1,120 @@
import * as Pad from "../../../node/db/Pad.js";
import assert$0 from "assert";
import * as authorManager from "../../../node/db/AuthorManager.js";
import * as common from "../common.js";
import * as padManager from "../../../node/db/PadManager.js";
import * as plugins from "../../../static/js/pluginfw/plugin_defs.js";
import * as settings from "../../../node/utils/Settings.js";
'use strict'; 'use strict';
const assert = assert$0.strict;
const Pad = require('../../../node/db/Pad');
const assert = require('assert').strict;
const authorManager = require('../../../node/db/AuthorManager');
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../../node/utils/Settings');
describe(__filename, function () { describe(__filename, function () {
const backups = {}; const backups = {};
let pad; let pad;
let padId; let padId;
before(async function () {
before(async function () { backups.hooks = {
backups.hooks = { padDefaultContent: plugins.hooks.padDefaultContent,
padDefaultContent: plugins.hooks.padDefaultContent, };
}; backups.defaultPadText = settings.defaultPadText;
backups.defaultPadText = settings.defaultPadText;
});
beforeEach(async function () {
backups.hooks.padDefaultContent = [];
padId = common.randomString();
assert(!(await padManager.doesPadExist(padId)));
});
afterEach(async function () {
Object.assign(plugins.hooks, backups.hooks);
if (pad != null) await pad.remove();
pad = null;
});
describe('cleanText', function () {
const testCases = [
['', ''],
['\n', '\n'],
['x', 'x'],
['x\n', 'x\n'],
['x\ny\n', 'x\ny\n'],
['x\ry\n', 'x\ny\n'],
['x\r\ny\n', 'x\ny\n'],
['x\r\r\ny\n', 'x\n\ny\n'],
];
for (const [input, want] of testCases) {
it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {
assert.equal(Pad.cleanText(input), want);
});
}
});
describe('padDefaultContent hook', function () {
it('runs when a pad is created without specific text', async function () {
const p = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push({hook_fn: () => resolve()});
});
pad = await padManager.getPad(padId);
await p;
}); });
beforeEach(async function () {
it('not run if pad is created with specific text', async function () { backups.hooks.padDefaultContent = [];
plugins.hooks.padDefaultContent.push( padId = common.randomString();
{hook_fn: () => { throw new Error('should not be called'); }}); assert(!(await padManager.doesPadExist(padId)));
pad = await padManager.getPad(padId, '');
}); });
afterEach(async function () {
it('defaults to settings.defaultPadText', async function () { Object.assign(plugins.hooks, backups.hooks);
const p = new Promise((resolve, reject) => { if (pad != null)
plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, ctx) => { await pad.remove();
try { pad = null;
assert.equal(ctx.type, 'text');
assert.equal(ctx.content, settings.defaultPadText);
} catch (err) {
return reject(err);
}
resolve();
}});
});
pad = await padManager.getPad(padId);
await p;
}); });
describe('cleanText', function () {
it('passes the pad object', async function () { const testCases = [
const gotP = new Promise((resolve) => { ['', ''],
plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, {pad}) => resolve(pad)}); ['\n', '\n'],
}); ['x', 'x'],
pad = await padManager.getPad(padId); ['x\n', 'x\n'],
assert.equal(await gotP, pad); ['x\ny\n', 'x\ny\n'],
['x\ry\n', 'x\ny\n'],
['x\r\ny\n', 'x\ny\n'],
['x\r\r\ny\n', 'x\n\ny\n'],
];
for (const [input, want] of testCases) {
it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {
assert.equal(Pad.cleanText(input), want);
});
}
}); });
describe('padDefaultContent hook', function () {
it('passes empty authorId if not provided', async function () { it('runs when a pad is created without specific text', async function () {
const gotP = new Promise((resolve) => { const p = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push( plugins.hooks.padDefaultContent.push({ hook_fn: () => resolve() });
{hook_fn: async (hookName, {authorId}) => resolve(authorId)}); });
}); pad = await padManager.getPad(padId);
pad = await padManager.getPad(padId); await p;
assert.equal(await gotP, ''); });
it('not run if pad is created with specific text', async function () {
plugins.hooks.padDefaultContent.push({ hook_fn: () => { throw new Error('should not be called'); } });
pad = await padManager.getPad(padId, '');
});
it('defaults to settings.defaultPadText', async function () {
const p = new Promise((resolve, reject) => {
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, ctx) => {
try {
assert.equal(ctx.type, 'text');
assert.equal(ctx.content, settings.defaultPadText);
}
catch (err) {
return reject(err);
}
resolve();
} });
});
pad = await padManager.getPad(padId);
await p;
});
it('passes the pad object', async function () {
const gotP = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, { pad }) => resolve(pad) });
});
pad = await padManager.getPad(padId);
assert.equal(await gotP, pad);
});
it('passes empty authorId if not provided', async function () {
const gotP = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, { authorId }) => resolve(authorId) });
});
pad = await padManager.getPad(padId);
assert.equal(await gotP, '');
});
it('passes provided authorId', async function () {
const want = await authorManager.getAuthor4Token(`t.${padId}`);
const gotP = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, { authorId }) => resolve(authorId) });
});
pad = await padManager.getPad(padId, null, want);
assert.equal(await gotP, want);
});
it('uses provided content', async function () {
const want = 'hello world';
assert.notEqual(want, settings.defaultPadText);
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, ctx) => {
ctx.type = 'text';
ctx.content = want;
} });
pad = await padManager.getPad(padId);
assert.equal(pad.text(), `${want}\n`);
});
it('cleans provided content', async function () {
const input = 'foo\r\nbar\r\tbaz';
const want = 'foo\nbar\n baz';
assert.notEqual(want, settings.defaultPadText);
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, ctx) => {
ctx.type = 'text';
ctx.content = input;
} });
pad = await padManager.getPad(padId);
assert.equal(pad.text(), `${want}\n`);
});
}); });
it('passes provided authorId', async function () {
const want = await authorManager.getAuthor4Token(`t.${padId}`);
const gotP = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push(
{hook_fn: async (hookName, {authorId}) => resolve(authorId)});
});
pad = await padManager.getPad(padId, null, want);
assert.equal(await gotP, want);
});
it('uses provided content', async function () {
const want = 'hello world';
assert.notEqual(want, settings.defaultPadText);
plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, ctx) => {
ctx.type = 'text';
ctx.content = want;
}});
pad = await padManager.getPad(padId);
assert.equal(pad.text(), `${want}\n`);
});
it('cleans provided content', async function () {
const input = 'foo\r\nbar\r\tbaz';
const want = 'foo\nbar\n baz';
assert.notEqual(want, settings.defaultPadText);
plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, ctx) => {
ctx.type = 'text';
ctx.content = input;
}});
pad = await padManager.getPad(padId);
assert.equal(pad.text(), `${want}\n`);
});
});
}); });

View file

@ -1,238 +1,210 @@
import SessionStore from "../../../node/db/SessionStore.js";
import assert$0 from "assert";
import * as common from "../common.js";
import * as db from "../../../node/db/DB.js";
import util from "util";
'use strict'; 'use strict';
const assert = assert$0.strict;
const SessionStore = require('../../../node/db/SessionStore');
const assert = require('assert').strict;
const common = require('../common');
const db = require('../../../node/db/DB');
const util = require('util');
describe(__filename, function () { describe(__filename, function () {
let ss; let ss;
let sid; let sid;
const set = async (sess) => await util.promisify(ss.set).call(ss, sid, sess);
const set = async (sess) => await util.promisify(ss.set).call(ss, sid, sess); const get = async () => await util.promisify(ss.get).call(ss, sid);
const get = async () => await util.promisify(ss.get).call(ss, sid); const destroy = async () => await util.promisify(ss.destroy).call(ss, sid);
const destroy = async () => await util.promisify(ss.destroy).call(ss, sid); const touch = async (sess) => await util.promisify(ss.touch).call(ss, sid, sess);
const touch = async (sess) => await util.promisify(ss.touch).call(ss, sid, sess); before(async function () {
await common.init();
before(async function () {
await common.init();
});
beforeEach(async function () {
ss = new SessionStore();
sid = common.randomString();
});
afterEach(async function () {
if (ss != null) {
if (sid != null) await destroy();
ss.shutdown();
}
sid = null;
ss = null;
});
describe('set', function () {
it('set of null is a no-op', async function () {
await set(null);
assert(await db.get(`sessionstorage:${sid}`) == null);
}); });
it('set of non-expiring session', async function () {
const sess = {foo: 'bar', baz: {asdf: 'jkl;'}};
await set(sess);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
it('set of session that expires', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
await new Promise((resolve) => setTimeout(resolve, 110));
// Writing should start a timeout.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('set of already expired session', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(1)}};
await set(sess);
// No record should have been created.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('switch from non-expiring to expiring', async function () {
const sess = {foo: 'bar'};
await set(sess);
const sess2 = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await set(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('switch from expiring to non-expiring', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
const sess2 = {foo: 'bar'};
await set(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2));
});
});
describe('get', function () {
it('get of non-existent entry', async function () {
assert(await get() == null);
});
it('set+get round trip', async function () {
const sess = {foo: 'bar', baz: {asdf: 'jkl;'}};
await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('get of record from previous run (no expiration)', async function () {
const sess = {foo: 'bar', baz: {asdf: 'jkl;'}};
await db.set(`sessionstorage:${sid}`, sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('get of record from previous run (not yet expired)', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await db.set(`sessionstorage:${sid}`, sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
await new Promise((resolve) => setTimeout(resolve, 110));
// Reading should start a timeout.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('get of record from previous run (already expired)', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(1)}};
await db.set(`sessionstorage:${sid}`, sess);
assert(await get() == null);
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('external expiration update is picked up', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
const sess2 = {...sess, cookie: {expires: new Date(Date.now() + 200)}};
await db.set(`sessionstorage:${sid}`, sess2);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
await new Promise((resolve) => setTimeout(resolve, 110));
// The original timeout should not have fired.
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
});
describe('shutdown', function () {
it('shutdown cancels timeouts', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
ss.shutdown();
await new Promise((resolve) => setTimeout(resolve, 110));
// The record should not have been automatically purged.
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
});
describe('destroy', function () {
it('destroy deletes the database record', async function () {
const sess = {cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
await destroy();
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('destroy cancels the timeout', async function () {
const sess = {cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
await destroy();
await db.set(`sessionstorage:${sid}`, sess);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
it('destroy session that does not exist', async function () {
await destroy();
});
});
describe('touch without refresh', function () {
it('touch before set is equivalent to set if session expires', async function () {
const sess = {cookie: {expires: new Date(Date.now() + 1000)}};
await touch(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('touch updates observed expiration but not database', async function () {
const start = Date.now();
const sess = {cookie: {expires: new Date(start + 200)}};
await set(sess);
const sess2 = {cookie: {expires: new Date(start + 12000)}};
await touch(sess2);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
});
describe('touch with refresh', function () {
beforeEach(async function () { beforeEach(async function () {
ss = new SessionStore(200); ss = new SessionStore();
sid = common.randomString();
}); });
afterEach(async function () {
it('touch before set is equivalent to set if session expires', async function () { if (ss != null) {
const sess = {cookie: {expires: new Date(Date.now() + 1000)}}; if (sid != null)
await touch(sess); await destroy();
assert.equal(JSON.stringify(await get()), JSON.stringify(sess)); ss.shutdown();
}
sid = null;
ss = null;
}); });
describe('set', function () {
it('touch before eligible for refresh updates expiration but not DB', async function () { it('set of null is a no-op', async function () {
const now = Date.now(); await set(null);
const sess = {foo: 'bar', cookie: {expires: new Date(now + 1000)}}; assert(await db.get(`sessionstorage:${sid}`) == null);
await set(sess); });
const sess2 = {foo: 'bar', cookie: {expires: new Date(now + 1001)}}; it('set of non-expiring session', async function () {
await touch(sess2); const sess = { foo: 'bar', baz: { asdf: 'jkl;' } };
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess)); await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2)); assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
it('set of session that expires', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await set(sess);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
await new Promise((resolve) => setTimeout(resolve, 110));
// Writing should start a timeout.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('set of already expired session', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(1) } };
await set(sess);
// No record should have been created.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('switch from non-expiring to expiring', async function () {
const sess = { foo: 'bar' };
await set(sess);
const sess2 = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await set(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('switch from expiring to non-expiring', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await set(sess);
const sess2 = { foo: 'bar' };
await set(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2));
});
}); });
describe('get', function () {
it('touch before eligible for refresh updates timeout', async function () { it('get of non-existent entry', async function () {
const start = Date.now(); assert(await get() == null);
const sess = {foo: 'bar', cookie: {expires: new Date(start + 200)}}; });
await set(sess); it('set+get round trip', async function () {
await new Promise((resolve) => setTimeout(resolve, 100)); const sess = { foo: 'bar', baz: { asdf: 'jkl;' } };
const sess2 = {foo: 'bar', cookie: {expires: new Date(start + 399)}}; await set(sess);
await touch(sess2); assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
await new Promise((resolve) => setTimeout(resolve, 110)); });
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess)); it('get of record from previous run (no expiration)', async function () {
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2)); const sess = { foo: 'bar', baz: { asdf: 'jkl;' } };
await db.set(`sessionstorage:${sid}`, sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('get of record from previous run (not yet expired)', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await db.set(`sessionstorage:${sid}`, sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
await new Promise((resolve) => setTimeout(resolve, 110));
// Reading should start a timeout.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('get of record from previous run (already expired)', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(1) } };
await db.set(`sessionstorage:${sid}`, sess);
assert(await get() == null);
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('external expiration update is picked up', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
const sess2 = { ...sess, cookie: { expires: new Date(Date.now() + 200) } };
await db.set(`sessionstorage:${sid}`, sess2);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
await new Promise((resolve) => setTimeout(resolve, 110));
// The original timeout should not have fired.
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
}); });
describe('shutdown', function () {
it('touch after eligible for refresh updates db', async function () { it('shutdown cancels timeouts', async function () {
const start = Date.now(); const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
const sess = {foo: 'bar', cookie: {expires: new Date(start + 200)}}; await set(sess);
await set(sess); assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
await new Promise((resolve) => setTimeout(resolve, 100)); ss.shutdown();
const sess2 = {foo: 'bar', cookie: {expires: new Date(start + 400)}}; await new Promise((resolve) => setTimeout(resolve, 110));
await touch(sess2); // The record should not have been automatically purged.
await new Promise((resolve) => setTimeout(resolve, 110)); assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2)); });
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
}); });
describe('destroy', function () {
it('refresh=0 updates db every time', async function () { it('destroy deletes the database record', async function () {
ss = new SessionStore(0); const sess = { cookie: { expires: new Date(Date.now() + 100) } };
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 1000)}}; await set(sess);
await set(sess); await destroy();
await db.remove(`sessionstorage:${sid}`); assert(await db.get(`sessionstorage:${sid}`) == null);
await touch(sess); // No change in expiration time. });
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess)); it('destroy cancels the timeout', async function () {
await db.remove(`sessionstorage:${sid}`); const sess = { cookie: { expires: new Date(Date.now() + 100) } };
await touch(sess); // No change in expiration time. await set(sess);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess)); await destroy();
await db.set(`sessionstorage:${sid}`, sess);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
it('destroy session that does not exist', async function () {
await destroy();
});
});
describe('touch without refresh', function () {
it('touch before set is equivalent to set if session expires', async function () {
const sess = { cookie: { expires: new Date(Date.now() + 1000) } };
await touch(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('touch updates observed expiration but not database', async function () {
const start = Date.now();
const sess = { cookie: { expires: new Date(start + 200) } };
await set(sess);
const sess2 = { cookie: { expires: new Date(start + 12000) } };
await touch(sess2);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
});
describe('touch with refresh', function () {
beforeEach(async function () {
ss = new SessionStore(200);
});
it('touch before set is equivalent to set if session expires', async function () {
const sess = { cookie: { expires: new Date(Date.now() + 1000) } };
await touch(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('touch before eligible for refresh updates expiration but not DB', async function () {
const now = Date.now();
const sess = { foo: 'bar', cookie: { expires: new Date(now + 1000) } };
await set(sess);
const sess2 = { foo: 'bar', cookie: { expires: new Date(now + 1001) } };
await touch(sess2);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
it('touch before eligible for refresh updates timeout', async function () {
const start = Date.now();
const sess = { foo: 'bar', cookie: { expires: new Date(start + 200) } };
await set(sess);
await new Promise((resolve) => setTimeout(resolve, 100));
const sess2 = { foo: 'bar', cookie: { expires: new Date(start + 399) } };
await touch(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
it('touch after eligible for refresh updates db', async function () {
const start = Date.now();
const sess = { foo: 'bar', cookie: { expires: new Date(start + 200) } };
await set(sess);
await new Promise((resolve) => setTimeout(resolve, 100));
const sess2 = { foo: 'bar', cookie: { expires: new Date(start + 400) } };
await touch(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
it('refresh=0 updates db every time', async function () {
ss = new SessionStore(0);
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 1000) } };
await set(sess);
await db.remove(`sessionstorage:${sid}`);
await touch(sess); // No change in expiration time.
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
await db.remove(`sessionstorage:${sid}`);
await touch(sess); // No change in expiration time.
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
}); });
});
}); });

View file

@ -1,358 +1,330 @@
import * as Stream from "../../../node/utils/Stream.js";
import assert$0 from "assert";
'use strict'; 'use strict';
const assert = assert$0.strict;
const Stream = require('../../../node/utils/Stream');
const assert = require('assert').strict;
class DemoIterable { class DemoIterable {
constructor() { constructor() {
this.value = 0; this.value = 0;
this.errs = []; this.errs = [];
this.rets = []; this.rets = [];
} }
completed() { return this.errs.length > 0 || this.rets.length > 0; }
completed() { return this.errs.length > 0 || this.rets.length > 0; } next() {
if (this.completed())
next() { return { value: undefined, done: true }; // Mimic standard generators.
if (this.completed()) return {value: undefined, done: true}; // Mimic standard generators. return { value: this.value++, done: false };
return {value: this.value++, done: false}; }
} throw(err) {
const alreadyCompleted = this.completed();
throw(err) { this.errs.push(err);
const alreadyCompleted = this.completed(); if (alreadyCompleted)
this.errs.push(err); throw err; // Mimic standard generator objects.
if (alreadyCompleted) throw err; // Mimic standard generator objects. throw err;
throw err; }
} return(ret) {
const alreadyCompleted = this.completed();
return(ret) { this.rets.push(ret);
const alreadyCompleted = this.completed(); if (alreadyCompleted)
this.rets.push(ret); return { value: ret, done: true }; // Mimic standard generator objects.
if (alreadyCompleted) return {value: ret, done: true}; // Mimic standard generator objects. return { value: ret, done: true };
return {value: ret, done: true}; }
} [Symbol.iterator]() { return this; }
[Symbol.iterator]() { return this; }
} }
const assertUnhandledRejection = async (action, want) => { const assertUnhandledRejection = async (action, want) => {
// Temporarily remove unhandled Promise rejection listeners so that the unhandled rejections we // Temporarily remove unhandled Promise rejection listeners so that the unhandled rejections we
// expect to see don't trigger a test failure (or terminate node). // expect to see don't trigger a test failure (or terminate node).
const event = 'unhandledRejection'; const event = 'unhandledRejection';
const listenersBackup = process.rawListeners(event); const listenersBackup = process.rawListeners(event);
process.removeAllListeners(event); process.removeAllListeners(event);
let tempListener; let tempListener;
let asyncErr; let asyncErr;
try { try {
const seenErrPromise = new Promise((resolve) => { const seenErrPromise = new Promise((resolve) => {
tempListener = (err) => { tempListener = (err) => {
assert.equal(asyncErr, undefined); assert.equal(asyncErr, undefined);
asyncErr = err; asyncErr = err;
resolve(); resolve();
}; };
}); });
process.on(event, tempListener); process.on(event, tempListener);
await action(); await action();
await seenErrPromise; await seenErrPromise;
} finally { }
// Restore the original listeners. finally {
process.off(event, tempListener); // Restore the original listeners.
for (const listener of listenersBackup) process.on(event, listener); process.off(event, tempListener);
} for (const listener of listenersBackup)
await assert.rejects(Promise.reject(asyncErr), want); process.on(event, listener);
}
await assert.rejects(Promise.reject(asyncErr), want);
}; };
describe(__filename, function () { describe(__filename, function () {
describe('basic behavior', function () { describe('basic behavior', function () {
it('takes a generator', async function () { it('takes a generator', async function () {
assert.deepEqual([...new Stream((function* () { yield 0; yield 1; yield 2; })())], [0, 1, 2]); assert.deepEqual([...new Stream((function* () { yield 0; yield 1; yield 2; })())], [0, 1, 2]);
});
it('takes an array', async function () {
assert.deepEqual([...new Stream([0, 1, 2])], [0, 1, 2]);
});
it('takes an iterator', async function () {
assert.deepEqual([...new Stream([0, 1, 2][Symbol.iterator]())], [0, 1, 2]);
});
it('supports empty iterators', async function () {
assert.deepEqual([...new Stream([])], []);
});
it('is resumable', async function () {
const s = new Stream((function* () { yield 0; yield 1; yield 2; })());
let iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 1, done: false });
assert.deepEqual([...s], [2]);
});
it('supports return value', async function () {
const s = new Stream((function* () { yield 0; return 1; })());
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
assert.deepEqual(iter.next(), { value: 1, done: true });
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })());
// Fetching from the underlying iterator should not start until the first value is fetched
// from the stream.
assert.equal(lastYield, null);
});
it('throw is propagated', async function () {
const underlying = new DemoIterable();
const s = new Stream(underlying);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
const err = new Error('injected');
assert.throws(() => iter.throw(err), err);
assert.equal(underlying.errs[0], err);
});
it('return is propagated', async function () {
const underlying = new DemoIterable();
const s = new Stream(underlying);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
assert.deepEqual(iter.return(42), { value: 42, done: true });
assert.equal(underlying.rets[0], 42);
});
}); });
describe('range', function () {
it('takes an array', async function () { it('basic', async function () {
assert.deepEqual([...new Stream([0, 1, 2])], [0, 1, 2]); assert.deepEqual([...Stream.range(0, 3)], [0, 1, 2]);
});
it('empty', async function () {
assert.deepEqual([...Stream.range(0, 0)], []);
});
it('positive start', async function () {
assert.deepEqual([...Stream.range(3, 5)], [3, 4]);
});
it('negative start', async function () {
assert.deepEqual([...Stream.range(-3, 0)], [-3, -2, -1]);
});
it('end before start', async function () {
assert.deepEqual([...Stream.range(3, 0)], []);
});
}); });
describe('batch', function () {
it('takes an iterator', async function () { it('empty', async function () {
assert.deepEqual([...new Stream([0, 1, 2][Symbol.iterator]())], [0, 1, 2]); assert.deepEqual([...new Stream([]).batch(10)], []);
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })()).batch(10);
assert.equal(lastYield, null);
});
it('fewer than batch size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++)
yield lastYield = i;
})();
const s = new Stream(values).batch(10);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), { value: 0, done: false });
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('exactly batch size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++)
yield lastYield = i;
})();
const s = new Stream(values).batch(5);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), { value: 0, done: false });
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('multiple batches, last batch is not full', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 10; i++)
yield lastYield = i;
})();
const s = new Stream(values).batch(3);
assert.equal(lastYield, null);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
assert.equal(lastYield, 2);
assert.deepEqual(iter.next(), { value: 1, done: false });
assert.deepEqual(iter.next(), { value: 2, done: false });
assert.equal(lastYield, 2);
assert.deepEqual(iter.next(), { value: 3, done: false });
assert.equal(lastYield, 5);
assert.deepEqual([...s], [4, 5, 6, 7, 8, 9]);
assert.equal(lastYield, 9);
});
it('batched Promise rejections are suppressed while iterating', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).batch(3);
const iter = s[Symbol.iterator]();
const nextp = iter.next().value;
assert.equal(lastYield, 'promise of 2');
assert.equal(await nextp, 0);
await assert.rejects(iter.next().value, err);
iter.return();
});
it('batched Promise rejections are unsuppressed when iteration completes', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).batch(3);
const iter = s[Symbol.iterator]();
assert.equal(await iter.next().value, 0);
assert.equal(lastYield, 'promise of 2');
await assertUnhandledRejection(() => iter.return(), err);
});
}); });
describe('buffer', function () {
it('supports empty iterators', async function () { it('empty', async function () {
assert.deepEqual([...new Stream([])], []); assert.deepEqual([...new Stream([]).buffer(10)], []);
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })()).buffer(10);
assert.equal(lastYield, null);
});
it('fewer than buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++)
yield lastYield = i;
})();
const s = new Stream(values).buffer(10);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), { value: 0, done: false });
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('exactly buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++)
yield lastYield = i;
})();
const s = new Stream(values).buffer(5);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), { value: 0, done: false });
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('more than buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 10; i++)
yield lastYield = i;
})();
const s = new Stream(values).buffer(3);
assert.equal(lastYield, null);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
assert.equal(lastYield, 3);
assert.deepEqual(iter.next(), { value: 1, done: false });
assert.equal(lastYield, 4);
assert.deepEqual(iter.next(), { value: 2, done: false });
assert.equal(lastYield, 5);
assert.deepEqual([...s], [3, 4, 5, 6, 7, 8, 9]);
assert.equal(lastYield, 9);
});
it('buffered Promise rejections are suppressed while iterating', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).buffer(3);
const iter = s[Symbol.iterator]();
const nextp = iter.next().value;
assert.equal(lastYield, 'promise of 2');
assert.equal(await nextp, 0);
await assert.rejects(iter.next().value, err);
iter.return();
});
it('buffered Promise rejections are unsuppressed when iteration completes', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).buffer(3);
const iter = s[Symbol.iterator]();
assert.equal(await iter.next().value, 0);
assert.equal(lastYield, 'promise of 2');
await assertUnhandledRejection(() => iter.return(), err);
});
}); });
describe('map', function () {
it('is resumable', async function () { it('empty', async function () {
const s = new Stream((function* () { yield 0; yield 1; yield 2; })()); let called = false;
let iter = s[Symbol.iterator](); assert.deepEqual([...new Stream([]).map((v) => called = true)], []);
assert.deepEqual(iter.next(), {value: 0, done: false}); assert.equal(called, false);
iter = s[Symbol.iterator](); });
assert.deepEqual(iter.next(), {value: 1, done: false}); it('does not start until needed', async function () {
assert.deepEqual([...s], [2]); let called = false;
assert.deepEqual([...new Stream([]).map((v) => called = true)], []);
new Stream((function* () { yield 0; })()).map((v) => called = true);
assert.equal(called, false);
});
it('works', async function () {
const calls = [];
assert.deepEqual([...new Stream([0, 1, 2]).map((v) => { calls.push(v); return 2 * v; })], [0, 2, 4]);
assert.deepEqual(calls, [0, 1, 2]);
});
}); });
it('supports return value', async function () {
const s = new Stream((function* () { yield 0; return 1; })());
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
assert.deepEqual(iter.next(), {value: 1, done: true});
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })());
// Fetching from the underlying iterator should not start until the first value is fetched
// from the stream.
assert.equal(lastYield, null);
});
it('throw is propagated', async function () {
const underlying = new DemoIterable();
const s = new Stream(underlying);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
const err = new Error('injected');
assert.throws(() => iter.throw(err), err);
assert.equal(underlying.errs[0], err);
});
it('return is propagated', async function () {
const underlying = new DemoIterable();
const s = new Stream(underlying);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
assert.deepEqual(iter.return(42), {value: 42, done: true});
assert.equal(underlying.rets[0], 42);
});
});
describe('range', function () {
it('basic', async function () {
assert.deepEqual([...Stream.range(0, 3)], [0, 1, 2]);
});
it('empty', async function () {
assert.deepEqual([...Stream.range(0, 0)], []);
});
it('positive start', async function () {
assert.deepEqual([...Stream.range(3, 5)], [3, 4]);
});
it('negative start', async function () {
assert.deepEqual([...Stream.range(-3, 0)], [-3, -2, -1]);
});
it('end before start', async function () {
assert.deepEqual([...Stream.range(3, 0)], []);
});
});
describe('batch', function () {
it('empty', async function () {
assert.deepEqual([...new Stream([]).batch(10)], []);
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })()).batch(10);
assert.equal(lastYield, null);
});
it('fewer than batch size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++) yield lastYield = i;
})();
const s = new Stream(values).batch(10);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('exactly batch size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++) yield lastYield = i;
})();
const s = new Stream(values).batch(5);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('multiple batches, last batch is not full', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 10; i++) yield lastYield = i;
})();
const s = new Stream(values).batch(3);
assert.equal(lastYield, null);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
assert.equal(lastYield, 2);
assert.deepEqual(iter.next(), {value: 1, done: false});
assert.deepEqual(iter.next(), {value: 2, done: false});
assert.equal(lastYield, 2);
assert.deepEqual(iter.next(), {value: 3, done: false});
assert.equal(lastYield, 5);
assert.deepEqual([...s], [4, 5, 6, 7, 8, 9]);
assert.equal(lastYield, 9);
});
it('batched Promise rejections are suppressed while iterating', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).batch(3);
const iter = s[Symbol.iterator]();
const nextp = iter.next().value;
assert.equal(lastYield, 'promise of 2');
assert.equal(await nextp, 0);
await assert.rejects(iter.next().value, err);
iter.return();
});
it('batched Promise rejections are unsuppressed when iteration completes', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).batch(3);
const iter = s[Symbol.iterator]();
assert.equal(await iter.next().value, 0);
assert.equal(lastYield, 'promise of 2');
await assertUnhandledRejection(() => iter.return(), err);
});
});
describe('buffer', function () {
it('empty', async function () {
assert.deepEqual([...new Stream([]).buffer(10)], []);
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })()).buffer(10);
assert.equal(lastYield, null);
});
it('fewer than buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++) yield lastYield = i;
})();
const s = new Stream(values).buffer(10);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('exactly buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++) yield lastYield = i;
})();
const s = new Stream(values).buffer(5);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('more than buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 10; i++) yield lastYield = i;
})();
const s = new Stream(values).buffer(3);
assert.equal(lastYield, null);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
assert.equal(lastYield, 3);
assert.deepEqual(iter.next(), {value: 1, done: false});
assert.equal(lastYield, 4);
assert.deepEqual(iter.next(), {value: 2, done: false});
assert.equal(lastYield, 5);
assert.deepEqual([...s], [3, 4, 5, 6, 7, 8, 9]);
assert.equal(lastYield, 9);
});
it('buffered Promise rejections are suppressed while iterating', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).buffer(3);
const iter = s[Symbol.iterator]();
const nextp = iter.next().value;
assert.equal(lastYield, 'promise of 2');
assert.equal(await nextp, 0);
await assert.rejects(iter.next().value, err);
iter.return();
});
it('buffered Promise rejections are unsuppressed when iteration completes', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).buffer(3);
const iter = s[Symbol.iterator]();
assert.equal(await iter.next().value, 0);
assert.equal(lastYield, 'promise of 2');
await assertUnhandledRejection(() => iter.return(), err);
});
});
describe('map', function () {
it('empty', async function () {
let called = false;
assert.deepEqual([...new Stream([]).map((v) => called = true)], []);
assert.equal(called, false);
});
it('does not start until needed', async function () {
let called = false;
assert.deepEqual([...new Stream([]).map((v) => called = true)], []);
new Stream((function* () { yield 0; })()).map((v) => called = true);
assert.equal(called, false);
});
it('works', async function () {
const calls = [];
assert.deepEqual(
[...new Stream([0, 1, 2]).map((v) => { calls.push(v); return 2 * v; })], [0, 2, 4]);
assert.deepEqual(calls, [0, 1, 2]);
});
});
}); });

View file

@ -1,58 +1,43 @@
import * as common from "../../common.js";
import { validate } from "openapi-schema-validation";
'use strict'; 'use strict';
const validateOpenAPI = { validate }.validate;
/**
* API specs
*
* Tests for generic overarching HTTP API related features not related to any
* specific part of the data model or domain. For example: tests for versioning
* and openapi definitions.
*/
const common = require('../../common');
const validateOpenAPI = require('openapi-schema-validation').validate;
let agent; let agent;
const apiKey = common.apiKey; const apiKey = common.apiKey;
let apiVersion = 1; let apiVersion = 1;
const makeid = () => { const makeid = () => {
let text = ''; let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
for (let i = 0; i < 5; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length));
text += possible.charAt(Math.floor(Math.random() * possible.length)); }
} return text;
return text;
}; };
const testPadId = makeid(); const testPadId = makeid();
const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`;
describe(__filename, function () { describe(__filename, function () {
before(async function () { agent = await common.init(); }); before(async function () { agent = await common.init(); });
it('can obtain API version', async function () {
it('can obtain API version', async function () { await agent.get('/api/')
await agent.get('/api/') .expect(200)
.expect(200) .expect((res) => {
.expect((res) => { apiVersion = res.body.currentVersion;
apiVersion = res.body.currentVersion; if (!res.body.currentVersion)
if (!res.body.currentVersion) throw new Error('No version set in API'); throw new Error('No version set in API');
return; return;
}); });
}); });
it('can obtain valid openapi definition document', async function () {
it('can obtain valid openapi definition document', async function () { this.timeout(15000);
this.timeout(15000); await agent.get('/api/openapi.json')
await agent.get('/api/openapi.json') .expect(200)
.expect(200) .expect((res) => {
.expect((res) => { const { valid, errors } = validateOpenAPI(res.body, 3);
const {valid, errors} = validateOpenAPI(res.body, 3); if (!valid) {
if (!valid) { const prettyErrors = JSON.stringify(errors, null, 2);
const prettyErrors = JSON.stringify(errors, null, 2); throw new Error(`Document is not valid OpenAPI. ${errors.length} ` +
throw new Error(`Document is not valid OpenAPI. ${errors.length} ` + `validation errors:\n${prettyErrors}`);
`validation errors:\n${prettyErrors}`); }
}
}); });
}); });
}); });

View file

@ -1,88 +1,75 @@
import assert$0 from "assert";
import * as common from "../../common.js";
import fs from "fs";
'use strict'; 'use strict';
/* /*
* This file is copied & modified from <basedir>/src/tests/backend/specs/api/pad.js * This file is copied & modified from <basedir>/src/tests/backend/specs/api/pad.js
* *
* TODO: maybe unify those two files and merge in a single one. * TODO: maybe unify those two files and merge in a single one.
*/ */
const assert = assert$0.strict;
const assert = require('assert').strict;
const common = require('../../common');
const fs = require('fs');
const fsp = fs.promises; const fsp = fs.promises;
let agent; let agent;
const apiKey = common.apiKey; const apiKey = common.apiKey;
let apiVersion = 1; let apiVersion = 1;
const testPadId = makeid(); const testPadId = makeid();
const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`;
describe(__filename, function () { describe(__filename, function () {
before(async function () { agent = await common.init(); }); before(async function () { agent = await common.init(); });
describe('Sanity checks', function () {
describe('Sanity checks', function () { it('can connect', async function () {
it('can connect', async function () { await agent.get('/api/')
await agent.get('/api/') .expect(200)
.expect(200) .expect('Content-Type', /json/);
.expect('Content-Type', /json/); });
it('finds the version tag', async function () {
const res = await agent.get('/api/')
.expect(200);
apiVersion = res.body.currentVersion;
assert(apiVersion);
});
it('errors with invalid APIKey', async function () {
// This is broken because Etherpad doesn't handle HTTP codes properly see #2343
// If your APIKey is password you deserve to fail all tests anyway
await agent.get(`/api/${apiVersion}/createPad?apikey=password&padID=test`)
.expect(401);
});
}); });
describe('Tests', function () {
it('finds the version tag', async function () { it('creates a new Pad', async function () {
const res = await agent.get('/api/') const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect(200); .expect(200)
apiVersion = res.body.currentVersion; .expect('Content-Type', /json/);
assert(apiVersion); assert.equal(res.body.code, 0);
});
it('Sets the HTML of a Pad attempting to weird utf8 encoded content', async function () {
const res = await agent.post(endPoint('setHTML'))
.send({
padID: testPadId,
html: await fsp.readFile('tests/backend/specs/api/emojis.html', 'utf8'),
})
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('get the HTML of Pad with emojis', async function () {
const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.match(res.body.data.html, /&#127484/);
});
}); });
it('errors with invalid APIKey', async function () {
// This is broken because Etherpad doesn't handle HTTP codes properly see #2343
// If your APIKey is password you deserve to fail all tests anyway
await agent.get(`/api/${apiVersion}/createPad?apikey=password&padID=test`)
.expect(401);
});
});
describe('Tests', function () {
it('creates a new Pad', async function () {
const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('Sets the HTML of a Pad attempting to weird utf8 encoded content', async function () {
const res = await agent.post(endPoint('setHTML'))
.send({
padID: testPadId,
html: await fsp.readFile('tests/backend/specs/api/emojis.html', 'utf8'),
})
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('get the HTML of Pad with emojis', async function () {
const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.match(res.body.data.html, /&#127484/);
});
});
}); });
/* /*
End of test End of test
*/ */
function makeid() { function makeid() {
let text = ''; let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 10; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length));
text += possible.charAt(Math.floor(Math.random() * possible.length)); }
} return text;
return text;
} }

View file

@ -1,115 +1,105 @@
import * as common from "../../common.js";
'use strict'; 'use strict';
const common = require('../../common');
let agent; let agent;
const apiKey = common.apiKey; const apiKey = common.apiKey;
let apiVersion = 1; let apiVersion = 1;
let authorID = ''; let authorID = '';
const padID = makeid(); const padID = makeid();
const timestamp = Date.now(); const timestamp = Date.now();
const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`;
describe(__filename, function () { describe(__filename, function () {
before(async function () { agent = await common.init(); }); before(async function () { agent = await common.init(); });
describe('API Versioning', function () {
describe('API Versioning', function () { it('errors if can not connect', function (done) {
it('errors if can not connect', function (done) { agent.get('/api/')
agent.get('/api/') .expect((res) => {
.expect((res) => { apiVersion = res.body.currentVersion;
apiVersion = res.body.currentVersion; if (!res.body.currentVersion)
if (!res.body.currentVersion) throw new Error('No version set in API'); throw new Error('No version set in API');
return; return;
}) })
.expect(200, done); .expect(200, done);
});
}); });
}); // BEGIN GROUP AND AUTHOR TESTS
// ///////////////////////////////////
// BEGIN GROUP AND AUTHOR TESTS // ///////////////////////////////////
// /////////////////////////////////// /* Tests performed
// /////////////////////////////////// -> createPad(padID)
-> createAuthor([name]) -- should return an authorID
/* Tests performed -> appendChatMessage(padID, text, authorID, time)
-> createPad(padID) -> getChatHead(padID)
-> createAuthor([name]) -- should return an authorID -> getChatHistory(padID)
-> appendChatMessage(padID, text, authorID, time) */
-> getChatHead(padID) describe('createPad', function () {
-> getChatHistory(padID) it('creates a new Pad', function (done) {
*/ agent.get(`${endPoint('createPad')}&padID=${padID}`)
.expect((res) => {
describe('createPad', function () { if (res.body.code !== 0)
it('creates a new Pad', function (done) { throw new Error('Unable to create new Pad');
agent.get(`${endPoint('createPad')}&padID=${padID}`) })
.expect((res) => { .expect('Content-Type', /json/)
if (res.body.code !== 0) throw new Error('Unable to create new Pad'); .expect(200, done);
}) });
.expect('Content-Type', /json/)
.expect(200, done);
}); });
}); describe('createAuthor', function () {
it('Creates an author with a name set', function (done) {
describe('createAuthor', function () { agent.get(endPoint('createAuthor'))
it('Creates an author with a name set', function (done) { .expect((res) => {
agent.get(endPoint('createAuthor')) if (res.body.code !== 0 || !res.body.data.authorID) {
.expect((res) => { throw new Error('Unable to create author');
if (res.body.code !== 0 || !res.body.data.authorID) { }
throw new Error('Unable to create author'); authorID = res.body.data.authorID; // we will be this author for the rest of the tests
} })
authorID = res.body.data.authorID; // we will be this author for the rest of the tests .expect('Content-Type', /json/)
}) .expect(200, done);
.expect('Content-Type', /json/) });
.expect(200, done);
}); });
}); describe('appendChatMessage', function () {
it('Adds a chat message to the pad', function (done) {
describe('appendChatMessage', function () { agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
it('Adds a chat message to the pad', function (done) {
agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
`&authorID=${authorID}&time=${timestamp}`) `&authorID=${authorID}&time=${timestamp}`)
.expect((res) => { .expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create chat message'); if (res.body.code !== 0)
}) throw new Error('Unable to create chat message');
.expect('Content-Type', /json/) })
.expect(200, done); .expect('Content-Type', /json/)
.expect(200, done);
});
}); });
}); describe('getChatHead', function () {
it('Gets the head of chat', function (done) {
agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
describe('getChatHead', function () { .expect((res) => {
it('Gets the head of chat', function (done) { if (res.body.data.chatHead !== 0)
agent.get(`${endPoint('getChatHead')}&padID=${padID}`) throw new Error('Chat Head Length is wrong');
.expect((res) => { if (res.body.code !== 0)
if (res.body.data.chatHead !== 0) throw new Error('Chat Head Length is wrong'); throw new Error('Unable to get chat head');
})
if (res.body.code !== 0) throw new Error('Unable to get chat head'); .expect('Content-Type', /json/)
}) .expect(200, done);
.expect('Content-Type', /json/) });
.expect(200, done);
}); });
}); describe('getChatHistory', function () {
it('Gets Chat History of a Pad', function (done) {
describe('getChatHistory', function () { agent.get(`${endPoint('getChatHistory')}&padID=${padID}`)
it('Gets Chat History of a Pad', function (done) { .expect((res) => {
agent.get(`${endPoint('getChatHistory')}&padID=${padID}`) if (res.body.data.messages.length !== 1) {
.expect((res) => { throw new Error('Chat History Length is wrong');
if (res.body.data.messages.length !== 1) { }
throw new Error('Chat History Length is wrong'); if (res.body.code !== 0)
} throw new Error('Unable to get chat history');
if (res.body.code !== 0) throw new Error('Unable to get chat history'); })
}) .expect('Content-Type', /json/)
.expect('Content-Type', /json/) .expect(200, done);
.expect(200, done); });
}); });
});
}); });
function makeid() { function makeid() {
let text = ''; let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
for (let i = 0; i < 5; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length));
text += possible.charAt(Math.floor(Math.random() * possible.length)); }
} return text;
return text;
} }

View file

@ -1,3 +1,5 @@
import assert$0 from "assert";
import * as common from "../../common.js";
'use strict'; 'use strict';
/* /*
* ACHTUNG: there is a copied & modified version of this file in * ACHTUNG: there is a copied & modified version of this file in
@ -5,181 +7,175 @@
* *
* TODO: unify those two files, and merge in a single one. * TODO: unify those two files, and merge in a single one.
*/ */
const assert = assert$0.strict;
const assert = require('assert').strict;
const common = require('../../common');
let agent; let agent;
const apiKey = common.apiKey; const apiKey = common.apiKey;
const apiVersion = 1; const apiVersion = 1;
const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`;
const testImports = { const testImports = {
'malformed': { 'malformed': {
input: '<html><body><li>wtf</ul></body></html>', input: '<html><body><li>wtf</ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>wtf<br><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>wtf<br><br></body></html>',
wantText: 'wtf\n\n', wantText: 'wtf\n\n',
disabled: true, disabled: true,
}, },
'nonelistiteminlist #3620': { 'nonelistiteminlist #3620': {
input: '<html><body><ul>test<li>FOO</li></ul></body></html>', input: '<html><body><ul>test<li>FOO</li></ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="bullet">test<li>FOO</ul><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body><ul class="bullet">test<li>FOO</ul><br></body></html>',
wantText: '\ttest\n\t* FOO\n\n', wantText: '\ttest\n\t* FOO\n\n',
disabled: true, disabled: true,
}, },
'whitespaceinlist #3620': { 'whitespaceinlist #3620': {
input: '<html><body><ul> <li>FOO</li></ul></body></html>', input: '<html><body><ul> <li>FOO</li></ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="bullet"><li>FOO</ul><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body><ul class="bullet"><li>FOO</ul><br></body></html>',
wantText: '\t* FOO\n\n', wantText: '\t* FOO\n\n',
}, },
'prefixcorrectlinenumber': { 'prefixcorrectlinenumber': {
input: '<html><body><ol><li>should be 1</li><li>should be 2</li></ol></body></html>', input: '<html><body><ol><li>should be 1</li><li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1</li><li>should be 2</ol><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1</li><li>should be 2</ol><br></body></html>',
wantText: '\t1. should be 1\n\t2. should be 2\n\n', wantText: '\t1. should be 1\n\t2. should be 2\n\n',
}, },
'prefixcorrectlinenumbernested': { 'prefixcorrectlinenumbernested': {
input: '<html><body><ol><li>should be 1</li><ol><li>foo</li></ol><li>should be 2</li></ol></body></html>', input: '<html><body><ol><li>should be 1</li><ol><li>foo</li></ol><li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1<ol start="2" class="number"><li>foo</ol><li>should be 2</ol><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1<ol start="2" class="number"><li>foo</ol><li>should be 2</ol><br></body></html>',
wantText: '\t1. should be 1\n\t\t1.1. foo\n\t2. should be 2\n\n', wantText: '\t1. should be 1\n\t\t1.1. foo\n\t2. should be 2\n\n',
}, },
/*
/* "prefixcorrectlinenumber when introduced none list item - currently not supported see #3450": {
"prefixcorrectlinenumber when introduced none list item - currently not supported see #3450": { input: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>',
input: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>', wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>', wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n',
wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n', }
} ,
, "newlinesshouldntresetlinenumber #2194": {
"newlinesshouldntresetlinenumber #2194": { input: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>',
input: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>', wantHTML: '<!DOCTYPE HTML><html><body><ol class="number"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol class="number"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>', wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n',
wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n', }
} */
*/ 'ignoreAnyTagsOutsideBody': {
'ignoreAnyTagsOutsideBody': { description: 'Content outside body should be ignored',
description: 'Content outside body should be ignored', input: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>',
input: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>empty<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>empty<br><br></body></html>', wantText: 'empty\n\n',
wantText: 'empty\n\n', },
}, 'indentedListsAreNotBullets': {
'indentedListsAreNotBullets': { description: 'Indented lists are represented with tabs and without bullets',
description: 'Indented lists are represented with tabs and without bullets', input: '<html><body><ul class="indent"><li>indent</li><li>indent</ul></body></html>',
input: '<html><body><ul class="indent"><li>indent</li><li>indent</ul></body></html>', wantHTML: '<!DOCTYPE HTML><html><body><ul class="indent"><li>indent</li><li>indent</ul><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="indent"><li>indent</li><li>indent</ul><br></body></html>', wantText: '\tindent\n\tindent\n\n',
wantText: '\tindent\n\tindent\n\n', },
}, 'lineWithMultipleSpaces': {
'lineWithMultipleSpaces': { description: 'Multiple spaces should be collapsed',
description: 'Multiple spaces should be collapsed', input: '<html><body>Text with more than one space.<br></body></html>',
input: '<html><body>Text with more than one space.<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>Text with more than one space.<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Text with more than one space.<br><br></body></html>', wantText: 'Text with more than one space.\n\n',
wantText: 'Text with more than one space.\n\n', },
}, 'lineWithMultipleNonBreakingAndNormalSpaces': {
'lineWithMultipleNonBreakingAndNormalSpaces': { // XXX the HTML between "than" and "one" looks strange
// XXX the HTML between "than" and "one" looks strange description: 'non-breaking space should be preserved, but can be replaced when it',
description: 'non-breaking space should be preserved, but can be replaced when it', input: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than &nbsp;one space.<br></body></html>',
input: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than &nbsp;one space.<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>Text with&nbsp; more&nbsp;&nbsp; than&nbsp; one space.<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Text with&nbsp; more&nbsp;&nbsp; than&nbsp; one space.<br><br></body></html>', wantText: 'Text with more than one space.\n\n',
wantText: 'Text with more than one space.\n\n', },
}, 'multiplenbsp': {
'multiplenbsp': { description: 'Multiple non-breaking space should be preserved',
description: 'Multiple non-breaking space should be preserved', input: '<html><body>&nbsp;&nbsp;<br></body></html>',
input: '<html><body>&nbsp;&nbsp;<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;&nbsp;<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;&nbsp;<br><br></body></html>', wantText: ' \n\n',
wantText: ' \n\n', },
}, 'multipleNonBreakingSpaceBetweenWords': {
'multipleNonBreakingSpaceBetweenWords': { description: 'A normal space is always inserted before a word',
description: 'A normal space is always inserted before a word', input: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>',
input: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp;&nbsp; word3<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp;&nbsp; word3<br><br></body></html>', wantText: ' word1 word2 word3\n\n',
wantText: ' word1 word2 word3\n\n', },
}, 'nonBreakingSpacePreceededBySpaceBetweenWords': {
'nonBreakingSpacePreceededBySpaceBetweenWords': { description: 'A non-breaking space preceded by a normal space',
description: 'A non-breaking space preceded by a normal space', input: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>',
input: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;word1&nbsp; word2&nbsp; word3<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;word1&nbsp; word2&nbsp; word3<br><br></body></html>', wantText: ' word1 word2 word3\n\n',
wantText: ' word1 word2 word3\n\n', },
}, 'nonBreakingSpaceFollowededBySpaceBetweenWords': {
'nonBreakingSpaceFollowededBySpaceBetweenWords': { description: 'A non-breaking space followed by a normal space',
description: 'A non-breaking space followed by a normal space', input: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>',
input: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br><br></body></html>', wantText: ' word1 word2 word3\n\n',
wantText: ' word1 word2 word3\n\n', },
}, 'spacesAfterNewline': {
'spacesAfterNewline': { description: 'Collapse spaces that follow a newline',
description: 'Collapse spaces that follow a newline', input: '<!doctype html><html><body>something<br> something<br></body></html>',
input: '<!doctype html><html><body>something<br> something<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>', wantText: 'something\nsomething\n\n',
wantText: 'something\nsomething\n\n', },
}, 'spacesAfterNewlineP': {
'spacesAfterNewlineP': { description: 'Collapse spaces that follow a paragraph',
description: 'Collapse spaces that follow a paragraph', input: '<!doctype html><html><body>something<p></p> something<br></body></html>',
input: '<!doctype html><html><body>something<p></p> something<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>', wantText: 'something\n\nsomething\n\n',
wantText: 'something\n\nsomething\n\n', },
}, 'spacesAtEndOfLine': {
'spacesAtEndOfLine': { description: 'Collapse spaces that preceed/follow a newline',
description: 'Collapse spaces that preceed/follow a newline', input: '<html><body>something <br> something<br></body></html>',
input: '<html><body>something <br> something<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>', wantText: 'something\nsomething\n\n',
wantText: 'something\nsomething\n\n', },
}, 'spacesAtEndOfLineP': {
'spacesAtEndOfLineP': { description: 'Collapse spaces that preceed/follow a paragraph',
description: 'Collapse spaces that preceed/follow a paragraph', input: '<html><body>something <p></p> something<br></body></html>',
input: '<html><body>something <p></p> something<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>', wantText: 'something\n\nsomething\n\n',
wantText: 'something\n\nsomething\n\n', },
}, 'nonBreakingSpacesAfterNewlines': {
'nonBreakingSpacesAfterNewlines': { description: 'Don\'t collapse non-breaking spaces that follow a newline',
description: 'Don\'t collapse non-breaking spaces that follow a newline', input: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>',
input: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>something<br>&nbsp;&nbsp; something<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>&nbsp;&nbsp; something<br><br></body></html>', wantText: 'something\n something\n\n',
wantText: 'something\n something\n\n', },
}, 'nonBreakingSpacesAfterNewlinesP': {
'nonBreakingSpacesAfterNewlinesP': { description: 'Don\'t collapse non-breaking spaces that follow a paragraph',
description: 'Don\'t collapse non-breaking spaces that follow a paragraph', input: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>',
input: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>&nbsp;&nbsp; something<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>&nbsp;&nbsp; something<br><br></body></html>', wantText: 'something\n\n something\n\n',
wantText: 'something\n\n something\n\n', },
}, 'collapseSpacesInsideElements': {
'collapseSpacesInsideElements': { description: 'Preserve only one space when multiple are present',
description: 'Preserve only one space when multiple are present', input: '<html><body>Need <span> more </span> space<i> s </i> !<br></body></html>',
input: '<html><body>Need <span> more </span> space<i> s </i> !<br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>', wantText: 'Need more space s !\n\n',
wantText: 'Need more space s !\n\n', },
}, 'collapseSpacesAcrossNewlines': {
'collapseSpacesAcrossNewlines': { description: 'Newlines and multiple spaces across newlines should be collapsed',
description: 'Newlines and multiple spaces across newlines should be collapsed', input: `
input: `
<html><body>Need <html><body>Need
<span> more </span> <span> more </span>
space space
<i> s </i> <i> s </i>
!<br></body></html>`, !<br></body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n', wantText: 'Need more space s !\n\n',
}, },
'multipleNewLinesAtBeginning': { 'multipleNewLinesAtBeginning': {
description: 'Multiple new lines and paragraphs at the beginning should be preserved', description: 'Multiple new lines and paragraphs at the beginning should be preserved',
input: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>', input: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><br><br><br><br>first line<br><br>second line<br><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body><br><br><br><br>first line<br><br>second line<br><br></body></html>',
wantText: '\n\n\n\nfirst line\n\nsecond line\n\n', wantText: '\n\n\n\nfirst line\n\nsecond line\n\n',
}, },
'multiLineParagraph': { 'multiLineParagraph': {
description: 'A paragraph with multiple lines should not loose spaces when lines are combined', description: 'A paragraph with multiple lines should not loose spaces when lines are combined',
input: `<html><body> input: `<html><body>
<p> <p>
а б в г ґ д е є ж з и і ї й к л м н о а б в г ґ д е є ж з и і ї й к л м н о
п р с т у ф х ц ч ш щ ю я ь п р с т у ф х ц ч ш щ ю я ь
</p> </p>
</body></html>`, </body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086; &#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086; &#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>',
wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n', wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n',
}, },
'multiLineParagraphWithPre': { 'multiLineParagraphWithPre': {
// XXX why is there &nbsp; before "in"? // XXX why is there &nbsp; before "in"?
description: 'lines in preformatted text should be kept intact', description: 'lines in preformatted text should be kept intact',
input: `<html><body> input: `<html><body>
<p> <p>
а б в г ґ д е є ж з и і ї й к л м н о<pre>multiple а б в г ґ д е є ж з и і ї й к л м н о<pre>multiple
lines lines
@ -188,97 +184,88 @@ const testImports = {
</pre></p><p>п р с т у ф х ц ч ш щ ю я </pre></p><p>п р с т у ф х ц ч ш щ ю я
ь</p> ь</p>
</body></html>`, </body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086;<br>multiple<br>&nbsp;&nbsp; lines<br>&nbsp;in<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; pre<br><br>&#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086;<br>multiple<br>&nbsp;&nbsp; lines<br>&nbsp;in<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; pre<br><br>&#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>',
wantText: 'а б в г ґ д е є ж з и і ї й к л м н о\nmultiple\n lines\n in\n pre\n\nп р с т у ф х ц ч ш щ ю я ь\n\n', wantText: 'а б в г ґ д е є ж з и і ї й к л м н о\nmultiple\n lines\n in\n pre\n\nп р с т у ф х ц ч ш щ ю я ь\n\n',
}, },
'preIntroducesASpace': { 'preIntroducesASpace': {
description: 'pre should be on a new line not preceded by a space', description: 'pre should be on a new line not preceded by a space',
input: `<html><body><p> input: `<html><body><p>
1 1
<pre>preline <pre>preline
</pre></p></body></html>`, </pre></p></body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>1<br>preline<br><br><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>1<br>preline<br><br><br></body></html>',
wantText: '1\npreline\n\n\n', wantText: '1\npreline\n\n\n',
}, },
'dontDeleteSpaceInsideElements': { 'dontDeleteSpaceInsideElements': {
description: 'Preserve spaces inside elements', description: 'Preserve spaces inside elements',
input: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>', input: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n', wantText: 'Need more space s !\n\n',
}, },
'dontDeleteSpaceOutsideElements': { 'dontDeleteSpaceOutsideElements': {
description: 'Preserve spaces outside elements', description: 'Preserve spaces outside elements',
input: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>', input: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s</em> !<br><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s</em> !<br><br></body></html>',
wantText: 'Need more space s !\n\n', wantText: 'Need more space s !\n\n',
}, },
'dontDeleteSpaceAtEndOfElement': { 'dontDeleteSpaceAtEndOfElement': {
description: 'Preserve spaces at the end of an element', description: 'Preserve spaces at the end of an element',
input: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>', input: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n', wantText: 'Need more space s !\n\n',
}, },
'dontDeleteSpaceAtBeginOfElements': { 'dontDeleteSpaceAtBeginOfElements': {
description: 'Preserve spaces at the start of an element', description: 'Preserve spaces at the start of an element',
input: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>', input: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s</em> !<br><br></body></html>', wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s</em> !<br><br></body></html>',
wantText: 'Need more space s !\n\n', wantText: 'Need more space s !\n\n',
}, },
}; };
describe(__filename, function () { describe(__filename, function () {
this.timeout(1000); this.timeout(1000);
before(async function () { agent = await common.init(); });
before(async function () { agent = await common.init(); }); Object.keys(testImports).forEach((testName) => {
describe(testName, function () {
Object.keys(testImports).forEach((testName) => { const testPadId = makeid();
describe(testName, function () { const test = testImports[testName];
const testPadId = makeid(); if (test.disabled) {
const test = testImports[testName]; return xit(`DISABLED: ${testName}`, function (done) {
if (test.disabled) { done();
return xit(`DISABLED: ${testName}`, function (done) { });
done(); }
it('createPad', async function () {
const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('setHTML', async function () {
const res = await agent.get(`${endPoint('setHTML')}&padID=${testPadId}` +
`&html=${encodeURIComponent(test.input)}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('getHTML', async function () {
const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.data.html, test.wantHTML);
});
it('getText', async function () {
const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.data.text, test.wantText);
});
}); });
}
it('createPad', async function () {
const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('setHTML', async function () {
const res = await agent.get(`${endPoint('setHTML')}&padID=${testPadId}` +
`&html=${encodeURIComponent(test.input)}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('getHTML', async function () {
const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.data.html, test.wantHTML);
});
it('getText', async function () {
const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.data.text, test.wantText);
});
}); });
});
}); });
function makeid() { function makeid() {
let text = ''; let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
for (let i = 0; i < 5; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length));
text += possible.charAt(Math.floor(Math.random() * possible.length)); }
} return text;
return text;
} }

View file

@ -1,18 +1,10 @@
import * as common from "../common.js";
import assertLegacy from "../assert-legacy.js";
import queryString from "querystring";
import * as settings from "../../../node/utils/Settings.js";
'use strict'; 'use strict';
const assert = assertLegacy.strict;
/**
* caching_middleware is responsible for serving everything under path `/javascripts/`
* That includes packages as defined in `src/node/utils/tar.json` and probably also plugin code
*
*/
const common = require('../common');
const assert = require('../assert-legacy').strict;
const queryString = require('querystring');
const settings = require('../../../node/utils/Settings');
let agent; let agent;
/** /**
* Hack! Returns true if the resource is not plaintext * Hack! Returns true if the resource is not plaintext
* The file should start with the callback method, so we need the * The file should start with the callback method, so we need the
@ -23,95 +15,87 @@ let agent;
* @returns {boolean} if it is plaintext * @returns {boolean} if it is plaintext
*/ */
const isPlaintextResponse = (fileContent, resource) => { const isPlaintextResponse = (fileContent, resource) => {
// callback=require.define&v=1234 // callback=require.define&v=1234
const query = (new URL(resource, 'http://localhost')).search.slice(1); const query = (new URL(resource, 'http://localhost')).search.slice(1);
// require.define // require.define
const jsonp = queryString.parse(query).callback; const jsonp = queryString.parse(query).callback;
// returns true if the first letters in fileContent equal the content of `jsonp`
// returns true if the first letters in fileContent equal the content of `jsonp` return fileContent.substring(0, jsonp.length) === jsonp;
return fileContent.substring(0, jsonp.length) === jsonp;
}; };
/** /**
* A hack to disable `superagent`'s auto unzip functionality * A hack to disable `superagent`'s auto unzip functionality
* *
* @param {Request} request * @param {Request} request
*/ */
const disableAutoDeflate = (request) => { const disableAutoDeflate = (request) => {
request._shouldUnzip = () => false; request._shouldUnzip = () => false;
}; };
describe(__filename, function () { describe(__filename, function () {
const backups = {}; const backups = {};
const fantasyEncoding = 'brainwaves'; // non-working encoding until https://github.com/visionmedia/superagent/pull/1560 is resolved const fantasyEncoding = 'brainwaves'; // non-working encoding until https://github.com/visionmedia/superagent/pull/1560 is resolved
const packages = [ const packages = [
'/javascripts/lib/ep_etherpad-lite/static/js/ace2_common.js?callback=require.define', '/javascripts/lib/ep_etherpad-lite/static/js/ace2_common.js?callback=require.define',
'/javascripts/lib/ep_etherpad-lite/static/js/ace2_inner.js?callback=require.define', '/javascripts/lib/ep_etherpad-lite/static/js/ace2_inner.js?callback=require.define',
'/javascripts/lib/ep_etherpad-lite/static/js/pad.js?callback=require.define', '/javascripts/lib/ep_etherpad-lite/static/js/pad.js?callback=require.define',
'/javascripts/lib/ep_etherpad-lite/static/js/timeslider.js?callback=require.define', '/javascripts/lib/ep_etherpad-lite/static/js/timeslider.js?callback=require.define',
]; ];
before(async function () {
before(async function () { agent = await common.init();
agent = await common.init(); backups.settings = {};
backups.settings = {}; backups.settings.minify = settings.minify;
backups.settings.minify = settings.minify;
});
after(async function () {
Object.assign(settings, backups.settings);
});
for (const minify of [false, true]) {
context(`when minify is ${minify}`, function () {
before(async function () {
settings.minify = minify;
});
describe('gets packages uncompressed without Accept-Encoding gzip', function () {
for (const resource of packages) {
it(resource, async function () {
await agent.get(resource)
.set('Accept-Encoding', fantasyEncoding)
.use(disableAutoDeflate)
.expect(200)
.expect('Content-Type', /application\/javascript/)
.expect((res) => {
assert.equal(res.header['content-encoding'], undefined);
assert(isPlaintextResponse(res.text, resource));
});
});
}
});
describe('gets packages compressed with Accept-Encoding gzip', function () {
for (const resource of packages) {
it(resource, async function () {
await agent.get(resource)
.set('Accept-Encoding', 'gzip')
.use(disableAutoDeflate)
.expect(200)
.expect('Content-Type', /application\/javascript/)
.expect('Content-Encoding', 'gzip')
.expect((res) => {
assert(!isPlaintextResponse(res.text, resource));
});
});
}
});
it('does not cache content-encoding headers', async function () {
await agent.get(packages[0])
.set('Accept-Encoding', fantasyEncoding)
.expect(200)
.expect((res) => assert.equal(res.header['content-encoding'], undefined));
await agent.get(packages[0])
.set('Accept-Encoding', 'gzip')
.expect(200)
.expect('Content-Encoding', 'gzip');
await agent.get(packages[0])
.set('Accept-Encoding', fantasyEncoding)
.expect(200)
.expect((res) => assert.equal(res.header['content-encoding'], undefined));
});
}); });
} after(async function () {
Object.assign(settings, backups.settings);
});
for (const minify of [false, true]) {
context(`when minify is ${minify}`, function () {
before(async function () {
settings.minify = minify;
});
describe('gets packages uncompressed without Accept-Encoding gzip', function () {
for (const resource of packages) {
it(resource, async function () {
await agent.get(resource)
.set('Accept-Encoding', fantasyEncoding)
.use(disableAutoDeflate)
.expect(200)
.expect('Content-Type', /application\/javascript/)
.expect((res) => {
assert.equal(res.header['content-encoding'], undefined);
assert(isPlaintextResponse(res.text, resource));
});
});
}
});
describe('gets packages compressed with Accept-Encoding gzip', function () {
for (const resource of packages) {
it(resource, async function () {
await agent.get(resource)
.set('Accept-Encoding', 'gzip')
.use(disableAutoDeflate)
.expect(200)
.expect('Content-Type', /application\/javascript/)
.expect('Content-Encoding', 'gzip')
.expect((res) => {
assert(!isPlaintextResponse(res.text, resource));
});
});
}
});
it('does not cache content-encoding headers', async function () {
await agent.get(packages[0])
.set('Accept-Encoding', fantasyEncoding)
.expect(200)
.expect((res) => assert.equal(res.header['content-encoding'], undefined));
await agent.get(packages[0])
.set('Accept-Encoding', 'gzip')
.expect(200)
.expect('Content-Encoding', 'gzip');
await agent.get(packages[0])
.set('Accept-Encoding', fantasyEncoding)
.expect(200)
.expect((res) => assert.equal(res.header['content-encoding'], undefined));
});
});
}
}); });

View file

@ -1,160 +1,153 @@
import ChatMessage from "../../../static/js/ChatMessage.js";
import { Pad } from "../../../node/db/Pad.js";
import assert$0 from "assert";
import * as common from "../common.js";
import * as padManager from "../../../node/db/PadManager.js";
import * as pluginDefs from "../../../static/js/pluginfw/plugin_defs.js";
'use strict'; 'use strict';
const assert = assert$0.strict;
const ChatMessage = require('../../../static/js/ChatMessage');
const {Pad} = require('../../../node/db/Pad');
const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
const logger = common.logger; const logger = common.logger;
const checkHook = async (hookName, checkFn) => { const checkHook = async (hookName, checkFn) => {
if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = []; if (pluginDefs.hooks[hookName] == null)
await new Promise((resolve, reject) => { pluginDefs.hooks[hookName] = [];
pluginDefs.hooks[hookName].push({ await new Promise((resolve, reject) => {
hook_fn: async (hookName, context) => { pluginDefs.hooks[hookName].push({
if (checkFn == null) return; hook_fn: async (hookName, context) => {
logger.debug(`hook ${hookName} invoked`); if (checkFn == null)
try { return;
// Make sure checkFn is called only once. logger.debug(`hook ${hookName} invoked`);
const _checkFn = checkFn; try {
checkFn = null; // Make sure checkFn is called only once.
await _checkFn(context); const _checkFn = checkFn;
} catch (err) { checkFn = null;
reject(err); await _checkFn(context);
return; }
} catch (err) {
resolve(); reject(err);
}, return;
}
resolve();
},
});
}); });
});
}; };
const sendMessage = (socket, data) => { const sendMessage = (socket, data) => {
socket.send({ socket.send({
type: 'COLLABROOM', type: 'COLLABROOM',
component: 'pad', component: 'pad',
data, data,
}); });
}; };
const sendChat = (socket, message) => sendMessage(socket, { type: 'CHAT_MESSAGE', message });
const sendChat = (socket, message) => sendMessage(socket, {type: 'CHAT_MESSAGE', message});
describe(__filename, function () { describe(__filename, function () {
const padId = 'testChatPad'; const padId = 'testChatPad';
const hooksBackup = {}; const hooksBackup = {};
before(async function () {
before(async function () { for (const [name, defs] of Object.entries(pluginDefs.hooks)) {
for (const [name, defs] of Object.entries(pluginDefs.hooks)) { if (defs == null)
if (defs == null) continue; continue;
hooksBackup[name] = defs; hooksBackup[name] = defs;
} }
}); });
beforeEach(async function () {
for (const [name, defs] of Object.entries(hooksBackup)) pluginDefs.hooks[name] = [...defs];
for (const name of Object.keys(pluginDefs.hooks)) {
if (hooksBackup[name] == null) delete pluginDefs.hooks[name];
}
if (await padManager.doesPadExist(padId)) {
const pad = await padManager.getPad(padId);
await pad.remove();
}
});
after(async function () {
Object.assign(pluginDefs.hooks, hooksBackup);
for (const name of Object.keys(pluginDefs.hooks)) {
if (hooksBackup[name] == null) delete pluginDefs.hooks[name];
}
});
describe('chatNewMessage hook', function () {
let authorId;
let socket;
beforeEach(async function () { beforeEach(async function () {
socket = await common.connect(); for (const [name, defs] of Object.entries(hooksBackup))
const {data: clientVars} = await common.handshake(socket, padId); pluginDefs.hooks[name] = [...defs];
authorId = clientVars.userId; for (const name of Object.keys(pluginDefs.hooks)) {
if (hooksBackup[name] == null)
delete pluginDefs.hooks[name];
}
if (await padManager.doesPadExist(padId)) {
const pad = await padManager.getPad(padId);
await pad.remove();
}
}); });
after(async function () {
afterEach(async function () { Object.assign(pluginDefs.hooks, hooksBackup);
socket.close(); for (const name of Object.keys(pluginDefs.hooks)) {
if (hooksBackup[name] == null)
delete pluginDefs.hooks[name];
}
}); });
describe('chatNewMessage hook', function () {
it('message', async function () { let authorId;
const start = Date.now(); let socket;
await Promise.all([ beforeEach(async function () {
checkHook('chatNewMessage', ({message}) => { socket = await common.connect();
assert(message != null); const { data: clientVars } = await common.handshake(socket, padId);
assert(message instanceof ChatMessage); authorId = clientVars.userId;
assert.equal(message.authorId, authorId); });
assert.equal(message.text, this.test.title); afterEach(async function () {
assert(message.time >= start); socket.close();
assert(message.time <= Date.now()); });
}), it('message', async function () {
sendChat(socket, {text: this.test.title}), const start = Date.now();
]); await Promise.all([
checkHook('chatNewMessage', ({ message }) => {
assert(message != null);
assert(message instanceof ChatMessage);
assert.equal(message.authorId, authorId);
assert.equal(message.text, this.test.title);
assert(message.time >= start);
assert(message.time <= Date.now());
}),
sendChat(socket, { text: this.test.title }),
]);
});
it('pad', async function () {
await Promise.all([
checkHook('chatNewMessage', ({ pad }) => {
assert(pad != null);
assert(pad instanceof Pad);
assert.equal(pad.id, padId);
}),
sendChat(socket, { text: this.test.title }),
]);
});
it('padId', async function () {
await Promise.all([
checkHook('chatNewMessage', (context) => {
assert.equal(context.padId, padId);
}),
sendChat(socket, { text: this.test.title }),
]);
});
it('mutations propagate', async function () {
const listen = async (type) => await new Promise((resolve) => {
const handler = (msg) => {
if (msg.type !== 'COLLABROOM')
return;
if (msg.data == null || msg.data.type !== type)
return;
resolve(msg.data);
socket.off('message', handler);
};
socket.on('message', handler);
});
const modifiedText = `${this.test.title} <added changes>`;
const customMetadata = { foo: this.test.title };
await Promise.all([
checkHook('chatNewMessage', ({ message }) => {
message.text = modifiedText;
message.customMetadata = customMetadata;
}),
(async () => {
const { message } = await listen('CHAT_MESSAGE');
assert(message != null);
assert.equal(message.text, modifiedText);
assert.deepEqual(message.customMetadata, customMetadata);
})(),
sendChat(socket, { text: this.test.title }),
]);
// Simulate fetch of historical chat messages when a pad is first loaded.
await Promise.all([
(async () => {
const { messages: [message] } = await listen('CHAT_MESSAGES');
assert(message != null);
assert.equal(message.text, modifiedText);
assert.deepEqual(message.customMetadata, customMetadata);
})(),
sendMessage(socket, { type: 'GET_CHAT_MESSAGES', start: 0, end: 0 }),
]);
});
}); });
it('pad', async function () {
await Promise.all([
checkHook('chatNewMessage', ({pad}) => {
assert(pad != null);
assert(pad instanceof Pad);
assert.equal(pad.id, padId);
}),
sendChat(socket, {text: this.test.title}),
]);
});
it('padId', async function () {
await Promise.all([
checkHook('chatNewMessage', (context) => {
assert.equal(context.padId, padId);
}),
sendChat(socket, {text: this.test.title}),
]);
});
it('mutations propagate', async function () {
const listen = async (type) => await new Promise((resolve) => {
const handler = (msg) => {
if (msg.type !== 'COLLABROOM') return;
if (msg.data == null || msg.data.type !== type) return;
resolve(msg.data);
socket.off('message', handler);
};
socket.on('message', handler);
});
const modifiedText = `${this.test.title} <added changes>`;
const customMetadata = {foo: this.test.title};
await Promise.all([
checkHook('chatNewMessage', ({message}) => {
message.text = modifiedText;
message.customMetadata = customMetadata;
}),
(async () => {
const {message} = await listen('CHAT_MESSAGE');
assert(message != null);
assert.equal(message.text, modifiedText);
assert.deepEqual(message.customMetadata, customMetadata);
})(),
sendChat(socket, {text: this.test.title}),
]);
// Simulate fetch of historical chat messages when a pad is first loaded.
await Promise.all([
(async () => {
const {messages: [message]} = await listen('CHAT_MESSAGES');
assert(message != null);
assert.equal(message.text, modifiedText);
assert.deepEqual(message.customMetadata, customMetadata);
})(),
sendMessage(socket, {type: 'GET_CHAT_MESSAGES', start: 0, end: 0}),
]);
});
});
}); });

View file

@ -1,284 +1,273 @@
import AttributePool from "../../../static/js/AttributePool.js";
import * as Changeset from "../../../static/js/Changeset.js";
import assert$0 from "assert";
import * as attributes from "../../../static/js/attributes.js";
import * as contentcollector from "../../../static/js/contentcollector.js";
import * as jsdom from "jsdom";
'use strict'; 'use strict';
const assert = assert$0.strict;
/*
* While importexport tests target the `setHTML` API endpoint, which is nearly identical to what
* happens when a user manually imports a document via the UI, the contentcollector tests here don't
* use rehype to process the document. Rehype removes spaces and newĺines were applicable, so the
* expected results here can differ from importexport.js.
*
* If you add tests here, please also add them to importexport.js
*/
const AttributePool = require('../../../static/js/AttributePool');
const Changeset = require('../../../static/js/Changeset');
const assert = require('assert').strict;
const attributes = require('../../../static/js/attributes');
const contentcollector = require('../../../static/js/contentcollector');
const jsdom = require('jsdom');
// All test case `wantAlines` values must only refer to attributes in this list so that the // All test case `wantAlines` values must only refer to attributes in this list so that the
// attribute numbers do not change due to changes in pool insertion order. // attribute numbers do not change due to changes in pool insertion order.
const knownAttribs = [ const knownAttribs = [
['insertorder', 'first'], ['insertorder', 'first'],
['italic', 'true'], ['italic', 'true'],
['list', 'bullet1'], ['list', 'bullet1'],
['list', 'bullet2'], ['list', 'bullet2'],
['list', 'number1'], ['list', 'number1'],
['list', 'number2'], ['list', 'number2'],
['lmkr', '1'], ['lmkr', '1'],
['start', '1'], ['start', '1'],
['start', '2'], ['start', '2'],
]; ];
const testCases = [ const testCases = [
{ {
description: 'Simple', description: 'Simple',
html: '<html><body><p>foo</p></body></html>', html: '<html><body><p>foo</p></body></html>',
wantAlines: ['+3'], wantAlines: ['+3'],
wantText: ['foo'], wantText: ['foo'],
}, },
{ {
description: 'Line starts with asterisk', description: 'Line starts with asterisk',
html: '<html><body><p>*foo</p></body></html>', html: '<html><body><p>*foo</p></body></html>',
wantAlines: ['+4'], wantAlines: ['+4'],
wantText: ['*foo'], wantText: ['*foo'],
}, },
{ {
description: 'Complex nested Li', description: 'Complex nested Li',
html: '<!doctype html><html><body><ol><li>one</li><li><ol><li>1.1</li></ol></li><li>two</li></ol></body></html>', html: '<!doctype html><html><body><ol><li>one</li><li><ol><li>1.1</li></ol></li><li>two</li></ol></body></html>',
wantAlines: [ wantAlines: [
'*0*4*6*7+1+3', '*0*4*6*7+1+3',
'*0*5*6*8+1+3', '*0*5*6*8+1+3',
'*0*4*6*8+1+3', '*0*4*6*8+1+3',
], ],
wantText: [ wantText: [
'*one', '*1.1', '*two', '*one', '*1.1', '*two',
], ],
}, },
{ {
description: 'Complex list of different types', description: 'Complex list of different types',
html: '<!doctype html><html><body><ul class="bullet"><li>one</li><li>two</li><li>0</li><li>1</li><li>2<ul class="bullet"><li>3</li><li>4</li></ul></li></ul><ol class="number"><li>item<ol class="number"><li>item1</li><li>item2</li></ol></li></ol></body></html>', html: '<!doctype html><html><body><ul class="bullet"><li>one</li><li>two</li><li>0</li><li>1</li><li>2<ul class="bullet"><li>3</li><li>4</li></ul></li></ul><ol class="number"><li>item<ol class="number"><li>item1</li><li>item2</li></ol></li></ol></body></html>',
wantAlines: [ wantAlines: [
'*0*2*6+1+3', '*0*2*6+1+3',
'*0*2*6+1+3', '*0*2*6+1+3',
'*0*2*6+1+1', '*0*2*6+1+1',
'*0*2*6+1+1', '*0*2*6+1+1',
'*0*2*6+1+1', '*0*2*6+1+1',
'*0*3*6+1+1', '*0*3*6+1+1',
'*0*3*6+1+1', '*0*3*6+1+1',
'*0*4*6*7+1+4', '*0*4*6*7+1+4',
'*0*5*6*8+1+5', '*0*5*6*8+1+5',
'*0*5*6*8+1+5', '*0*5*6*8+1+5',
], ],
wantText: [ wantText: [
'*one', '*one',
'*two', '*two',
'*0', '*0',
'*1', '*1',
'*2', '*2',
'*3', '*3',
'*4', '*4',
'*item', '*item',
'*item1', '*item1',
'*item2', '*item2',
], ],
}, },
{ {
description: 'Tests if uls properly get attributes', description: 'Tests if uls properly get attributes',
html: '<html><body><ul><li>a</li><li>b</li></ul><div>div</div><p>foo</p></body></html>', html: '<html><body><ul><li>a</li><li>b</li></ul><div>div</div><p>foo</p></body></html>',
wantAlines: [ wantAlines: [
'*0*2*6+1+1', '*0*2*6+1+1',
'*0*2*6+1+1', '*0*2*6+1+1',
'+3', '+3',
'+3', '+3',
], ],
wantText: ['*a', '*b', 'div', 'foo'], wantText: ['*a', '*b', 'div', 'foo'],
}, },
{ {
description: 'Tests if indented uls properly get attributes', description: 'Tests if indented uls properly get attributes',
html: '<html><body><ul><li>a</li><ul><li>b</li></ul><li>a</li></ul><p>foo</p></body></html>', html: '<html><body><ul><li>a</li><ul><li>b</li></ul><li>a</li></ul><p>foo</p></body></html>',
wantAlines: [ wantAlines: [
'*0*2*6+1+1', '*0*2*6+1+1',
'*0*3*6+1+1', '*0*3*6+1+1',
'*0*2*6+1+1', '*0*2*6+1+1',
'+3', '+3',
], ],
wantText: ['*a', '*b', '*a', 'foo'], wantText: ['*a', '*b', '*a', 'foo'],
}, },
{ {
description: 'Tests if ols properly get line numbers when in a normal OL', description: 'Tests if ols properly get line numbers when in a normal OL',
html: '<html><body><ol><li>a</li><li>b</li><li>c</li></ol><p>test</p></body></html>', html: '<html><body><ol><li>a</li><li>b</li><li>c</li></ol><p>test</p></body></html>',
wantAlines: [ wantAlines: [
'*0*4*6*7+1+1', '*0*4*6*7+1+1',
'*0*4*6*7+1+1', '*0*4*6*7+1+1',
'*0*4*6*7+1+1', '*0*4*6*7+1+1',
'+4', '+4',
], ],
wantText: ['*a', '*b', '*c', 'test'], wantText: ['*a', '*b', '*c', 'test'],
noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?',
}, },
{ {
description: 'A single completely empty line break within an ol should reset count if OL is closed off..', description: 'A single completely empty line break within an ol should reset count if OL is closed off..',
html: '<html><body><ol><li>should be 1</li></ol><p>hello</p><ol><li>should be 1</li><li>should be 2</li></ol><p></p></body></html>', html: '<html><body><ol><li>should be 1</li></ol><p>hello</p><ol><li>should be 1</li><li>should be 2</li></ol><p></p></body></html>',
wantAlines: [ wantAlines: [
'*0*4*6*7+1+b', '*0*4*6*7+1+b',
'+5', '+5',
'*0*4*6*8+1+b', '*0*4*6*8+1+b',
'*0*4*6*8+1+b', '*0*4*6*8+1+b',
'', '',
], ],
wantText: ['*should be 1', 'hello', '*should be 1', '*should be 2', ''], wantText: ['*should be 1', 'hello', '*should be 1', '*should be 2', ''],
noteToSelf: "Shouldn't include attribute marker in the <p> line", noteToSelf: "Shouldn't include attribute marker in the <p> line",
}, },
{ {
description: 'A single <p></p> should create a new line', description: 'A single <p></p> should create a new line',
html: '<html><body><p></p><p></p></body></html>', html: '<html><body><p></p><p></p></body></html>',
wantAlines: ['', ''], wantAlines: ['', ''],
wantText: ['', ''], wantText: ['', ''],
noteToSelf: '<p></p>should create a line break but not break numbering', noteToSelf: '<p></p>should create a line break but not break numbering',
}, },
{ {
description: 'Tests if ols properly get line numbers when in a normal OL #2', description: 'Tests if ols properly get line numbers when in a normal OL #2',
html: '<html><body>a<ol><li>b<ol><li>c</li></ol></ol>notlist<p>foo</p></body></html>', html: '<html><body>a<ol><li>b<ol><li>c</li></ol></ol>notlist<p>foo</p></body></html>',
wantAlines: [ wantAlines: [
'+1', '+1',
'*0*4*6*7+1+1', '*0*4*6*7+1+1',
'*0*5*6*8+1+1', '*0*5*6*8+1+1',
'+7', '+7',
'+3', '+3',
], ],
wantText: ['a', '*b', '*c', 'notlist', 'foo'], wantText: ['a', '*b', '*c', 'notlist', 'foo'],
noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?',
}, },
{ {
description: 'First item being an UL then subsequent being OL will fail', description: 'First item being an UL then subsequent being OL will fail',
html: '<html><body><ul><li>a<ol><li>b</li><li>c</li></ol></li></ul></body></html>', html: '<html><body><ul><li>a<ol><li>b</li><li>c</li></ol></li></ul></body></html>',
wantAlines: ['+1', '*0*1*2*3+1+1', '*0*4*2*5+1+1'], wantAlines: ['+1', '*0*1*2*3+1+1', '*0*4*2*5+1+1'],
wantText: ['a', '*b', '*c'], wantText: ['a', '*b', '*c'],
noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?',
disabled: true, disabled: true,
}, },
{ {
description: 'A single completely empty line break within an ol should NOT reset count', description: 'A single completely empty line break within an ol should NOT reset count',
html: '<html><body><ol><li>should be 1</li><p></p><li>should be 2</li><li>should be 3</li></ol><p></p></body></html>', html: '<html><body><ol><li>should be 1</li><p></p><li>should be 2</li><li>should be 3</li></ol><p></p></body></html>',
wantAlines: [], wantAlines: [],
wantText: ['*should be 1', '*should be 2', '*should be 3'], wantText: ['*should be 1', '*should be 2', '*should be 3'],
noteToSelf: "<p></p>should create a line break but not break numbering -- This is what I can't get working!", noteToSelf: "<p></p>should create a line break but not break numbering -- This is what I can't get working!",
disabled: true, disabled: true,
}, },
{ {
description: 'Content outside body should be ignored', description: 'Content outside body should be ignored',
html: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>', html: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>',
wantAlines: ['+5'], wantAlines: ['+5'],
wantText: ['empty'], wantText: ['empty'],
}, },
{ {
description: 'Multiple spaces should be preserved', description: 'Multiple spaces should be preserved',
html: '<html><body>Text with more than one space.<br></body></html>', html: '<html><body>Text with more than one space.<br></body></html>',
wantAlines: ['+10'], wantAlines: ['+10'],
wantText: ['Text with more than one space.'], wantText: ['Text with more than one space.'],
}, },
{ {
description: 'non-breaking and normal space should be preserved', description: 'non-breaking and normal space should be preserved',
html: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than &nbsp;one space.<br></body></html>', html: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than &nbsp;one space.<br></body></html>',
wantAlines: ['+10'], wantAlines: ['+10'],
wantText: ['Text with more than one space.'], wantText: ['Text with more than one space.'],
}, },
{ {
description: 'Multiple nbsp should be preserved', description: 'Multiple nbsp should be preserved',
html: '<html><body>&nbsp;&nbsp;<br></body></html>', html: '<html><body>&nbsp;&nbsp;<br></body></html>',
wantAlines: ['+2'], wantAlines: ['+2'],
wantText: [' '], wantText: [' '],
}, },
{ {
description: 'Multiple nbsp between words ', description: 'Multiple nbsp between words ',
html: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>', html: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>',
wantAlines: ['+m'], wantAlines: ['+m'],
wantText: [' word1 word2 word3'], wantText: [' word1 word2 word3'],
}, },
{ {
description: 'A non-breaking space preceded by a normal space', description: 'A non-breaking space preceded by a normal space',
html: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>', html: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>',
wantAlines: ['+l'], wantAlines: ['+l'],
wantText: [' word1 word2 word3'], wantText: [' word1 word2 word3'],
}, },
{ {
description: 'A non-breaking space followed by a normal space', description: 'A non-breaking space followed by a normal space',
html: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>', html: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>',
wantAlines: ['+l'], wantAlines: ['+l'],
wantText: [' word1 word2 word3'], wantText: [' word1 word2 word3'],
}, },
{ {
description: 'Don\'t collapse spaces that follow a newline', description: 'Don\'t collapse spaces that follow a newline',
html: '<!doctype html><html><body>something<br> something<br></body></html>', html: '<!doctype html><html><body>something<br> something<br></body></html>',
wantAlines: ['+9', '+m'], wantAlines: ['+9', '+m'],
wantText: ['something', ' something'], wantText: ['something', ' something'],
}, },
{ {
description: 'Don\'t collapse spaces that follow a empty paragraph', description: 'Don\'t collapse spaces that follow a empty paragraph',
html: '<!doctype html><html><body>something<p></p> something<br></body></html>', html: '<!doctype html><html><body>something<p></p> something<br></body></html>',
wantAlines: ['+9', '', '+m'], wantAlines: ['+9', '', '+m'],
wantText: ['something', '', ' something'], wantText: ['something', '', ' something'],
}, },
{ {
description: 'Don\'t collapse spaces that preceed/follow a newline', description: 'Don\'t collapse spaces that preceed/follow a newline',
html: '<html><body>something <br> something<br></body></html>', html: '<html><body>something <br> something<br></body></html>',
wantAlines: ['+l', '+m'], wantAlines: ['+l', '+m'],
wantText: ['something ', ' something'], wantText: ['something ', ' something'],
}, },
{ {
description: 'Don\'t collapse spaces that preceed/follow a empty paragraph', description: 'Don\'t collapse spaces that preceed/follow a empty paragraph',
html: '<html><body>something <p></p> something<br></body></html>', html: '<html><body>something <p></p> something<br></body></html>',
wantAlines: ['+l', '', '+m'], wantAlines: ['+l', '', '+m'],
wantText: ['something ', '', ' something'], wantText: ['something ', '', ' something'],
}, },
{ {
description: 'Don\'t collapse non-breaking spaces that follow a newline', description: 'Don\'t collapse non-breaking spaces that follow a newline',
html: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>', html: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantAlines: ['+9', '+c'], wantAlines: ['+9', '+c'],
wantText: ['something', ' something'], wantText: ['something', ' something'],
}, },
{ {
description: 'Don\'t collapse non-breaking spaces that follow a paragraph', description: 'Don\'t collapse non-breaking spaces that follow a paragraph',
html: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>', html: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantAlines: ['+9', '', '+c'], wantAlines: ['+9', '', '+c'],
wantText: ['something', '', ' something'], wantText: ['something', '', ' something'],
}, },
{ {
description: 'Preserve all spaces when multiple are present', description: 'Preserve all spaces when multiple are present',
html: '<html><body>Need <span> more </span> space<i> s </i> !<br></body></html>', html: '<html><body>Need <span> more </span> space<i> s </i> !<br></body></html>',
wantAlines: ['+h*1+4+2'], wantAlines: ['+h*1+4+2'],
wantText: ['Need more space s !'], wantText: ['Need more space s !'],
}, },
{ {
description: 'Newlines and multiple spaces across newlines should be preserved', description: 'Newlines and multiple spaces across newlines should be preserved',
html: ` html: `
<html><body>Need <html><body>Need
<span> more </span> <span> more </span>
space space
<i> s </i> <i> s </i>
!<br></body></html>`, !<br></body></html>`,
wantAlines: ['+19*1+4+b'], wantAlines: ['+19*1+4+b'],
wantText: ['Need more space s !'], wantText: ['Need more space s !'],
}, },
{ {
description: 'Multiple new lines at the beginning should be preserved', description: 'Multiple new lines at the beginning should be preserved',
html: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>', html: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>',
wantAlines: ['', '', '', '', '+a', '', '+b'], wantAlines: ['', '', '', '', '+a', '', '+b'],
wantText: ['', '', '', '', 'first line', '', 'second line'], wantText: ['', '', '', '', 'first line', '', 'second line'],
}, },
{ {
description: 'A paragraph with multiple lines should not loose spaces when lines are combined', description: 'A paragraph with multiple lines should not loose spaces when lines are combined',
html: `<html><body><p> html: `<html><body><p>
а б в г ґ д е є ж з и і ї й к л м н о а б в г ґ д е є ж з и і ї й к л м н о
п р с т у ф х ц ч ш щ ю я ь</p> п р с т у ф х ц ч ш щ ю я ь</p>
</body></html>`, </body></html>`,
wantAlines: ['+1t'], wantAlines: ['+1t'],
wantText: ['а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь'], wantText: ['а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь'],
}, },
{ {
description: 'lines in preformatted text should be kept intact', description: 'lines in preformatted text should be kept intact',
html: `<html><body><p> html: `<html><body><p>
а б в г ґ д е є ж з и і ї й к л м н о</p><pre>multiple а б в г ґ д е є ж з и і ї й к л м н о</p><pre>multiple
lines lines
in in
@ -286,101 +275,98 @@ pre
</pre><p>п р с т у ф х ц ч ш щ ю я </pre><p>п р с т у ф х ц ч ш щ ю я
ь</p> ь</p>
</body></html>`, </body></html>`,
wantAlines: ['+11', '+8', '+5', '+2', '+3', '+r'], wantAlines: ['+11', '+8', '+5', '+2', '+3', '+r'],
wantText: [ wantText: [
'а б в г ґ д е є ж з и і ї й к л м н о', 'а б в г ґ д е є ж з и і ї й к л м н о',
'multiple', 'multiple',
'lines', 'lines',
'in', 'in',
'pre', 'pre',
'п р с т у ф х ц ч ш щ ю я ь', 'п р с т у ф х ц ч ш щ ю я ь',
], ],
}, },
{ {
description: 'pre should be on a new line not preceded by a space', description: 'pre should be on a new line not preceded by a space',
html: `<html><body><p> html: `<html><body><p>
1 1
</p><pre>preline </p><pre>preline
</pre></body></html>`, </pre></body></html>`,
wantAlines: ['+6', '+7'], wantAlines: ['+6', '+7'],
wantText: [' 1 ', 'preline'], wantText: [' 1 ', 'preline'],
}, },
{ {
description: 'Preserve spaces on the beginning and end of a element', description: 'Preserve spaces on the beginning and end of a element',
html: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>', html: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>',
wantAlines: ['+f*1+3+1'], wantAlines: ['+f*1+3+1'],
wantText: ['Need more space s !'], wantText: ['Need more space s !'],
}, },
{ {
description: 'Preserve spaces outside elements', description: 'Preserve spaces outside elements',
html: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>', html: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>',
wantAlines: ['+g*1+1+2'], wantAlines: ['+g*1+1+2'],
wantText: ['Need more space s !'], wantText: ['Need more space s !'],
}, },
{ {
description: 'Preserve spaces at the end of an element', description: 'Preserve spaces at the end of an element',
html: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>', html: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>',
wantAlines: ['+g*1+2+1'], wantAlines: ['+g*1+2+1'],
wantText: ['Need more space s !'], wantText: ['Need more space s !'],
}, },
{ {
description: 'Preserve spaces at the start of an element', description: 'Preserve spaces at the start of an element',
html: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>', html: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>',
wantAlines: ['+f*1+2+2'], wantAlines: ['+f*1+2+2'],
wantText: ['Need more space s !'], wantText: ['Need more space s !'],
}, },
]; ];
describe(__filename, function () { describe(__filename, function () {
for (const tc of testCases) { for (const tc of testCases) {
describe(tc.description, function () { describe(tc.description, function () {
let apool; let apool;
let result; let result;
before(async function () {
before(async function () { if (tc.disabled)
if (tc.disabled) return this.skip(); return this.skip();
const {window: {document}} = new jsdom.JSDOM(tc.html); const { window: { document } } = new jsdom.JSDOM(tc.html);
apool = new AttributePool(); apool = new AttributePool();
// To reduce test fragility, the attribute pool is seeded with `knownAttribs`, and all // To reduce test fragility, the attribute pool is seeded with `knownAttribs`, and all
// attributes in `tc.wantAlines` must be in `knownAttribs`. (This guarantees that attribute // attributes in `tc.wantAlines` must be in `knownAttribs`. (This guarantees that attribute
// numbers do not change if the attribute processing code changes.) // numbers do not change if the attribute processing code changes.)
for (const attrib of knownAttribs) apool.putAttrib(attrib); for (const attrib of knownAttribs)
for (const aline of tc.wantAlines) { apool.putAttrib(attrib);
for (const op of Changeset.deserializeOps(aline)) { for (const aline of tc.wantAlines) {
for (const n of attributes.decodeAttribString(op.attribs)) { for (const op of Changeset.deserializeOps(aline)) {
assert(n < knownAttribs.length); for (const n of attributes.decodeAttribString(op.attribs)) {
} assert(n < knownAttribs.length);
} }
} }
const cc = contentcollector.makeContentCollector(true, null, apool); }
cc.collectContent(document.body); const cc = contentcollector.makeContentCollector(true, null, apool);
result = cc.finish(); cc.collectContent(document.body);
}); result = cc.finish();
});
it('text matches', async function () { it('text matches', async function () {
assert.deepEqual(result.lines, tc.wantText); assert.deepEqual(result.lines, tc.wantText);
}); });
it('alines match', async function () {
it('alines match', async function () { assert.deepEqual(result.lineAttribs, tc.wantAlines);
assert.deepEqual(result.lineAttribs, tc.wantAlines); });
}); it('attributes are sorted in canonical order', async function () {
const gotAttribs = [];
it('attributes are sorted in canonical order', async function () { const wantAttribs = [];
const gotAttribs = []; for (const aline of result.lineAttribs) {
const wantAttribs = []; const gotAlineAttribs = [];
for (const aline of result.lineAttribs) { gotAttribs.push(gotAlineAttribs);
const gotAlineAttribs = []; const wantAlineAttribs = [];
gotAttribs.push(gotAlineAttribs); wantAttribs.push(wantAlineAttribs);
const wantAlineAttribs = []; for (const op of Changeset.deserializeOps(aline)) {
wantAttribs.push(wantAlineAttribs); const gotOpAttribs = [...attributes.attribsFromString(op.attribs, apool)];
for (const op of Changeset.deserializeOps(aline)) { gotAlineAttribs.push(gotOpAttribs);
const gotOpAttribs = [...attributes.attribsFromString(op.attribs, apool)]; wantAlineAttribs.push(attributes.sort([...gotOpAttribs]));
gotAlineAttribs.push(gotOpAttribs); }
wantAlineAttribs.push(attributes.sort([...gotOpAttribs])); }
} assert.deepEqual(gotAttribs, wantAttribs);
} });
assert.deepEqual(gotAttribs, wantAttribs); });
}); }
});
}
}); });

View file

@ -1,26 +1,21 @@
import * as common from "../common.js";
import * as padManager from "../../../node/db/PadManager.js";
import * as settings from "../../../node/utils/Settings.js";
'use strict'; 'use strict';
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const settings = require('../../../node/utils/Settings');
describe(__filename, function () { describe(__filename, function () {
let agent; let agent;
const settingsBackup = {}; const settingsBackup = {};
before(async function () {
before(async function () { agent = await common.init();
agent = await common.init(); settingsBackup.soffice = settings.soffice;
settingsBackup.soffice = settings.soffice; await padManager.getPad('testExportPad', 'test content');
await padManager.getPad('testExportPad', 'test content'); });
}); after(async function () {
Object.assign(settings, settingsBackup);
after(async function () { });
Object.assign(settings, settingsBackup); it('returns 500 on export error', async function () {
}); settings.soffice = 'false'; // '/bin/false' doesn't work on Windows
await agent.get('/p/testExportPad/export/doc')
it('returns 500 on export error', async function () { .expect(500);
settings.soffice = 'false'; // '/bin/false' doesn't work on Windows });
await agent.get('/p/testExportPad/export/doc')
.expect(500);
});
}); });

View file

@ -1,91 +1,83 @@
import assert$0 from "assert";
import * as common from "../common.js";
import fs from "fs";
import path from "path";
import * as settings from "../../../node/utils/Settings.js";
import superagent from "superagent";
'use strict'; 'use strict';
const assert = assert$0.strict;
const assert = require('assert').strict;
const common = require('../common');
const fs = require('fs');
const fsp = fs.promises; const fsp = fs.promises;
const path = require('path');
const settings = require('../../../node/utils/Settings');
const superagent = require('superagent');
describe(__filename, function () { describe(__filename, function () {
let agent; let agent;
let backupSettings; let backupSettings;
let skinDir; let skinDir;
let wantCustomIcon; let wantCustomIcon;
let wantDefaultIcon; let wantDefaultIcon;
let wantSkinIcon; let wantSkinIcon;
before(async function () {
before(async function () { agent = await common.init();
agent = await common.init(); wantCustomIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-custom.png'));
wantCustomIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-custom.png')); wantDefaultIcon = await fsp.readFile(path.join(settings.root, 'src', 'static', 'favicon.ico'));
wantDefaultIcon = await fsp.readFile(path.join(settings.root, 'src', 'static', 'favicon.ico')); wantSkinIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-skin.png'));
wantSkinIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-skin.png')); });
}); beforeEach(async function () {
backupSettings = { ...settings };
beforeEach(async function () { skinDir = await fsp.mkdtemp(path.join(settings.root, 'src', 'static', 'skins', 'test-'));
backupSettings = {...settings}; settings.skinName = path.basename(skinDir);
skinDir = await fsp.mkdtemp(path.join(settings.root, 'src', 'static', 'skins', 'test-')); });
settings.skinName = path.basename(skinDir); afterEach(async function () {
}); delete settings.favicon;
delete settings.skinName;
afterEach(async function () { Object.assign(settings, backupSettings);
delete settings.favicon; try {
delete settings.skinName; // TODO: The {recursive: true} option wasn't added to fsp.rmdir() until Node.js v12.10.0 so we
Object.assign(settings, backupSettings); // can't rely on it until support for Node.js v10 is dropped.
try { await fsp.unlink(path.join(skinDir, 'favicon.ico'));
// TODO: The {recursive: true} option wasn't added to fsp.rmdir() until Node.js v12.10.0 so we await fsp.rmdir(skinDir, { recursive: true });
// can't rely on it until support for Node.js v10 is dropped. }
await fsp.unlink(path.join(skinDir, 'favicon.ico')); catch (err) { /* intentionally ignored */ }
await fsp.rmdir(skinDir, {recursive: true}); });
} catch (err) { /* intentionally ignored */ } it('uses custom favicon if set (relative pathname)', async function () {
}); settings.favicon =
path.relative(settings.root, path.join(__dirname, 'favicon-test-custom.png'));
it('uses custom favicon if set (relative pathname)', async function () { assert(!path.isAbsolute(settings.favicon));
settings.favicon = const { body: gotIcon } = await agent.get('/favicon.ico')
path.relative(settings.root, path.join(__dirname, 'favicon-test-custom.png')); .accept('png').buffer(true).parse(superagent.parse.image)
assert(!path.isAbsolute(settings.favicon)); .expect(200);
const {body: gotIcon} = await agent.get('/favicon.ico') assert(gotIcon.equals(wantCustomIcon));
.accept('png').buffer(true).parse(superagent.parse.image) });
.expect(200); it('uses custom favicon if set (absolute pathname)', async function () {
assert(gotIcon.equals(wantCustomIcon)); settings.favicon = path.join(__dirname, 'favicon-test-custom.png');
}); assert(path.isAbsolute(settings.favicon));
const { body: gotIcon } = await agent.get('/favicon.ico')
it('uses custom favicon if set (absolute pathname)', async function () { .accept('png').buffer(true).parse(superagent.parse.image)
settings.favicon = path.join(__dirname, 'favicon-test-custom.png'); .expect(200);
assert(path.isAbsolute(settings.favicon)); assert(gotIcon.equals(wantCustomIcon));
const {body: gotIcon} = await agent.get('/favicon.ico') });
.accept('png').buffer(true).parse(superagent.parse.image) it('falls back if custom favicon is missing', async function () {
.expect(200); // The previous default for settings.favicon was 'favicon.ico', so many users will continue to
assert(gotIcon.equals(wantCustomIcon)); // have that in their settings.json for a long time. There is unlikely to be a favicon at
}); // path.resolve(settings.root, 'favicon.ico'), so this test ensures that 'favicon.ico' won't be
// a problem for those users.
it('falls back if custom favicon is missing', async function () { settings.favicon = 'favicon.ico';
// The previous default for settings.favicon was 'favicon.ico', so many users will continue to const { body: gotIcon } = await agent.get('/favicon.ico')
// have that in their settings.json for a long time. There is unlikely to be a favicon at .accept('png').buffer(true).parse(superagent.parse.image)
// path.resolve(settings.root, 'favicon.ico'), so this test ensures that 'favicon.ico' won't be .expect(200);
// a problem for those users. assert(gotIcon.equals(wantDefaultIcon));
settings.favicon = 'favicon.ico'; });
const {body: gotIcon} = await agent.get('/favicon.ico') it('uses skin favicon if present', async function () {
.accept('png').buffer(true).parse(superagent.parse.image) await fsp.writeFile(path.join(skinDir, 'favicon.ico'), wantSkinIcon);
.expect(200); settings.favicon = null;
assert(gotIcon.equals(wantDefaultIcon)); const { body: gotIcon } = await agent.get('/favicon.ico')
}); .accept('png').buffer(true).parse(superagent.parse.image)
.expect(200);
it('uses skin favicon if present', async function () { assert(gotIcon.equals(wantSkinIcon));
await fsp.writeFile(path.join(skinDir, 'favicon.ico'), wantSkinIcon); });
settings.favicon = null; it('falls back to default favicon', async function () {
const {body: gotIcon} = await agent.get('/favicon.ico') settings.favicon = null;
.accept('png').buffer(true).parse(superagent.parse.image) const { body: gotIcon } = await agent.get('/favicon.ico')
.expect(200); .accept('png').buffer(true).parse(superagent.parse.image)
assert(gotIcon.equals(wantSkinIcon)); .expect(200);
}); assert(gotIcon.equals(wantDefaultIcon));
});
it('falls back to default favicon', async function () {
settings.favicon = null;
const {body: gotIcon} = await agent.get('/favicon.ico')
.accept('png').buffer(true).parse(superagent.parse.image)
.expect(200);
assert(gotIcon.equals(wantDefaultIcon));
});
}); });

View file

@ -1,56 +1,48 @@
import assert$0 from "assert";
import * as common from "../common.js";
import * as settings from "../../../node/utils/Settings.js";
import superagent from "superagent";
'use strict'; 'use strict';
const assert = assert$0.strict;
const assert = require('assert').strict;
const common = require('../common');
const settings = require('../../../node/utils/Settings');
const superagent = require('superagent');
describe(__filename, function () { describe(__filename, function () {
let agent; let agent;
const backup = {}; const backup = {};
const getHealth = () => agent.get('/health')
const getHealth = () => agent.get('/health') .accept('application/health+json')
.accept('application/health+json') .buffer(true)
.buffer(true) .parse(superagent.parse['application/json'])
.parse(superagent.parse['application/json']) .expect(200)
.expect(200) .expect((res) => assert.equal(res.type, 'application/health+json'));
.expect((res) => assert.equal(res.type, 'application/health+json')); before(async function () {
agent = await common.init();
before(async function () { });
agent = await common.init(); beforeEach(async function () {
}); backup.settings = {};
for (const setting of ['requireAuthentication', 'requireAuthorization']) {
beforeEach(async function () { backup.settings[setting] = settings[setting];
backup.settings = {}; }
for (const setting of ['requireAuthentication', 'requireAuthorization']) { });
backup.settings[setting] = settings[setting]; afterEach(async function () {
} Object.assign(settings, backup.settings);
}); });
it('/health works', async function () {
afterEach(async function () { const res = await getHealth();
Object.assign(settings, backup.settings); assert.equal(res.body.status, 'pass');
}); assert.equal(res.body.releaseId, settings.getEpVersion());
});
it('/health works', async function () { it('auth is not required', async function () {
const res = await getHealth(); settings.requireAuthentication = true;
assert.equal(res.body.status, 'pass'); settings.requireAuthorization = true;
assert.equal(res.body.releaseId, settings.getEpVersion()); const res = await getHealth();
}); assert.equal(res.body.status, 'pass');
});
it('auth is not required', async function () { // We actually want to test that no express-session state is created, but that is difficult to do
settings.requireAuthentication = true; // without intrusive changes or unpleasant ueberdb digging. Instead, we assume that the lack of a
settings.requireAuthorization = true; // cookie means that no express-session state was created (how would express-session look up the
const res = await getHealth(); // session state if no ID was returned to the client?).
assert.equal(res.body.status, 'pass'); it('no cookie is returned', async function () {
}); const res = await getHealth();
const cookie = res.headers['set-cookie'];
// We actually want to test that no express-session state is created, but that is difficult to do assert(cookie == null, `unexpected Set-Cookie: ${cookie}`);
// without intrusive changes or unpleasant ueberdb digging. Instead, we assume that the lack of a });
// cookie means that no express-session state was created (how would express-session look up the
// session state if no ID was returned to the client?).
it('no cookie is returned', async function () {
const res = await getHealth();
const cookie = res.headers['set-cookie'];
assert(cookie == null, `unexpected Set-Cookie: ${cookie}`);
});
}); });

File diff suppressed because it is too large Load diff

View file

@ -1,171 +1,159 @@
import assert$0 from "assert";
import * as common from "../common.js";
import * as padManager from "../../../node/db/PadManager.js";
import * as plugins from "../../../static/js/pluginfw/plugin_defs.js";
import * as readOnlyManager from "../../../node/db/ReadOnlyManager.js";
'use strict'; 'use strict';
const assert = assert$0.strict;
const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const readOnlyManager = require('../../../node/db/ReadOnlyManager');
describe(__filename, function () { describe(__filename, function () {
let agent; let agent;
let pad; let pad;
let padId; let padId;
let roPadId; let roPadId;
let rev; let rev;
let socket; let socket;
let roSocket; let roSocket;
const backups = {}; const backups = {};
before(async function () {
before(async function () { agent = await common.init();
agent = await common.init(); });
}); beforeEach(async function () {
backups.hooks = { handleMessageSecurity: plugins.hooks.handleMessageSecurity };
beforeEach(async function () { plugins.hooks.handleMessageSecurity = [];
backups.hooks = {handleMessageSecurity: plugins.hooks.handleMessageSecurity}; padId = common.randomString();
plugins.hooks.handleMessageSecurity = []; assert(!await padManager.doesPadExist(padId));
padId = common.randomString(); pad = await padManager.getPad(padId, 'dummy text\n');
assert(!await padManager.doesPadExist(padId)); await pad.setText('\n'); // Make sure the pad is created.
pad = await padManager.getPad(padId, 'dummy text\n'); assert.equal(pad.text(), '\n');
await pad.setText('\n'); // Make sure the pad is created. let res = await agent.get(`/p/${padId}`).expect(200);
assert.equal(pad.text(), '\n'); socket = await common.connect(res);
let res = await agent.get(`/p/${padId}`).expect(200); const { type, data: clientVars } = await common.handshake(socket, padId);
socket = await common.connect(res); assert.equal(type, 'CLIENT_VARS');
const {type, data: clientVars} = await common.handshake(socket, padId); rev = clientVars.collab_client_vars.rev;
assert.equal(type, 'CLIENT_VARS'); roPadId = await readOnlyManager.getReadOnlyId(padId);
rev = clientVars.collab_client_vars.rev; res = await agent.get(`/p/${roPadId}`).expect(200);
roSocket = await common.connect(res);
roPadId = await readOnlyManager.getReadOnlyId(padId); await common.handshake(roSocket, roPadId);
res = await agent.get(`/p/${roPadId}`).expect(200); });
roSocket = await common.connect(res); afterEach(async function () {
await common.handshake(roSocket, roPadId); Object.assign(plugins.hooks, backups.hooks);
}); if (socket != null)
socket.close();
afterEach(async function () { socket = null;
Object.assign(plugins.hooks, backups.hooks); if (roSocket != null)
if (socket != null) socket.close(); roSocket.close();
socket = null; roSocket = null;
if (roSocket != null) roSocket.close(); if (pad != null)
roSocket = null; await pad.remove();
if (pad != null) await pad.remove(); pad = null;
pad = null; });
}); describe('CHANGESET_REQ', function () {
it('users are unable to read changesets from other pads', async function () {
describe('CHANGESET_REQ', function () { const otherPadId = `${padId}other`;
it('users are unable to read changesets from other pads', async function () { assert(!await padManager.doesPadExist(otherPadId));
const otherPadId = `${padId}other`; const otherPad = await padManager.getPad(otherPadId, 'other text\n');
assert(!await padManager.doesPadExist(otherPadId)); try {
const otherPad = await padManager.getPad(otherPadId, 'other text\n'); await otherPad.setText('other text\n');
try { const resP = common.waitForSocketEvent(roSocket, 'message');
await otherPad.setText('other text\n'); await common.sendMessage(roSocket, {
const resP = common.waitForSocketEvent(roSocket, 'message'); component: 'pad',
await common.sendMessage(roSocket, { padId: otherPadId,
component: 'pad', type: 'CHANGESET_REQ',
padId: otherPadId, // The server should ignore this. data: {
type: 'CHANGESET_REQ', granularity: 1,
data: { start: 0,
granularity: 1, requestID: 'requestId',
start: 0, },
requestID: 'requestId', });
}, const res = await resP;
assert.equal(res.type, 'CHANGESET_REQ');
assert.equal(res.data.requestID, 'requestId');
// Should match padId's text, not otherPadId's text.
assert.match(res.data.forwardsChangesets[0], /^[^$]*\$dummy text\n/);
}
finally {
await otherPad.remove();
}
}); });
const res = await resP;
assert.equal(res.type, 'CHANGESET_REQ');
assert.equal(res.data.requestID, 'requestId');
// Should match padId's text, not otherPadId's text.
assert.match(res.data.forwardsChangesets[0], /^[^$]*\$dummy text\n/);
} finally {
await otherPad.remove();
}
}); });
}); describe('USER_CHANGES', function () {
const sendUserChanges = async (socket, cs) => await common.sendUserChanges(socket, { baseRev: rev, changeset: cs });
describe('USER_CHANGES', function () { const assertAccepted = async (socket, wantRev) => {
const sendUserChanges = await common.waitForAcceptCommit(socket, wantRev);
async (socket, cs) => await common.sendUserChanges(socket, {baseRev: rev, changeset: cs}); rev = wantRev;
const assertAccepted = async (socket, wantRev) => { };
await common.waitForAcceptCommit(socket, wantRev); const assertRejected = async (socket) => {
rev = wantRev; const msg = await common.waitForSocketEvent(socket, 'message');
}; assert.deepEqual(msg, { disconnect: 'badChangeset' });
const assertRejected = async (socket) => { };
const msg = await common.waitForSocketEvent(socket, 'message'); it('changes are applied', async function () {
assert.deepEqual(msg, {disconnect: 'badChangeset'}); await Promise.all([
}; assertAccepted(socket, rev + 1),
sendUserChanges(socket, 'Z:1>5+5$hello'),
it('changes are applied', async function () { ]);
await Promise.all([ assert.equal(pad.text(), 'hello\n');
assertAccepted(socket, rev + 1), });
sendUserChanges(socket, 'Z:1>5+5$hello'), it('bad changeset is rejected', async function () {
]); await Promise.all([
assert.equal(pad.text(), 'hello\n'); assertRejected(socket),
sendUserChanges(socket, 'this is not a valid changeset'),
]);
});
it('retransmission is accepted, has no effect', async function () {
const cs = 'Z:1>5+5$hello';
await Promise.all([
assertAccepted(socket, rev + 1),
sendUserChanges(socket, cs),
]);
--rev;
await Promise.all([
assertAccepted(socket, rev + 1),
sendUserChanges(socket, cs),
]);
assert.equal(pad.text(), 'hello\n');
});
it('identity changeset is accepted, has no effect', async function () {
await Promise.all([
assertAccepted(socket, rev + 1),
sendUserChanges(socket, 'Z:1>5+5$hello'),
]);
await Promise.all([
assertAccepted(socket, rev),
sendUserChanges(socket, 'Z:6>0$'),
]);
assert.equal(pad.text(), 'hello\n');
});
it('non-identity changeset with no net change is accepted, has no effect', async function () {
await Promise.all([
assertAccepted(socket, rev + 1),
sendUserChanges(socket, 'Z:1>5+5$hello'),
]);
await Promise.all([
assertAccepted(socket, rev),
sendUserChanges(socket, 'Z:6>0-5+5$hello'),
]);
assert.equal(pad.text(), 'hello\n');
});
it('handleMessageSecurity can grant one-time write access', async function () {
const cs = 'Z:1>5+5$hello';
const errRegEx = /write attempt on read-only pad/;
// First try to send a change and verify that it was dropped.
await assert.rejects(sendUserChanges(roSocket, cs), errRegEx);
// sendUserChanges() waits for message ack, so if the message was accepted then head should
// have already incremented by the time we get here.
assert.equal(pad.head, rev); // Not incremented.
// Now allow the change.
plugins.hooks.handleMessageSecurity.push({ hook_fn: () => 'permitOnce' });
await Promise.all([
assertAccepted(roSocket, rev + 1),
sendUserChanges(roSocket, cs),
]);
assert.equal(pad.text(), 'hello\n');
// The next change should be dropped.
plugins.hooks.handleMessageSecurity = [];
await assert.rejects(sendUserChanges(roSocket, 'Z:6>6=5+6$ world'), errRegEx);
assert.equal(pad.head, rev); // Not incremented.
assert.equal(pad.text(), 'hello\n');
});
}); });
it('bad changeset is rejected', async function () {
await Promise.all([
assertRejected(socket),
sendUserChanges(socket, 'this is not a valid changeset'),
]);
});
it('retransmission is accepted, has no effect', async function () {
const cs = 'Z:1>5+5$hello';
await Promise.all([
assertAccepted(socket, rev + 1),
sendUserChanges(socket, cs),
]);
--rev;
await Promise.all([
assertAccepted(socket, rev + 1),
sendUserChanges(socket, cs),
]);
assert.equal(pad.text(), 'hello\n');
});
it('identity changeset is accepted, has no effect', async function () {
await Promise.all([
assertAccepted(socket, rev + 1),
sendUserChanges(socket, 'Z:1>5+5$hello'),
]);
await Promise.all([
assertAccepted(socket, rev),
sendUserChanges(socket, 'Z:6>0$'),
]);
assert.equal(pad.text(), 'hello\n');
});
it('non-identity changeset with no net change is accepted, has no effect', async function () {
await Promise.all([
assertAccepted(socket, rev + 1),
sendUserChanges(socket, 'Z:1>5+5$hello'),
]);
await Promise.all([
assertAccepted(socket, rev),
sendUserChanges(socket, 'Z:6>0-5+5$hello'),
]);
assert.equal(pad.text(), 'hello\n');
});
it('handleMessageSecurity can grant one-time write access', async function () {
const cs = 'Z:1>5+5$hello';
const errRegEx = /write attempt on read-only pad/;
// First try to send a change and verify that it was dropped.
await assert.rejects(sendUserChanges(roSocket, cs), errRegEx);
// sendUserChanges() waits for message ack, so if the message was accepted then head should
// have already incremented by the time we get here.
assert.equal(pad.head, rev); // Not incremented.
// Now allow the change.
plugins.hooks.handleMessageSecurity.push({hook_fn: () => 'permitOnce'});
await Promise.all([
assertAccepted(roSocket, rev + 1),
sendUserChanges(roSocket, cs),
]);
assert.equal(pad.text(), 'hello\n');
// The next change should be dropped.
plugins.hooks.handleMessageSecurity = [];
await assert.rejects(sendUserChanges(roSocket, 'Z:6>6=5+6$ world'), errRegEx);
assert.equal(pad.head, rev); // Not incremented.
assert.equal(pad.text(), 'hello\n');
});
});
}); });

View file

@ -1,43 +1,38 @@
import assert$0 from "assert";
import { padutils } from "../../../static/js/pad_utils.js";
'use strict'; 'use strict';
const assert = assert$0.strict;
const assert = require('assert').strict;
const {padutils} = require('../../../static/js/pad_utils');
describe(__filename, function () { describe(__filename, function () {
describe('warnDeprecated', function () { describe('warnDeprecated', function () {
const {warnDeprecated} = padutils; const { warnDeprecated } = padutils;
const backups = {}; const backups = {};
before(async function () {
before(async function () { backups.logger = warnDeprecated.logger;
backups.logger = warnDeprecated.logger; });
afterEach(async function () {
warnDeprecated.logger = backups.logger;
delete warnDeprecated._rl; // Reset internal rate limiter state.
});
it('includes the stack', async function () {
let got;
warnDeprecated.logger = { warn: (stack) => got = stack };
warnDeprecated();
assert(got.includes(__filename));
});
it('rate limited', async function () {
let got = 0;
warnDeprecated.logger = { warn: () => ++got };
warnDeprecated(); // Initialize internal rate limiter state.
const { period } = warnDeprecated._rl;
got = 0;
const testCases = [[0, 1], [0, 1], [period - 1, 1], [period, 2]];
for (const [now, want] of testCases) { // In a loop so that the stack trace is the same.
warnDeprecated._rl.now = () => now;
warnDeprecated();
assert.equal(got, want);
}
warnDeprecated(); // Should have a different stack trace.
assert.equal(got, testCases[testCases.length - 1][1] + 1);
});
}); });
afterEach(async function () {
warnDeprecated.logger = backups.logger;
delete warnDeprecated._rl; // Reset internal rate limiter state.
});
it('includes the stack', async function () {
let got;
warnDeprecated.logger = {warn: (stack) => got = stack};
warnDeprecated();
assert(got.includes(__filename));
});
it('rate limited', async function () {
let got = 0;
warnDeprecated.logger = {warn: () => ++got};
warnDeprecated(); // Initialize internal rate limiter state.
const {period} = warnDeprecated._rl;
got = 0;
const testCases = [[0, 1], [0, 1], [period - 1, 1], [period, 2]];
for (const [now, want] of testCases) { // In a loop so that the stack trace is the same.
warnDeprecated._rl.now = () => now;
warnDeprecated();
assert.equal(got, want);
}
warnDeprecated(); // Should have a different stack trace.
assert.equal(got, testCases[testCases.length - 1][1] + 1);
});
});
}); });

View file

@ -1,24 +1,20 @@
import * as common from "../common.js";
import assertLegacy from "../assert-legacy.js";
'use strict'; 'use strict';
const assert = assertLegacy.strict;
const common = require('../common');
const assert = require('../assert-legacy').strict;
let agent; let agent;
describe(__filename, function () { describe(__filename, function () {
before(async function () { before(async function () {
agent = await common.init(); agent = await common.init();
}); });
it('supports pads with spaces, regression test for #4883', async function () {
it('supports pads with spaces, regression test for #4883', async function () { await agent.get('/p/pads with spaces')
await agent.get('/p/pads with spaces') .expect(302)
.expect(302) .expect('location', 'pads_with_spaces');
.expect('location', 'pads_with_spaces'); });
}); it('supports pads with spaces and query, regression test for #4883', async function () {
await agent.get('/p/pads with spaces?showChat=true&noColors=false')
it('supports pads with spaces and query, regression test for #4883', async function () { .expect(302)
await agent.get('/p/pads with spaces?showChat=true&noColors=false') .expect('location', 'pads_with_spaces?showChat=true&noColors=false');
.expect(302) });
.expect('location', 'pads_with_spaces?showChat=true&noColors=false');
});
}); });

View file

@ -1,85 +1,76 @@
const assert = require('assert').strict; import assert$0 from "assert";
const promises = require('../../../node/utils/promises'); import * as promises from "../../../node/utils/promises.js";
const assert = assert$0.strict;
describe(__filename, function () { describe(__filename, function () {
describe('promises.timesLimit', function () { describe('promises.timesLimit', function () {
let wantIndex = 0; let wantIndex = 0;
const testPromises = []; const testPromises = [];
const makePromise = (index) => { const makePromise = (index) => {
// Make sure index increases by one each time. // Make sure index increases by one each time.
assert.equal(index, wantIndex++); assert.equal(index, wantIndex++);
// Save the resolve callback (so the test can trigger resolution) // Save the resolve callback (so the test can trigger resolution)
// and the promise itself (to wait for resolve to take effect). // and the promise itself (to wait for resolve to take effect).
const p = {}; const p = {};
const promise = new Promise((resolve) => { const promise = new Promise((resolve) => {
p.resolve = resolve; p.resolve = resolve;
}); });
p.promise = promise; p.promise = promise;
testPromises.push(p); testPromises.push(p);
return p.promise; return p.promise;
}; };
const total = 11;
const total = 11; const concurrency = 7;
const concurrency = 7; const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise);
const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise); it('honors concurrency', async function () {
assert.equal(wantIndex, concurrency);
it('honors concurrency', async function () { });
assert.equal(wantIndex, concurrency); it('creates another when one completes', async function () {
const { promise, resolve } = testPromises.shift();
resolve();
await promise;
assert.equal(wantIndex, concurrency + 1);
});
it('creates the expected total number of promises', async function () {
while (testPromises.length > 0) {
// Resolve them in random order to ensure that the resolution order doesn't matter.
const i = Math.floor(Math.random() * Math.floor(testPromises.length));
const { promise, resolve } = testPromises.splice(i, 1)[0];
resolve();
await promise;
}
assert.equal(wantIndex, total);
});
it('resolves', async function () {
await timesLimitPromise;
});
it('does not create too many promises if total < concurrency', async function () {
wantIndex = 0;
assert.equal(testPromises.length, 0);
const total = 7;
const concurrency = 11;
const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise);
while (testPromises.length > 0) {
const { promise, resolve } = testPromises.pop();
resolve();
await promise;
}
await timesLimitPromise;
assert.equal(wantIndex, total);
});
it('accepts total === 0, concurrency > 0', async function () {
wantIndex = 0;
assert.equal(testPromises.length, 0);
await promises.timesLimit(0, concurrency, makePromise);
assert.equal(wantIndex, 0);
});
it('accepts total === 0, concurrency === 0', async function () {
wantIndex = 0;
assert.equal(testPromises.length, 0);
await promises.timesLimit(0, 0, makePromise);
assert.equal(wantIndex, 0);
});
it('rejects total > 0, concurrency === 0', async function () {
await assert.rejects(promises.timesLimit(total, 0, makePromise), RangeError);
});
}); });
it('creates another when one completes', async function () {
const {promise, resolve} = testPromises.shift();
resolve();
await promise;
assert.equal(wantIndex, concurrency + 1);
});
it('creates the expected total number of promises', async function () {
while (testPromises.length > 0) {
// Resolve them in random order to ensure that the resolution order doesn't matter.
const i = Math.floor(Math.random() * Math.floor(testPromises.length));
const {promise, resolve} = testPromises.splice(i, 1)[0];
resolve();
await promise;
}
assert.equal(wantIndex, total);
});
it('resolves', async function () {
await timesLimitPromise;
});
it('does not create too many promises if total < concurrency', async function () {
wantIndex = 0;
assert.equal(testPromises.length, 0);
const total = 7;
const concurrency = 11;
const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise);
while (testPromises.length > 0) {
const {promise, resolve} = testPromises.pop();
resolve();
await promise;
}
await timesLimitPromise;
assert.equal(wantIndex, total);
});
it('accepts total === 0, concurrency > 0', async function () {
wantIndex = 0;
assert.equal(testPromises.length, 0);
await promises.timesLimit(0, concurrency, makePromise);
assert.equal(wantIndex, 0);
});
it('accepts total === 0, concurrency === 0', async function () {
wantIndex = 0;
assert.equal(testPromises.length, 0);
await promises.timesLimit(0, 0, makePromise);
assert.equal(wantIndex, 0);
});
it('rejects total > 0, concurrency === 0', async function () {
await assert.rejects(promises.timesLimit(total, 0, makePromise), RangeError);
});
});
}); });

View file

@ -1,30 +1,25 @@
import * as AuthorManager from "../../../node/db/AuthorManager.js";
import assert$0 from "assert";
import * as common from "../common.js";
import * as db from "../../../node/db/DB.js";
'use strict'; 'use strict';
const assert = assert$0.strict;
const AuthorManager = require('../../../node/db/AuthorManager');
const assert = require('assert').strict;
const common = require('../common');
const db = require('../../../node/db/DB');
describe(__filename, function () { describe(__filename, function () {
let setBackup; let setBackup;
before(async function () {
before(async function () { await common.init();
await common.init(); setBackup = db.set;
setBackup = db.set; db.set = async (...args) => {
// delay db.set
db.set = async (...args) => { await new Promise((resolve) => { setTimeout(() => resolve(), 500); });
// delay db.set return await setBackup.call(db, ...args);
await new Promise((resolve) => { setTimeout(() => resolve(), 500); }); };
return await setBackup.call(db, ...args); });
}; after(async function () {
}); db.set = setBackup;
});
after(async function () { it('regression test for missing await in createAuthor (#5000)', async function () {
db.set = setBackup; const { authorID } = await AuthorManager.createAuthor(); // Should block until db.set() finishes.
}); assert(await AuthorManager.doesAuthorExist(authorID));
});
it('regression test for missing await in createAuthor (#5000)', async function () {
const {authorID} = await AuthorManager.createAuthor(); // Should block until db.set() finishes.
assert(await AuthorManager.doesAuthorExist(authorID));
});
}); });

View file

@ -1,96 +1,93 @@
import assert$0 from "assert";
import path from "path";
import sanitizePathname from "../../../node/utils/sanitizePathname.js";
'use strict'; 'use strict';
const assert = assert$0.strict;
const assert = require('assert').strict;
const path = require('path');
const sanitizePathname = require('../../../node/utils/sanitizePathname');
describe(__filename, function () { describe(__filename, function () {
describe('absolute paths rejected', function () { describe('absolute paths rejected', function () {
const testCases = [ const testCases = [
['posix', '/'], ['posix', '/'],
['posix', '/foo'], ['posix', '/foo'],
['win32', '/'], ['win32', '/'],
['win32', '\\'], ['win32', '\\'],
['win32', 'C:/foo'], ['win32', 'C:/foo'],
['win32', 'C:\\foo'], ['win32', 'C:\\foo'],
['win32', 'c:/foo'], ['win32', 'c:/foo'],
['win32', 'c:\\foo'], ['win32', 'c:\\foo'],
['win32', '/foo'], ['win32', '/foo'],
['win32', '\\foo'], ['win32', '\\foo'],
]; ];
for (const [platform, p] of testCases) { for (const [platform, p] of testCases) {
it(`${platform} ${p}`, async function () { it(`${platform} ${p}`, async function () {
assert.throws(() => sanitizePathname(p, path[platform]), {message: /absolute path/}); assert.throws(() => sanitizePathname(p, path[platform]), { message: /absolute path/ });
}); });
} }
}); });
describe('directory traversal rejected', function () { describe('directory traversal rejected', function () {
const testCases = [ const testCases = [
['posix', '..'], ['posix', '..'],
['posix', '../'], ['posix', '../'],
['posix', '../foo'], ['posix', '../foo'],
['posix', 'foo/../..'], ['posix', 'foo/../..'],
['win32', '..'], ['win32', '..'],
['win32', '../'], ['win32', '../'],
['win32', '..\\'], ['win32', '..\\'],
['win32', '../foo'], ['win32', '../foo'],
['win32', '..\\foo'], ['win32', '..\\foo'],
['win32', 'foo/../..'], ['win32', 'foo/../..'],
['win32', 'foo\\..\\..'], ['win32', 'foo\\..\\..'],
]; ];
for (const [platform, p] of testCases) { for (const [platform, p] of testCases) {
it(`${platform} ${p}`, async function () { it(`${platform} ${p}`, async function () {
assert.throws(() => sanitizePathname(p, path[platform]), {message: /travers/}); assert.throws(() => sanitizePathname(p, path[platform]), { message: /travers/ });
}); });
} }
}); });
describe('accepted paths', function () {
describe('accepted paths', function () { const testCases = [
const testCases = [ ['posix', '', '.'],
['posix', '', '.'], ['posix', '.'],
['posix', '.'], ['posix', './'],
['posix', './'], ['posix', 'foo'],
['posix', 'foo'], ['posix', 'foo/'],
['posix', 'foo/'], ['posix', 'foo/bar/..', 'foo'],
['posix', 'foo/bar/..', 'foo'], ['posix', 'foo/bar/../', 'foo/'],
['posix', 'foo/bar/../', 'foo/'], ['posix', './foo', 'foo'],
['posix', './foo', 'foo'], ['posix', 'foo/bar'],
['posix', 'foo/bar'], ['posix', 'foo\\bar'],
['posix', 'foo\\bar'], ['posix', '\\foo'],
['posix', '\\foo'], ['posix', '..\\foo'],
['posix', '..\\foo'], ['posix', 'foo/../bar', 'bar'],
['posix', 'foo/../bar', 'bar'], ['posix', 'C:/foo'],
['posix', 'C:/foo'], ['posix', 'C:\\foo'],
['posix', 'C:\\foo'], ['win32', '', '.'],
['win32', '', '.'], ['win32', '.'],
['win32', '.'], ['win32', './'],
['win32', './'], ['win32', '.\\', './'],
['win32', '.\\', './'], ['win32', 'foo'],
['win32', 'foo'], ['win32', 'foo/'],
['win32', 'foo/'], ['win32', 'foo\\', 'foo/'],
['win32', 'foo\\', 'foo/'], ['win32', 'foo/bar/..', 'foo'],
['win32', 'foo/bar/..', 'foo'], ['win32', 'foo\\bar\\..', 'foo'],
['win32', 'foo\\bar\\..', 'foo'], ['win32', 'foo/bar/../', 'foo/'],
['win32', 'foo/bar/../', 'foo/'], ['win32', 'foo\\bar\\..\\', 'foo/'],
['win32', 'foo\\bar\\..\\', 'foo/'], ['win32', './foo', 'foo'],
['win32', './foo', 'foo'], ['win32', '.\\foo', 'foo'],
['win32', '.\\foo', 'foo'], ['win32', 'foo/bar'],
['win32', 'foo/bar'], ['win32', 'foo\\bar', 'foo/bar'],
['win32', 'foo\\bar', 'foo/bar'], ['win32', 'foo/../bar', 'bar'],
['win32', 'foo/../bar', 'bar'], ['win32', 'foo\\..\\bar', 'bar'],
['win32', 'foo\\..\\bar', 'bar'], ['win32', 'foo/..\\bar', 'bar'],
['win32', 'foo/..\\bar', 'bar'], ['win32', 'foo\\../bar', 'bar'],
['win32', 'foo\\../bar', 'bar'], ];
]; for (const [platform, p, tcWant] of testCases) {
for (const [platform, p, tcWant] of testCases) { const want = tcWant == null ? p : tcWant;
const want = tcWant == null ? p : tcWant; it(`${platform} ${p || '<empty string>'} -> ${want}`, async function () {
it(`${platform} ${p || '<empty string>'} -> ${want}`, async function () { assert.equal(sanitizePathname(p, path[platform]), want);
assert.equal(sanitizePathname(p, path[platform]), want); });
}); }
} });
}); it('default path API', async function () {
assert.equal(sanitizePathname('foo'), 'foo');
it('default path API', async function () { });
assert.equal(sanitizePathname('foo'), 'foo');
});
}); });

View file

@ -1,61 +1,60 @@
import assert$0 from "assert";
import { exportedForTestingOnly } from "../../../node/utils/Settings.js";
import path from "path";
import process from "process";
'use strict'; 'use strict';
const assert = assert$0.strict;
const assert = require('assert').strict; const { parseSettings } = { exportedForTestingOnly }.exportedForTestingOnly;
const {parseSettings} = require('../../../node/utils/Settings').exportedForTestingOnly;
const path = require('path');
const process = require('process');
describe(__filename, function () { describe(__filename, function () {
describe('parseSettings', function () { describe('parseSettings', function () {
let settings; let settings;
const envVarSubstTestCases = [ const envVarSubstTestCases = [
{name: 'true', val: 'true', var: 'SET_VAR_TRUE', want: true}, { name: 'true', val: 'true', var: 'SET_VAR_TRUE', want: true },
{name: 'false', val: 'false', var: 'SET_VAR_FALSE', want: false}, { name: 'false', val: 'false', var: 'SET_VAR_FALSE', want: false },
{name: 'null', val: 'null', var: 'SET_VAR_NULL', want: null}, { name: 'null', val: 'null', var: 'SET_VAR_NULL', want: null },
{name: 'undefined', val: 'undefined', var: 'SET_VAR_UNDEFINED', want: undefined}, { name: 'undefined', val: 'undefined', var: 'SET_VAR_UNDEFINED', want: undefined },
{name: 'number', val: '123', var: 'SET_VAR_NUMBER', want: 123}, { name: 'number', val: '123', var: 'SET_VAR_NUMBER', want: 123 },
{name: 'string', val: 'foo', var: 'SET_VAR_STRING', want: 'foo'}, { name: 'string', val: 'foo', var: 'SET_VAR_STRING', want: 'foo' },
{name: 'empty string', val: '', var: 'SET_VAR_EMPTY_STRING', want: ''}, { name: 'empty string', val: '', var: 'SET_VAR_EMPTY_STRING', want: '' },
]; ];
before(async function () {
before(async function () { for (const tc of envVarSubstTestCases)
for (const tc of envVarSubstTestCases) process.env[tc.var] = tc.val; process.env[tc.var] = tc.val;
delete process.env.UNSET_VAR; delete process.env.UNSET_VAR;
settings = parseSettings(path.join(__dirname, 'settings.json'), true); settings = parseSettings(path.join(__dirname, 'settings.json'), true);
assert(settings != null); assert(settings != null);
}); });
describe('environment variable substitution', function () {
describe('environment variable substitution', function () { describe('set', function () {
describe('set', function () { for (const tc of envVarSubstTestCases) {
for (const tc of envVarSubstTestCases) { it(tc.name, async function () {
it(tc.name, async function () { const obj = settings['environment variable substitution'].set;
const obj = settings['environment variable substitution'].set; if (tc.name === 'undefined') {
if (tc.name === 'undefined') { assert(!(tc.name in obj));
assert(!(tc.name in obj)); }
} else { else {
assert.equal(obj[tc.name], tc.want); assert.equal(obj[tc.name], tc.want);
} }
}); });
} }
}); });
describe('unset', function () {
describe('unset', function () { it('no default', async function () {
it('no default', async function () { const obj = settings['environment variable substitution'].unset;
const obj = settings['environment variable substitution'].unset; assert.equal(obj['no default'], null);
assert.equal(obj['no default'], null); });
for (const tc of envVarSubstTestCases) {
it(tc.name, async function () {
const obj = settings['environment variable substitution'].unset;
if (tc.name === 'undefined') {
assert(!(tc.name in obj));
}
else {
assert.equal(obj[tc.name], tc.want);
}
});
}
});
}); });
for (const tc of envVarSubstTestCases) {
it(tc.name, async function () {
const obj = settings['environment variable substitution'].unset;
if (tc.name === 'undefined') {
assert(!(tc.name in obj));
} else {
assert.equal(obj[tc.name], tc.want);
}
});
}
});
}); });
});
}); });

View file

@ -1,426 +1,405 @@
import assert$0 from "assert";
import * as common from "../common.js";
import * as padManager from "../../../node/db/PadManager.js";
import * as plugins from "../../../static/js/pluginfw/plugin_defs.js";
import * as readOnlyManager from "../../../node/db/ReadOnlyManager.js";
import * as settings from "../../../node/utils/Settings.js";
import * as socketIoRouter from "../../../node/handler/SocketIORouter.js";
'use strict'; 'use strict';
const assert = assert$0.strict;
const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const readOnlyManager = require('../../../node/db/ReadOnlyManager');
const settings = require('../../../node/utils/Settings');
const socketIoRouter = require('../../../node/handler/SocketIORouter');
describe(__filename, function () { describe(__filename, function () {
this.timeout(30000); this.timeout(30000);
let agent; let agent;
let authorize; let authorize;
const backups = {}; const backups = {};
const cleanUpPads = async () => { const cleanUpPads = async () => {
const padIds = ['pad', 'other-pad', 'päd']; const padIds = ['pad', 'other-pad', 'päd'];
await Promise.all(padIds.map(async (padId) => { await Promise.all(padIds.map(async (padId) => {
if (await padManager.doesPadExist(padId)) { if (await padManager.doesPadExist(padId)) {
const pad = await padManager.getPad(padId); const pad = await padManager.getPad(padId);
await pad.remove(); await pad.remove();
} }
})); }));
};
let socket;
before(async function () { agent = await common.init(); });
beforeEach(async function () {
backups.hooks = {};
for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) {
backups.hooks[hookName] = plugins.hooks[hookName];
plugins.hooks[hookName] = [];
}
backups.settings = {};
for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users']) {
backups.settings[setting] = settings[setting];
}
settings.editOnly = false;
settings.requireAuthentication = false;
settings.requireAuthorization = false;
settings.users = {
admin: {password: 'admin-password', is_admin: true},
user: {password: 'user-password'},
}; };
assert(socket == null); let socket;
authorize = () => true; before(async function () { agent = await common.init(); });
plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => cb([authorize(req)])}]; beforeEach(async function () {
await cleanUpPads(); backups.hooks = {};
}); for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) {
afterEach(async function () { backups.hooks[hookName] = plugins.hooks[hookName];
if (socket) socket.close(); plugins.hooks[hookName] = [];
socket = null; }
await cleanUpPads(); backups.settings = {};
Object.assign(plugins.hooks, backups.hooks); for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users']) {
Object.assign(settings, backups.settings); backups.settings[setting] = settings[setting];
}); }
settings.editOnly = false;
describe('Normal accesses', function () { settings.requireAuthentication = false;
it('!authn anonymous cookie /p/pad -> 200, ok', async function () { settings.requireAuthorization = false;
const res = await agent.get('/p/pad').expect(200); settings.users = {
socket = await common.connect(res); admin: { password: 'admin-password', is_admin: true },
const clientVars = await common.handshake(socket, 'pad'); user: { password: 'user-password' },
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('!authn !cookie -> ok', async function () {
socket = await common.connect(null);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('!authn user /p/pad -> 200, ok', async function () {
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('authn user /p/pad -> 200, ok', async function () {
settings.requireAuthentication = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
for (const authn of [false, true]) {
const desc = authn ? 'authn user' : '!authn anonymous';
it(`${desc} read-only /p/pad -> 200, ok`, async function () {
const get = (ep) => {
let res = agent.get(ep);
if (authn) res = res.auth('user', 'user-password');
return res.expect(200);
}; };
settings.requireAuthentication = authn; assert(socket == null);
let res = await get('/p/pad'); authorize = () => true;
socket = await common.connect(res); plugins.hooks.authorize = [{ hook_fn: (hookName, { req }, cb) => cb([authorize(req)]) }];
let clientVars = await common.handshake(socket, 'pad'); await cleanUpPads();
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
const readOnlyId = clientVars.data.readOnlyId;
assert(readOnlyManager.isReadOnlyId(readOnlyId));
socket.close();
res = await get(`/p/${readOnlyId}`);
socket = await common.connect(res);
clientVars = await common.handshake(socket, readOnlyId);
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
}
it('authz user /p/pad -> 200, ok', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
}); });
it('supports pad names with characters that must be percent-encoded', async function () {
settings.requireAuthentication = true;
// requireAuthorization is set to true here to guarantee that the user's padAuthorizations
// object is populated. Technically this isn't necessary because the user's padAuthorizations
// is currently populated even if requireAuthorization is false, but setting this to true
// ensures the test remains useful if the implementation ever changes.
settings.requireAuthorization = true;
const encodedPadId = encodeURIComponent('päd');
const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'päd');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
});
describe('Abnormal access attempts', function () {
it('authn anonymous /p/pad -> 401, error', async function () {
settings.requireAuthentication = true;
const res = await agent.get('/p/pad').expect(401);
// Despite the 401, try to create the pad via a socket.io connection anyway.
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('authn anonymous read-only /p/pad -> 401, error', async function () {
settings.requireAuthentication = true;
let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
const readOnlyId = clientVars.data.readOnlyId;
assert(readOnlyManager.isReadOnlyId(readOnlyId));
socket.close();
res = await agent.get(`/p/${readOnlyId}`).expect(401);
// Despite the 401, try to read the pad via a socket.io connection anyway.
socket = await common.connect(res);
const message = await common.handshake(socket, readOnlyId);
assert.equal(message.accessStatus, 'deny');
});
it('authn !cookie -> error', async function () {
settings.requireAuthentication = true;
socket = await common.connect(null);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('authorization bypass attempt -> error', async function () {
// Only allowed to access /p/pad.
authorize = (req) => req.path === '/p/pad';
settings.requireAuthentication = true;
settings.requireAuthorization = true;
// First authenticate and establish a session.
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
// Accessing /p/other-pad should fail, despite the successful fetch of /p/pad.
const message = await common.handshake(socket, 'other-pad');
assert.equal(message.accessStatus, 'deny');
});
});
describe('Authorization levels via authorize hook', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
});
it("level='create' -> can create", async function () {
authorize = () => 'create';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('level=true -> can create', async function () {
authorize = () => true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it("level='modify' -> can modify", async function () {
await padManager.getPad('pad'); // Create the pad.
authorize = () => 'modify';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it("level='create' settings.editOnly=true -> unable to create", async function () {
authorize = () => 'create';
settings.editOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='modify' settings.editOnly=false -> unable to create", async function () {
authorize = () => 'modify';
settings.editOnly = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='readOnly' -> unable to create", async function () {
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='readOnly' -> unable to modify", async function () {
await padManager.getPad('pad'); // Create the pad.
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
});
describe('Authorization levels via user settings', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
});
it('user.canCreate = true -> can create and modify', async function () {
settings.users.user.canCreate = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('user.canCreate = false -> unable to create', async function () {
settings.users.user.canCreate = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user.readOnly = true -> unable to create', async function () {
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user.readOnly = true -> unable to modify', async function () {
await padManager.getPad('pad'); // Create the pad.
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
it('user.readOnly = false -> can create and modify', async function () {
settings.users.user.readOnly = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('user.readOnly = true, user.canCreate = true -> unable to create', async function () {
settings.users.user.canCreate = true;
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
});
describe('Authorization level interaction between authorize hook and user settings', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
});
it('authorize hook does not elevate level from user settings', async function () {
settings.users.user.readOnly = true;
authorize = () => 'create';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user settings does not elevate level from authorize hook', async function () {
settings.users.user.readOnly = false;
settings.users.user.canCreate = true;
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
});
describe('SocketIORouter.js', function () {
const Module = class {
setSocketIO(io) {}
handleConnect(socket) {}
handleDisconnect(socket) {}
handleMessage(socket, message) {}
};
afterEach(async function () { afterEach(async function () {
socketIoRouter.deleteComponent(this.test.fullTitle()); if (socket)
socketIoRouter.deleteComponent(`${this.test.fullTitle()} #2`); socket.close();
socket = null;
await cleanUpPads();
Object.assign(plugins.hooks, backups.hooks);
Object.assign(settings, backups.settings);
}); });
describe('Normal accesses', function () {
it('setSocketIO', async function () { it('!authn anonymous cookie /p/pad -> 200, ok', async function () {
let ioServer; const res = await agent.get('/p/pad').expect(200);
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { socket = await common.connect(res);
setSocketIO(io) { ioServer = io; } const clientVars = await common.handshake(socket, 'pad');
}()); assert.equal(clientVars.type, 'CLIENT_VARS');
assert(ioServer != null); });
}); it('!authn !cookie -> ok', async function () {
socket = await common.connect(null);
it('handleConnect', async function () { const clientVars = await common.handshake(socket, 'pad');
let serverSocket; assert.equal(clientVars.type, 'CLIENT_VARS');
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { });
handleConnect(socket) { serverSocket = socket; } it('!authn user /p/pad -> 200, ok', async function () {
}()); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(); socket = await common.connect(res);
assert(serverSocket != null); const clientVars = await common.handshake(socket, 'pad');
}); assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('handleDisconnect', async function () { it('authn user /p/pad -> 200, ok', async function () {
let resolveConnected; settings.requireAuthentication = true;
const connected = new Promise((resolve) => resolveConnected = resolve); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
let resolveDisconnected; socket = await common.connect(res);
const disconnected = new Promise((resolve) => resolveDisconnected = resolve); const clientVars = await common.handshake(socket, 'pad');
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { assert.equal(clientVars.type, 'CLIENT_VARS');
handleConnect(socket) { });
this._socket = socket; for (const authn of [false, true]) {
resolveConnected(); const desc = authn ? 'authn user' : '!authn anonymous';
it(`${desc} read-only /p/pad -> 200, ok`, async function () {
const get = (ep) => {
let res = agent.get(ep);
if (authn)
res = res.auth('user', 'user-password');
return res.expect(200);
};
settings.requireAuthentication = authn;
let res = await get('/p/pad');
socket = await common.connect(res);
let clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
const readOnlyId = clientVars.data.readOnlyId;
assert(readOnlyManager.isReadOnlyId(readOnlyId));
socket.close();
res = await get(`/p/${readOnlyId}`);
socket = await common.connect(res);
clientVars = await common.handshake(socket, readOnlyId);
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
} }
handleDisconnect(socket) { it('authz user /p/pad -> 200, ok', async function () {
assert(socket != null); settings.requireAuthentication = true;
// There might be lingering disconnect events from sockets created by other tests. settings.requireAuthorization = true;
if (this._socket == null || socket.id !== this._socket.id) return; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
assert.equal(socket, this._socket); socket = await common.connect(res);
resolveDisconnected(); const clientVars = await common.handshake(socket, 'pad');
} assert.equal(clientVars.type, 'CLIENT_VARS');
}()); });
socket = await common.connect(); it('supports pad names with characters that must be percent-encoded', async function () {
await connected; settings.requireAuthentication = true;
socket.close(); // requireAuthorization is set to true here to guarantee that the user's padAuthorizations
socket = null; // object is populated. Technically this isn't necessary because the user's padAuthorizations
await disconnected; // is currently populated even if requireAuthorization is false, but setting this to true
// ensures the test remains useful if the implementation ever changes.
settings.requireAuthorization = true;
const encodedPadId = encodeURIComponent('päd');
const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'päd');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
}); });
describe('Abnormal access attempts', function () {
it('handleMessage (success)', async function () { it('authn anonymous /p/pad -> 401, error', async function () {
let serverSocket; settings.requireAuthentication = true;
const want = { const res = await agent.get('/p/pad').expect(401);
component: this.test.fullTitle(), // Despite the 401, try to create the pad via a socket.io connection anyway.
foo: {bar: 'asdf'}, socket = await common.connect(res);
}; const message = await common.handshake(socket, 'pad');
let rx; assert.equal(message.accessStatus, 'deny');
const got = new Promise((resolve) => { rx = resolve; }); });
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { it('authn anonymous read-only /p/pad -> 401, error', async function () {
handleConnect(socket) { serverSocket = socket; } settings.requireAuthentication = true;
handleMessage(socket, message) { assert.equal(socket, serverSocket); rx(message); } let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
}()); socket = await common.connect(res);
socketIoRouter.addComponent(`${this.test.fullTitle()} #2`, new class extends Module { const clientVars = await common.handshake(socket, 'pad');
handleMessage(socket, message) { assert.fail('wrong handler called'); } assert.equal(clientVars.type, 'CLIENT_VARS');
}()); const readOnlyId = clientVars.data.readOnlyId;
socket = await common.connect(); assert(readOnlyManager.isReadOnlyId(readOnlyId));
socket.send(want); socket.close();
assert.deepEqual(await got, want); res = await agent.get(`/p/${readOnlyId}`).expect(401);
// Despite the 401, try to read the pad via a socket.io connection anyway.
socket = await common.connect(res);
const message = await common.handshake(socket, readOnlyId);
assert.equal(message.accessStatus, 'deny');
});
it('authn !cookie -> error', async function () {
settings.requireAuthentication = true;
socket = await common.connect(null);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('authorization bypass attempt -> error', async function () {
// Only allowed to access /p/pad.
authorize = (req) => req.path === '/p/pad';
settings.requireAuthentication = true;
settings.requireAuthorization = true;
// First authenticate and establish a session.
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
// Accessing /p/other-pad should fail, despite the successful fetch of /p/pad.
const message = await common.handshake(socket, 'other-pad');
assert.equal(message.accessStatus, 'deny');
});
}); });
describe('Authorization levels via authorize hook', function () {
const tx = async (socket, message = {}) => await new Promise((resolve, reject) => { beforeEach(async function () {
const AckErr = class extends Error { settings.requireAuthentication = true;
constructor(name, ...args) { super(...args); this.name = name; } settings.requireAuthorization = true;
}; });
socket.send(message, it("level='create' -> can create", async function () {
(errj, val) => errj != null ? reject(new AckErr(errj.name, errj.message)) : resolve(val)); authorize = () => 'create';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('level=true -> can create', async function () {
authorize = () => true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it("level='modify' -> can modify", async function () {
await padManager.getPad('pad'); // Create the pad.
authorize = () => 'modify';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it("level='create' settings.editOnly=true -> unable to create", async function () {
authorize = () => 'create';
settings.editOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='modify' settings.editOnly=false -> unable to create", async function () {
authorize = () => 'modify';
settings.editOnly = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='readOnly' -> unable to create", async function () {
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='readOnly' -> unable to modify", async function () {
await padManager.getPad('pad'); // Create the pad.
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
}); });
describe('Authorization levels via user settings', function () {
it('handleMessage with ack (success)', async function () { beforeEach(async function () {
const want = 'value'; settings.requireAuthentication = true;
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { });
handleMessage(socket, msg) { return want; } it('user.canCreate = true -> can create and modify', async function () {
}()); settings.users.user.canCreate = true;
socket = await common.connect(); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
const got = await tx(socket, {component: this.test.fullTitle()}); socket = await common.connect(res);
assert.equal(got, want); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('user.canCreate = false -> unable to create', async function () {
settings.users.user.canCreate = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user.readOnly = true -> unable to create', async function () {
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user.readOnly = true -> unable to modify', async function () {
await padManager.getPad('pad'); // Create the pad.
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
it('user.readOnly = false -> can create and modify', async function () {
settings.users.user.readOnly = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('user.readOnly = true, user.canCreate = true -> unable to create', async function () {
settings.users.user.canCreate = true;
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
}); });
describe('Authorization level interaction between authorize hook and user settings', function () {
it('handleMessage with ack (error)', async function () { beforeEach(async function () {
const InjectedError = class extends Error { settings.requireAuthentication = true;
constructor() { super('injected test error'); this.name = 'InjectedError'; } settings.requireAuthorization = true;
}; });
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { it('authorize hook does not elevate level from user settings', async function () {
handleMessage(socket, msg) { throw new InjectedError(); } settings.users.user.readOnly = true;
}()); authorize = () => 'create';
socket = await common.connect(); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
await assert.rejects(tx(socket, {component: this.test.fullTitle()}), new InjectedError()); socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user settings does not elevate level from authorize hook', async function () {
settings.users.user.readOnly = false;
settings.users.user.canCreate = true;
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
});
describe('SocketIORouter.js', function () {
const Module = class {
setSocketIO(io) { }
handleConnect(socket) { }
handleDisconnect(socket) { }
handleMessage(socket, message) { }
};
afterEach(async function () {
socketIoRouter.deleteComponent(this.test.fullTitle());
socketIoRouter.deleteComponent(`${this.test.fullTitle()} #2`);
});
it('setSocketIO', async function () {
let ioServer;
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
setSocketIO(io) { ioServer = io; }
}());
assert(ioServer != null);
});
it('handleConnect', async function () {
let serverSocket;
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
handleConnect(socket) { serverSocket = socket; }
}());
socket = await common.connect();
assert(serverSocket != null);
});
it('handleDisconnect', async function () {
let resolveConnected;
const connected = new Promise((resolve) => resolveConnected = resolve);
let resolveDisconnected;
const disconnected = new Promise((resolve) => resolveDisconnected = resolve);
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
handleConnect(socket) {
this._socket = socket;
resolveConnected();
}
handleDisconnect(socket) {
assert(socket != null);
// There might be lingering disconnect events from sockets created by other tests.
if (this._socket == null || socket.id !== this._socket.id)
return;
assert.equal(socket, this._socket);
resolveDisconnected();
}
}());
socket = await common.connect();
await connected;
socket.close();
socket = null;
await disconnected;
});
it('handleMessage (success)', async function () {
let serverSocket;
const want = {
component: this.test.fullTitle(),
foo: { bar: 'asdf' },
};
let rx;
const got = new Promise((resolve) => { rx = resolve; });
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
handleConnect(socket) { serverSocket = socket; }
handleMessage(socket, message) { assert.equal(socket, serverSocket); rx(message); }
}());
socketIoRouter.addComponent(`${this.test.fullTitle()} #2`, new class extends Module {
handleMessage(socket, message) { assert.fail('wrong handler called'); }
}());
socket = await common.connect();
socket.send(want);
assert.deepEqual(await got, want);
});
const tx = async (socket, message = {}) => await new Promise((resolve, reject) => {
const AckErr = class extends Error {
constructor(name, ...args) { super(...args); this.name = name; }
};
socket.send(message, (errj, val) => errj != null ? reject(new AckErr(errj.name, errj.message)) : resolve(val));
});
it('handleMessage with ack (success)', async function () {
const want = 'value';
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
handleMessage(socket, msg) { return want; }
}());
socket = await common.connect();
const got = await tx(socket, { component: this.test.fullTitle() });
assert.equal(got, want);
});
it('handleMessage with ack (error)', async function () {
const InjectedError = class extends Error {
constructor() { super('injected test error'); this.name = 'InjectedError'; }
};
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
handleMessage(socket, msg) { throw new InjectedError(); }
}());
socket = await common.connect();
await assert.rejects(tx(socket, { component: this.test.fullTitle() }), new InjectedError());
});
}); });
});
}); });

View file

@ -1,28 +1,25 @@
import * as common from "../common.js";
import * as settings from "../../../node/utils/Settings.js";
'use strict'; 'use strict';
const common = require('../common');
const settings = require('../../../node/utils/Settings');
describe(__filename, function () { describe(__filename, function () {
this.timeout(30000); this.timeout(30000);
let agent; let agent;
const backups = {}; const backups = {};
before(async function () { agent = await common.init(); }); before(async function () { agent = await common.init(); });
beforeEach(async function () { beforeEach(async function () {
backups.settings = {}; backups.settings = {};
for (const setting of ['requireAuthentication', 'requireAuthorization']) { for (const setting of ['requireAuthentication', 'requireAuthorization']) {
backups.settings[setting] = settings[setting]; backups.settings[setting] = settings[setting];
} }
settings.requireAuthentication = false; settings.requireAuthentication = false;
settings.requireAuthorization = false; settings.requireAuthorization = false;
}); });
afterEach(async function () { afterEach(async function () {
Object.assign(settings, backups.settings); Object.assign(settings, backups.settings);
}); });
describe('/javascript', function () {
describe('/javascript', function () { it('/javascript -> 200', async function () {
it('/javascript -> 200', async function () { await agent.get('/javascript').expect(200);
await agent.get('/javascript').expect(200); });
}); });
});
}); });

View file

@ -1,494 +1,478 @@
import assert$0 from "assert";
import * as common from "../common.js";
import * as plugins from "../../../static/js/pluginfw/plugin_defs.js";
import * as settings from "../../../node/utils/Settings.js";
'use strict'; 'use strict';
const assert = assert$0.strict;
const assert = require('assert').strict;
const common = require('../common');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../../node/utils/Settings');
describe(__filename, function () { describe(__filename, function () {
this.timeout(30000); this.timeout(30000);
let agent; let agent;
const backups = {}; const backups = {};
const authHookNames = ['preAuthorize', 'authenticate', 'authorize']; const authHookNames = ['preAuthorize', 'authenticate', 'authorize'];
const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure']; const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure'];
const makeHook = (hookName, hookFn) => ({ const makeHook = (hookName, hookFn) => ({
hook_fn: hookFn, hook_fn: hookFn,
hook_fn_name: `fake_plugin/${hookName}`, hook_fn_name: `fake_plugin/${hookName}`,
hook_name: hookName, hook_name: hookName,
part: {plugin: 'fake_plugin'}, part: { plugin: 'fake_plugin' },
});
before(async function () { agent = await common.init(); });
beforeEach(async function () {
backups.hooks = {};
for (const hookName of authHookNames.concat(failHookNames)) {
backups.hooks[hookName] = plugins.hooks[hookName];
plugins.hooks[hookName] = [];
}
backups.settings = {};
for (const setting of ['requireAuthentication', 'requireAuthorization', 'users']) {
backups.settings[setting] = settings[setting];
}
settings.requireAuthentication = false;
settings.requireAuthorization = false;
settings.users = {
admin: {password: 'admin-password', is_admin: true},
user: {password: 'user-password'},
};
});
afterEach(async function () {
Object.assign(plugins.hooks, backups.hooks);
Object.assign(settings, backups.settings);
});
describe('webaccess: without plugins', function () {
it('!authn !authz anonymous / -> 200', async function () {
settings.requireAuthentication = false;
settings.requireAuthorization = false;
await agent.get('/').expect(200);
}); });
it('!authn !authz anonymous /admin/ -> 401', async function () { before(async function () { agent = await common.init(); });
settings.requireAuthentication = false;
settings.requireAuthorization = false;
await agent.get('/admin/').expect(401);
});
it('authn !authz anonymous / -> 401', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/').expect(401);
});
it('authn !authz user / -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/').auth('user', 'user-password').expect(200);
});
it('authn !authz user /admin/ -> 403', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/admin/').auth('user', 'user-password').expect(403);
});
it('authn !authz admin / -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/').auth('admin', 'admin-password').expect(200);
});
it('authn !authz admin /admin/ -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
});
it('authn authz anonymous /robots.txt -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/robots.txt').expect(200);
});
it('authn authz user / -> 403', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/').auth('user', 'user-password').expect(403);
});
it('authn authz user /admin/ -> 403', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/admin/').auth('user', 'user-password').expect(403);
});
it('authn authz admin / -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/').auth('admin', 'admin-password').expect(200);
});
it('authn authz admin /admin/ -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
});
describe('login fails if password is nullish', function () {
for (const adminPassword of [undefined, null]) {
// https://tools.ietf.org/html/rfc7617 says that the username and password are sent as
// base64(username + ':' + password), but there's nothing stopping a malicious user from
// sending just base64(username) (no colon). The lack of colon could throw off credential
// parsing, resulting in successful comparisons against a null or undefined password.
for (const creds of ['admin', 'admin:']) {
it(`admin password: ${adminPassword} credentials: ${creds}`, async function () {
settings.users.admin.password = adminPassword;
const encCreds = Buffer.from(creds).toString('base64');
await agent.get('/admin/').set('Authorization', `Basic ${encCreds}`).expect(401);
});
}
}
});
});
describe('webaccess: preAuthorize, authenticate, and authorize hooks', function () {
let callOrder;
const Handler = class {
constructor(hookName, suffix) {
this.called = false;
this.hookName = hookName;
this.innerHandle = () => [];
this.id = hookName + suffix;
this.checkContext = () => {};
}
handle(hookName, context, cb) {
assert.equal(hookName, this.hookName);
assert(context != null);
assert(context.req != null);
assert(context.res != null);
assert(context.next != null);
this.checkContext(context);
assert(!this.called);
this.called = true;
callOrder.push(this.id);
return cb(this.innerHandle(context));
}
};
const handlers = {};
beforeEach(async function () { beforeEach(async function () {
callOrder = []; backups.hooks = {};
for (const hookName of authHookNames) { for (const hookName of authHookNames.concat(failHookNames)) {
// Create two handlers for each hook to test deferral to the next function. backups.hooks[hookName] = plugins.hooks[hookName];
const h0 = new Handler(hookName, '_0'); plugins.hooks[hookName] = [];
const h1 = new Handler(hookName, '_1');
handlers[hookName] = [h0, h1];
plugins.hooks[hookName] = [
makeHook(hookName, h0.handle.bind(h0)),
makeHook(hookName, h1.handle.bind(h1)),
];
}
});
describe('preAuthorize', function () {
beforeEach(async function () {
settings.requireAuthentication = false;
settings.requireAuthorization = false;
});
it('defers if it returns []', async function () {
await agent.get('/').expect(200);
// Note: The preAuthorize hook always runs even if requireAuthorization is false.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
});
it('bypasses authenticate and authorize hooks when true is returned', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
handlers.preAuthorize[0].innerHandle = () => [true];
await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('bypasses authenticate and authorize hooks when false is returned', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
handlers.preAuthorize[0].innerHandle = () => [false];
await agent.get('/').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('bypasses authenticate and authorize hooks when next is called', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
handlers.preAuthorize[0].innerHandle = ({next}) => next();
await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('static content (expressPreSession) bypasses all auth checks', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/static/robots.txt').expect(200);
assert.deepEqual(callOrder, []);
});
it('cannot grant access to /admin', async function () {
handlers.preAuthorize[0].innerHandle = () => [true];
await agent.get('/admin/').expect(401);
// Notes:
// * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because
// 'true' entries are ignored for /admin/* requests.
// * The authenticate hook always runs for /admin/* requests even if
// settings.requireAuthentication is false.
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('can deny access to /admin', async function () {
handlers.preAuthorize[0].innerHandle = () => [false];
await agent.get('/admin/').auth('admin', 'admin-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('runs preAuthzFailure hook when access is denied', async function () {
handlers.preAuthorize[0].innerHandle = () => [false];
let called = false;
plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName, {req, res}, cb) => {
assert.equal(hookName, 'preAuthzFailure');
assert(req != null);
assert(res != null);
assert(!called);
called = true;
res.status(200).send('injected');
return cb([true]);
})];
await agent.get('/admin/').auth('admin', 'admin-password').expect(200, 'injected');
assert(called);
});
it('returns 500 if an exception is thrown', async function () {
handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').expect(500);
});
});
describe('authenticate', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
});
it('is not called if !requireAuthentication and not /admin/*', async function () {
settings.requireAuthentication = false;
await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
});
it('is called if !requireAuthentication and /admin/*', async function () {
settings.requireAuthentication = false;
await agent.get('/admin/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('defers if empty list returned', async function () {
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('does not defer if return [true], 200', async function () {
handlers.authenticate[0].innerHandle = ({req}) => { req.session.user = {}; return [true]; };
await agent.get('/').expect(200);
// Note: authenticate_1 was not called because authenticate_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('does not defer if return [false], 401', async function () {
handlers.authenticate[0].innerHandle = () => [false];
await agent.get('/').expect(401);
// Note: authenticate_1 was not called because authenticate_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('falls back to HTTP basic auth', async function () {
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('passes settings.users in context', async function () {
handlers.authenticate[0].checkContext = ({users}) => {
assert.equal(users, settings.users);
};
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('passes user, password in context if provided', async function () {
handlers.authenticate[0].checkContext = ({username, password}) => {
assert.equal(username, 'user');
assert.equal(password, 'user-password');
};
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('does not pass user, password in context if not provided', async function () {
handlers.authenticate[0].checkContext = ({username, password}) => {
assert(username == null);
assert(password == null);
};
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('errors if req.session.user is not created', async function () {
handlers.authenticate[0].innerHandle = () => [true];
await agent.get('/').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('returns 500 if an exception is thrown', async function () {
handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
});
describe('authorize', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
});
it('is not called if !requireAuthorization (non-/admin)', async function () {
settings.requireAuthorization = false;
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('is not called if !requireAuthorization (/admin)', async function () {
settings.requireAuthorization = false;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('defers if empty list returned', async function () {
await agent.get('/').auth('user', 'user-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0',
'authorize_1']);
});
it('does not defer if return [true], 200', async function () {
handlers.authorize[0].innerHandle = () => [true];
await agent.get('/').auth('user', 'user-password').expect(200);
// Note: authorize_1 was not called because authorize_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0']);
});
it('does not defer if return [false], 403', async function () {
handlers.authorize[0].innerHandle = () => [false];
await agent.get('/').auth('user', 'user-password').expect(403);
// Note: authorize_1 was not called because authorize_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0']);
});
it('passes req.path in context', async function () {
handlers.authorize[0].checkContext = ({resource}) => {
assert.equal(resource, '/');
};
await agent.get('/').auth('user', 'user-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0',
'authorize_1']);
});
it('returns 500 if an exception is thrown', async function () {
handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').auth('user', 'user-password').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0']);
});
});
});
describe('webaccess: authnFailure, authzFailure, authFailure hooks', function () {
const Handler = class {
constructor(hookName) {
this.hookName = hookName;
this.shouldHandle = false;
this.called = false;
}
handle(hookName, context, cb) {
assert.equal(hookName, this.hookName);
assert(context != null);
assert(context.req != null);
assert(context.res != null);
assert(!this.called);
this.called = true;
if (this.shouldHandle) {
context.res.status(200).send(this.hookName);
return cb([true]);
} }
return cb([]); backups.settings = {};
} for (const setting of ['requireAuthentication', 'requireAuthorization', 'users']) {
}; backups.settings[setting] = settings[setting];
const handlers = {}; }
settings.requireAuthentication = false;
beforeEach(async function () { settings.requireAuthorization = false;
failHookNames.forEach((hookName) => { settings.users = {
const handler = new Handler(hookName); admin: { password: 'admin-password', is_admin: true },
handlers[hookName] = handler; user: { password: 'user-password' },
plugins.hooks[hookName] = [makeHook(hookName, handler.handle.bind(handler))]; };
});
settings.requireAuthentication = true;
settings.requireAuthorization = true;
}); });
afterEach(async function () {
// authn failure tests Object.assign(plugins.hooks, backups.hooks);
it('authn fail, no hooks handle -> 401', async function () { Object.assign(settings, backups.settings);
await agent.get('/').expect(401);
assert(handlers.authnFailure.called);
assert(!handlers.authzFailure.called);
assert(handlers.authFailure.called);
}); });
it('authn fail, authnFailure handles', async function () { describe('webaccess: without plugins', function () {
handlers.authnFailure.shouldHandle = true; it('!authn !authz anonymous / -> 200', async function () {
await agent.get('/').expect(200, 'authnFailure'); settings.requireAuthentication = false;
assert(handlers.authnFailure.called); settings.requireAuthorization = false;
assert(!handlers.authzFailure.called); await agent.get('/').expect(200);
assert(!handlers.authFailure.called); });
it('!authn !authz anonymous /admin/ -> 401', async function () {
settings.requireAuthentication = false;
settings.requireAuthorization = false;
await agent.get('/admin/').expect(401);
});
it('authn !authz anonymous / -> 401', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/').expect(401);
});
it('authn !authz user / -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/').auth('user', 'user-password').expect(200);
});
it('authn !authz user /admin/ -> 403', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/admin/').auth('user', 'user-password').expect(403);
});
it('authn !authz admin / -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/').auth('admin', 'admin-password').expect(200);
});
it('authn !authz admin /admin/ -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
});
it('authn authz anonymous /robots.txt -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/robots.txt').expect(200);
});
it('authn authz user / -> 403', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/').auth('user', 'user-password').expect(403);
});
it('authn authz user /admin/ -> 403', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/admin/').auth('user', 'user-password').expect(403);
});
it('authn authz admin / -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/').auth('admin', 'admin-password').expect(200);
});
it('authn authz admin /admin/ -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
});
describe('login fails if password is nullish', function () {
for (const adminPassword of [undefined, null]) {
// https://tools.ietf.org/html/rfc7617 says that the username and password are sent as
// base64(username + ':' + password), but there's nothing stopping a malicious user from
// sending just base64(username) (no colon). The lack of colon could throw off credential
// parsing, resulting in successful comparisons against a null or undefined password.
for (const creds of ['admin', 'admin:']) {
it(`admin password: ${adminPassword} credentials: ${creds}`, async function () {
settings.users.admin.password = adminPassword;
const encCreds = Buffer.from(creds).toString('base64');
await agent.get('/admin/').set('Authorization', `Basic ${encCreds}`).expect(401);
});
}
}
});
}); });
it('authn fail, authFailure handles', async function () { describe('webaccess: preAuthorize, authenticate, and authorize hooks', function () {
handlers.authFailure.shouldHandle = true; let callOrder;
await agent.get('/').expect(200, 'authFailure'); const Handler = class {
assert(handlers.authnFailure.called); constructor(hookName, suffix) {
assert(!handlers.authzFailure.called); this.called = false;
assert(handlers.authFailure.called); this.hookName = hookName;
this.innerHandle = () => [];
this.id = hookName + suffix;
this.checkContext = () => { };
}
handle(hookName, context, cb) {
assert.equal(hookName, this.hookName);
assert(context != null);
assert(context.req != null);
assert(context.res != null);
assert(context.next != null);
this.checkContext(context);
assert(!this.called);
this.called = true;
callOrder.push(this.id);
return cb(this.innerHandle(context));
}
};
const handlers = {};
beforeEach(async function () {
callOrder = [];
for (const hookName of authHookNames) {
// Create two handlers for each hook to test deferral to the next function.
const h0 = new Handler(hookName, '_0');
const h1 = new Handler(hookName, '_1');
handlers[hookName] = [h0, h1];
plugins.hooks[hookName] = [
makeHook(hookName, h0.handle.bind(h0)),
makeHook(hookName, h1.handle.bind(h1)),
];
}
});
describe('preAuthorize', function () {
beforeEach(async function () {
settings.requireAuthentication = false;
settings.requireAuthorization = false;
});
it('defers if it returns []', async function () {
await agent.get('/').expect(200);
// Note: The preAuthorize hook always runs even if requireAuthorization is false.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
});
it('bypasses authenticate and authorize hooks when true is returned', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
handlers.preAuthorize[0].innerHandle = () => [true];
await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('bypasses authenticate and authorize hooks when false is returned', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
handlers.preAuthorize[0].innerHandle = () => [false];
await agent.get('/').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('bypasses authenticate and authorize hooks when next is called', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
handlers.preAuthorize[0].innerHandle = ({ next }) => next();
await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('static content (expressPreSession) bypasses all auth checks', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/static/robots.txt').expect(200);
assert.deepEqual(callOrder, []);
});
it('cannot grant access to /admin', async function () {
handlers.preAuthorize[0].innerHandle = () => [true];
await agent.get('/admin/').expect(401);
// Notes:
// * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because
// 'true' entries are ignored for /admin/* requests.
// * The authenticate hook always runs for /admin/* requests even if
// settings.requireAuthentication is false.
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('can deny access to /admin', async function () {
handlers.preAuthorize[0].innerHandle = () => [false];
await agent.get('/admin/').auth('admin', 'admin-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('runs preAuthzFailure hook when access is denied', async function () {
handlers.preAuthorize[0].innerHandle = () => [false];
let called = false;
plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName, { req, res }, cb) => {
assert.equal(hookName, 'preAuthzFailure');
assert(req != null);
assert(res != null);
assert(!called);
called = true;
res.status(200).send('injected');
return cb([true]);
})];
await agent.get('/admin/').auth('admin', 'admin-password').expect(200, 'injected');
assert(called);
});
it('returns 500 if an exception is thrown', async function () {
handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').expect(500);
});
});
describe('authenticate', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
});
it('is not called if !requireAuthentication and not /admin/*', async function () {
settings.requireAuthentication = false;
await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
});
it('is called if !requireAuthentication and /admin/*', async function () {
settings.requireAuthentication = false;
await agent.get('/admin/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('defers if empty list returned', async function () {
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('does not defer if return [true], 200', async function () {
handlers.authenticate[0].innerHandle = ({ req }) => { req.session.user = {}; return [true]; };
await agent.get('/').expect(200);
// Note: authenticate_1 was not called because authenticate_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('does not defer if return [false], 401', async function () {
handlers.authenticate[0].innerHandle = () => [false];
await agent.get('/').expect(401);
// Note: authenticate_1 was not called because authenticate_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('falls back to HTTP basic auth', async function () {
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('passes settings.users in context', async function () {
handlers.authenticate[0].checkContext = ({ users }) => {
assert.equal(users, settings.users);
};
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('passes user, password in context if provided', async function () {
handlers.authenticate[0].checkContext = ({ username, password }) => {
assert.equal(username, 'user');
assert.equal(password, 'user-password');
};
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('does not pass user, password in context if not provided', async function () {
handlers.authenticate[0].checkContext = ({ username, password }) => {
assert(username == null);
assert(password == null);
};
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('errors if req.session.user is not created', async function () {
handlers.authenticate[0].innerHandle = () => [true];
await agent.get('/').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('returns 500 if an exception is thrown', async function () {
handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
});
describe('authorize', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
});
it('is not called if !requireAuthorization (non-/admin)', async function () {
settings.requireAuthorization = false;
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('is not called if !requireAuthorization (/admin)', async function () {
settings.requireAuthorization = false;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('defers if empty list returned', async function () {
await agent.get('/').auth('user', 'user-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0',
'authorize_1']);
});
it('does not defer if return [true], 200', async function () {
handlers.authorize[0].innerHandle = () => [true];
await agent.get('/').auth('user', 'user-password').expect(200);
// Note: authorize_1 was not called because authorize_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0']);
});
it('does not defer if return [false], 403', async function () {
handlers.authorize[0].innerHandle = () => [false];
await agent.get('/').auth('user', 'user-password').expect(403);
// Note: authorize_1 was not called because authorize_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0']);
});
it('passes req.path in context', async function () {
handlers.authorize[0].checkContext = ({ resource }) => {
assert.equal(resource, '/');
};
await agent.get('/').auth('user', 'user-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0',
'authorize_1']);
});
it('returns 500 if an exception is thrown', async function () {
handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').auth('user', 'user-password').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0']);
});
});
}); });
it('authnFailure trumps authFailure', async function () { describe('webaccess: authnFailure, authzFailure, authFailure hooks', function () {
handlers.authnFailure.shouldHandle = true; const Handler = class {
handlers.authFailure.shouldHandle = true; constructor(hookName) {
await agent.get('/').expect(200, 'authnFailure'); this.hookName = hookName;
assert(handlers.authnFailure.called); this.shouldHandle = false;
assert(!handlers.authFailure.called); this.called = false;
}
handle(hookName, context, cb) {
assert.equal(hookName, this.hookName);
assert(context != null);
assert(context.req != null);
assert(context.res != null);
assert(!this.called);
this.called = true;
if (this.shouldHandle) {
context.res.status(200).send(this.hookName);
return cb([true]);
}
return cb([]);
}
};
const handlers = {};
beforeEach(async function () {
failHookNames.forEach((hookName) => {
const handler = new Handler(hookName);
handlers[hookName] = handler;
plugins.hooks[hookName] = [makeHook(hookName, handler.handle.bind(handler))];
});
settings.requireAuthentication = true;
settings.requireAuthorization = true;
});
// authn failure tests
it('authn fail, no hooks handle -> 401', async function () {
await agent.get('/').expect(401);
assert(handlers.authnFailure.called);
assert(!handlers.authzFailure.called);
assert(handlers.authFailure.called);
});
it('authn fail, authnFailure handles', async function () {
handlers.authnFailure.shouldHandle = true;
await agent.get('/').expect(200, 'authnFailure');
assert(handlers.authnFailure.called);
assert(!handlers.authzFailure.called);
assert(!handlers.authFailure.called);
});
it('authn fail, authFailure handles', async function () {
handlers.authFailure.shouldHandle = true;
await agent.get('/').expect(200, 'authFailure');
assert(handlers.authnFailure.called);
assert(!handlers.authzFailure.called);
assert(handlers.authFailure.called);
});
it('authnFailure trumps authFailure', async function () {
handlers.authnFailure.shouldHandle = true;
handlers.authFailure.shouldHandle = true;
await agent.get('/').expect(200, 'authnFailure');
assert(handlers.authnFailure.called);
assert(!handlers.authFailure.called);
});
// authz failure tests
it('authz fail, no hooks handle -> 403', async function () {
await agent.get('/').auth('user', 'user-password').expect(403);
assert(!handlers.authnFailure.called);
assert(handlers.authzFailure.called);
assert(handlers.authFailure.called);
});
it('authz fail, authzFailure handles', async function () {
handlers.authzFailure.shouldHandle = true;
await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure');
assert(!handlers.authnFailure.called);
assert(handlers.authzFailure.called);
assert(!handlers.authFailure.called);
});
it('authz fail, authFailure handles', async function () {
handlers.authFailure.shouldHandle = true;
await agent.get('/').auth('user', 'user-password').expect(200, 'authFailure');
assert(!handlers.authnFailure.called);
assert(handlers.authzFailure.called);
assert(handlers.authFailure.called);
});
it('authzFailure trumps authFailure', async function () {
handlers.authzFailure.shouldHandle = true;
handlers.authFailure.shouldHandle = true;
await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure');
assert(handlers.authzFailure.called);
assert(!handlers.authFailure.called);
});
}); });
// authz failure tests
it('authz fail, no hooks handle -> 403', async function () {
await agent.get('/').auth('user', 'user-password').expect(403);
assert(!handlers.authnFailure.called);
assert(handlers.authzFailure.called);
assert(handlers.authFailure.called);
});
it('authz fail, authzFailure handles', async function () {
handlers.authzFailure.shouldHandle = true;
await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure');
assert(!handlers.authnFailure.called);
assert(handlers.authzFailure.called);
assert(!handlers.authFailure.called);
});
it('authz fail, authFailure handles', async function () {
handlers.authFailure.shouldHandle = true;
await agent.get('/').auth('user', 'user-password').expect(200, 'authFailure');
assert(!handlers.authnFailure.called);
assert(handlers.authzFailure.called);
assert(handlers.authFailure.called);
});
it('authzFailure trumps authFailure', async function () {
handlers.authzFailure.shouldHandle = true;
handlers.authFailure.shouldHandle = true;
await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure');
assert(handlers.authzFailure.called);
assert(!handlers.authFailure.called);
});
});
}); });