mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-06-15 18:54:45 -04:00
Move all files to esm
This commit is contained in:
parent
237f7242ec
commit
76a6f665a4
87 changed files with 23693 additions and 30732 deletions
1
node_modules/ep_etherpad-lite
generated
vendored
1
node_modules/ep_etherpad-lite
generated
vendored
|
@ -1 +0,0 @@
|
||||||
../src
|
|
|
@ -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
1553
src/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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)
|
||||||
|
|
||||||
// for the last, potentially partial, line
|
*/
|
||||||
hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]);
|
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;
|
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;
|
|
||||||
|
|
|
@ -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
|
@ -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]);
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
@ -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]);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$('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));
|
}(jQuery));
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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));
|
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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> </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> </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;
|
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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
|
@ -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}`,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 += ' ';
|
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 += ' ';
|
||||||
|
}
|
||||||
|
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, ' ');
|
return s.replace(/ /g, ' ');
|
||||||
}
|
|
||||||
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] = ' ';
|
|
||||||
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] = ' ';
|
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] = ' ';
|
||||||
|
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] = ' ';
|
||||||
|
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] = ' ';
|
parts[i] = ' ';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return parts.join('');
|
||||||
return parts.join('');
|
|
||||||
};
|
};
|
||||||
|
export { domline };
|
||||||
exports.domline = domline;
|
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
1352
src/static/js/pad.js
1352
src/static/js/pad.js
File diff suppressed because it is too large
Load diff
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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_({});
|
|
||||||
}
|
|
||||||
}();
|
}();
|
||||||
|
|
|
@ -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('/'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}();
|
}();
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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
|
@ -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 };
|
||||||
|
|
|
@ -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 $ };
|
||||||
|
|
|
@ -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);
|
|
||||||
|
|
|
@ -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');
|
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -1,3 +1,2 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
export { default } from "underscore";
|
||||||
module.exports = require('underscore');
|
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
578
src/static/js/vendors/browser.js
vendored
578
src/static/js/vendors/browser.js
vendored
|
@ -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();
|
||||||
|
|
970
src/static/js/vendors/farbtastic.js
vendored
970
src/static/js/vendors/farbtastic.js
vendored
|
@ -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);
|
||||||
|
|
590
src/static/js/vendors/gritter.js
vendored
590
src/static/js/vendors/gritter.js
vendored
|
@ -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
|
||||||
|
|
1897
src/static/js/vendors/html10n.js
vendored
1897
src/static/js/vendors/html10n.js
vendored
File diff suppressed because it is too large
Load diff
17101
src/static/js/vendors/jquery.js
vendored
17101
src/static/js/vendors/jquery.js
vendored
File diff suppressed because it is too large
Load diff
379
src/static/js/vendors/nice-select.js
vendored
379
src/static/js/vendors/nice-select.js
vendored
|
@ -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));
|
||||||
|
|
|
@ -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/
|
||||||
};
|
};
|
||||||
|
|
|
@ -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'); });
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
window.customStart = () => {
|
window.customStart = () => {
|
||||||
};
|
};
|
||||||
|
|
|
@ -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/
|
||||||
};
|
};
|
||||||
|
|
|
@ -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/
|
||||||
};
|
};
|
||||||
|
|
|
@ -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/
|
||||||
};
|
};
|
||||||
|
|
|
@ -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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}`);
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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, /🇼/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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, /🇼/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 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',
|
},
|
||||||
},
|
'multiplenbsp': {
|
||||||
'multiplenbsp': {
|
description: 'Multiple non-breaking space should be preserved',
|
||||||
description: 'Multiple non-breaking space should be preserved',
|
input: '<html><body> <br></body></html>',
|
||||||
input: '<html><body> <br></body></html>',
|
wantHTML: '<!DOCTYPE HTML><html><body> <br><br></body></html>',
|
||||||
wantHTML: '<!DOCTYPE HTML><html><body> <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> word1 word2 word3<br></body></html>',
|
||||||
input: '<html><body> word1 word2 word3<br></body></html>',
|
wantHTML: '<!DOCTYPE HTML><html><body> word1 word2 word3<br><br></body></html>',
|
||||||
wantHTML: '<!DOCTYPE HTML><html><body> word1 word2 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> word1 word2 word3<br></body></html>',
|
||||||
input: '<html><body> word1 word2 word3<br></body></html>',
|
wantHTML: '<!DOCTYPE HTML><html><body> word1 word2 word3<br><br></body></html>',
|
||||||
wantHTML: '<!DOCTYPE HTML><html><body> word1 word2 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> word1 word2 word3<br></body></html>',
|
||||||
input: '<html><body> word1 word2 word3<br></body></html>',
|
wantHTML: '<!DOCTYPE HTML><html><body> word1 word2 word3<br><br></body></html>',
|
||||||
wantHTML: '<!DOCTYPE HTML><html><body> word1 word2 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> 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\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> 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\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>а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь<br><br></body></html>',
|
wantHTML: '<!DOCTYPE HTML><html><body>а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь<br><br></body></html>',
|
||||||
wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n',
|
wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n',
|
||||||
},
|
},
|
||||||
'multiLineParagraphWithPre': {
|
'multiLineParagraphWithPre': {
|
||||||
// XXX why is there before "in"?
|
// XXX why is there 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>а б в г ґ д е є ж з и і ї й к л м н о<br>multiple<br> lines<br> in<br> pre<br><br>п р с т у ф х ц ч ш щ ю я ь<br><br></body></html>',
|
wantHTML: '<!DOCTYPE HTML><html><body>а б в г ґ д е є ж з и і ї й к л м н о<br>multiple<br> lines<br> in<br> pre<br><br>п р с т у ф х ц ч ш щ ю я ь<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;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 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: 'Multiple nbsp should be preserved',
|
description: 'Multiple nbsp should be preserved',
|
||||||
html: '<html><body> <br></body></html>',
|
html: '<html><body> <br></body></html>',
|
||||||
wantAlines: ['+2'],
|
wantAlines: ['+2'],
|
||||||
wantText: [' '],
|
wantText: [' '],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Multiple nbsp between words ',
|
description: 'Multiple nbsp between words ',
|
||||||
html: '<html><body> word1 word2 word3<br></body></html>',
|
html: '<html><body> word1 word2 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> word1 word2 word3<br></body></html>',
|
html: '<html><body> word1 word2 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> word1 word2 word3<br></body></html>',
|
html: '<html><body> word1 word2 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> something<br></body></html>',
|
html: '<html><body>something<br> 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> something<br></body></html>',
|
html: '<html><body>something<p></p> 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);
|
});
|
||||||
});
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue