diff --git a/src/node/types/PadType.ts b/src/node/types/PadType.ts index b344ed8c5..43d6e31c0 100644 --- a/src/node/types/PadType.ts +++ b/src/node/types/PadType.ts @@ -19,6 +19,7 @@ export type PadType = { getRevisionDate: (rev: number)=>Promise, getRevisionChangeset: (rev: number)=>Promise, appendRevision: (changeset: AChangeSet, author: string)=>Promise, + settings:any } diff --git a/src/static/js/AttributeMap.ts b/src/static/js/AttributeMap.ts index 4bdbfd9b0..4e3fa636c 100644 --- a/src/static/js/AttributeMap.ts +++ b/src/static/js/AttributeMap.ts @@ -31,7 +31,7 @@ class AttributeMap extends Map { * @param {AttributePool} pool - Attribute pool. * @returns {AttributeMap} */ - public static fromString(str: string, pool: AttributePool): AttributeMap { + public static fromString(str: string, pool?: AttributePool|null): AttributeMap { return new AttributeMap(pool).updateFromString(str); } diff --git a/src/static/js/AttributionLinesMutator.ts b/src/static/js/AttributionLinesMutator.ts new file mode 100644 index 000000000..6eb36913c --- /dev/null +++ b/src/static/js/AttributionLinesMutator.ts @@ -0,0 +1,132 @@ +import {TextLinesMutator} from "./TextLinesMutator"; +import AttributePool from "./AttributePool"; +import {assert, copyOp, deserializeOps, slicerZipperFunc, unpack} from "./Changeset"; +import Op from "./Op"; +import {MergingOpAssembler} from "./MergingOpAssembler"; + +/** + * Applies a changeset to an array of attribute lines. + * + * @param {string} cs - The encoded changeset. + * @param {Array} lines - Attribute lines. Modified in place. + * @param {AttributePool.ts} pool - Attribute pool. + */ +export class AttributionLinesMutator { + private unpacked + private csOps: Generator|Op + private csOpsNext: IteratorResult + private csBank: string + private csBankIndex: number + private mut: TextLinesMutator + private lineOps: Generator|null + private lineOpsNext: IteratorResult|null + private lineAssem: null|MergingOpAssembler + private attOp: Op + private csOp: Op + constructor(cs: string, lines:string[], pool: AttributePool) { + this.unpacked = unpack(cs); + this.csOps = deserializeOps(this.unpacked.ops); + this.csOpsNext = this.csOps.next(); + this.csBank = this.unpacked.charBank; + this.csBankIndex = 0; + // treat the attribution lines as text lines, mutating a line at a time + this.mut = new TextLinesMutator(lines); + /** + * The Ops in the current line from `lines`. + * + * @type {?Generator} + */ + this.lineOps = null; + this.lineOpsNext = null; + this.lineAssem = null + this.csOp = new Op() + this.attOp = new Op() + while (this.csOp.opcode || !this.csOpsNext.done || this.attOp.opcode || this.isNextMutOp()) { + if (!this.csOp.opcode && !this.csOpsNext.done) { + // coOp done, but more ops in cs. + this.csOp = this.csOpsNext.value; + this.csOpsNext = this.csOps.next(); + } + if (!this.csOp.opcode && !this.attOp.opcode && !this.lineAssem && !this.lineOpsHasNext()) { + break; // done + } else if (this.csOp.opcode === '=' && this.csOp.lines > 0 && !this.csOp.attribs && !this.attOp.opcode && + !this.lineAssem && !this.lineOpsHasNext()) { + // Skip multiple lines without attributes; this is what makes small changes not order of the + // document size. + this.mut.skipLines(this.csOp.lines); + this.csOp.opcode = ''; + } else if (this.csOp.opcode === '+') { + const opOut = copyOp(this.csOp); + if (this.csOp.lines > 1) { + // Copy the first line from `csOp` to `opOut`. + const firstLineLen = this.csBank.indexOf('\n', this.csBankIndex) + 1 - this.csBankIndex; + this.csOp.chars -= firstLineLen; + this.csOp.lines--; + opOut.lines = 1; + opOut.chars = firstLineLen; + } else { + // Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`. + this.csOp.opcode = ''; + } + this.outputMutOp(opOut); + this.csBankIndex += opOut.chars; + } else { + if (!this.attOp.opcode && this.isNextMutOp()) { + this.attOp = this.nextMutOp(); + } + const opOut = slicerZipperFunc(this.attOp, this.csOp, pool); + if (opOut.opcode) { + this.outputMutOp(opOut); + } + } + } + + assert(!this.lineAssem, `line assembler not finished:${cs}`); + this.mut.close(); + } + + lineOpsHasNext = () => this.lineOpsNext && !this.lineOpsNext.done; + /** + * Returns false if we are on the last attribute line in `lines` and there is no additional op in + * that line. + * + * @returns {boolean} True if there are more ops to go through. + */ + isNextMutOp = () => this.lineOpsHasNext() || this.mut.hasMore(); + + + /** + * @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to + * iterate over the next line, which is consumed from `mut`. If there are no more lines, + * returns a null Op. + */ + nextMutOp = () => { + if (!this.lineOpsHasNext() && this.mut.hasMore()) { + // There are more attribute lines in `lines` to do AND either we just started so `lineIter` is + // still null or there are no more ops in current `lineIter`. + const line = this.mut.removeLines(1); + this.lineOps = deserializeOps(line); + this.lineOpsNext = this.lineOps.next(); + } + if (!this.lineOpsHasNext()) return new Op(); // No more ops and no more lines. + const op = this.lineOpsNext!.value; + this.lineOpsNext = this.lineOps!.next(); + return op; + } + + /** + * Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the + * `lines` mutator. + */ + outputMutOp = (op: Op) => { + if (!this.lineAssem) { + this.lineAssem = new MergingOpAssembler() + } + this.lineAssem.append(op); + if (op.lines <= 0) return; + assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`); + // ship it to the mut + this.mut.insert(this.lineAssem.toString(), 1); + this.lineAssem = null; + }; +} diff --git a/src/static/js/Builder.ts b/src/static/js/Builder.ts new file mode 100644 index 000000000..496d62991 --- /dev/null +++ b/src/static/js/Builder.ts @@ -0,0 +1,109 @@ +/** + * Incrementally builds a Changeset. + * + * @typedef {object} Builder + * @property {Function} insert - + * @property {Function} keep - + * @property {Function} keepText - + * @property {Function} remove - + * @property {Function} toString - + */ +import {SmartOpAssembler} from "./SmartOpAssembler"; +import Op from "./Op"; +import {StringAssembler} from "./StringAssembler"; +import AttributeMap from "./AttributeMap"; +import {Attribute} from "./types/Attribute"; +import AttributePool from "./AttributePool"; +import {opsFromText} from "./Changeset"; + +/** + * @param {number} oldLen - Old length + * @returns {Builder} + */ +export class Builder { + private readonly oldLen: number; + private assem: SmartOpAssembler; + private readonly o: Op; + private charBank: StringAssembler; + + constructor(oldLen: number) { + this.oldLen = oldLen + this.assem = new SmartOpAssembler() + this.o = new Op() + this.charBank = new StringAssembler() + } + + /** + * @param {number} N - Number of characters to keep. + * @param {number} L - Number of newlines among the `N` characters. If positive, the last + * character must be a newline. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + keep = (N: number, L: number, attribs?: string|Attribute[], pool?: AttributePool): Builder => { + this.o.opcode = '='; + this.o.attribs = typeof attribs === 'string' + ? attribs : new AttributeMap(pool).update(attribs || []).toString(); + this.o.chars = N; + this.o.lines = (L || 0); + this.assem.append(this.o); + return this; + } + + + /** + * @param {string} text - Text to keep. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + keepText= (text: string, attribs: string|Attribute[], pool?: AttributePool): Builder=> { + for (const op of opsFromText('=', text, attribs, pool)) this.assem.append(op); + return this; + } + + + /** + * @param {string} text - Text to insert. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + insert= (text: string, attribs: string | Attribute[] | undefined, pool?: AttributePool | null | undefined): Builder => { + for (const op of opsFromText('+', text, attribs, pool)) this.assem.append(op); + this.charBank.append(text); + return this; + } + + + /** + * @param {number} N - Number of characters to remove. + * @param {number} L - Number of newlines among the `N` characters. If positive, the last + * character must be a newline. + * @returns {Builder} this + */ + remove= (N: number, L: number): Builder => { + this.o.opcode = '-'; + this.o.attribs = ''; + this.o.chars = N; + this.o.lines = (L || 0); + this.assem.append(this.o); + return this; + } + + toString= () => { + this.assem.endDocument(); + const newLen = this.oldLen + this.assem.getLengthChange(); + return exports.pack(this.oldLen, newLen, this.assem.toString(), this.charBank.toString()); + } +} + + + diff --git a/src/static/js/Changeset.ts b/src/static/js/Changeset.ts index e4b5871f2..9da2e710e 100644 --- a/src/static/js/Changeset.ts +++ b/src/static/js/Changeset.ts @@ -24,13 +24,21 @@ import AttributeMap from './AttributeMap' import AttributePool from "./AttributePool"; -import {} from './attributes'; +import {attribsFromString} from './attributes'; import {padUtils as padutils} from "./pad_utils"; -import Op from './Op' +import Op, {OpCode} from './Op' import {numToString, parseNum} from './ChangesetUtils' import {StringAssembler} from "./StringAssembler"; import {OpIter} from "./OpIter"; import {Attribute} from "./types/Attribute"; +import {SmartOpAssembler} from "./SmartOpAssembler"; +import {TextLinesMutator} from "./TextLinesMutator"; +import {ChangeSet} from "./types/ChangeSet"; +import {AText} from "./types/AText"; +import {ChangeSetBuilder} from "./types/ChangeSetBuilder"; +import {Builder} from "./Builder"; +import {StringIterator} from "./StringIterator"; +import {MergingOpAssembler} from "./MergingOpAssembler"; /** * A `[key, value]` pair of strings describing a text attribute. @@ -235,16 +243,16 @@ export const opsFromText = function* (opcode: "" | "=" | "+" | "-" | undefined, * @returns {string} the checked Changeset */ export const checkRep = (cs: string) => { - const unpacked = exports.unpack(cs); + const unpacked = unpack(cs); const oldLen = unpacked.oldLen; const newLen = unpacked.newLen; const ops = unpacked.ops; let charBank = unpacked.charBank; - const assem = exports.smartOpAssembler(); + const assem = new SmartOpAssembler(); let oldPos = 0; let calcNewLen = 0; - for (const o of exports.deserializeOps(ops)) { + for (const o of deserializeOps(ops)) { switch (o.opcode) { case '=': oldPos += o.chars; @@ -277,7 +285,7 @@ export const checkRep = (cs: string) => { assert(calcNewLen === newLen, 'Invalid changeset: claimed length does not match actual length'); assert(charBank === '', 'Invalid changeset: excess characters in the charBank'); assem.endDocument(); - const normalized = exports.pack(oldLen, calcNewLen, assem.toString(), unpacked.charBank); + const normalized = pack(oldLen, calcNewLen, assem.toString(), unpacked.charBank); assert(normalized === cs, 'Invalid changeset: not in canonical form'); return cs; }; @@ -302,323 +310,6 @@ export const checkRep = (cs: string) => { * `Array.prototype.splice()`. */ -/** - * Class to iterate and modify texts which have several lines. It is used for applying Changesets on - * arrays of lines. - * - * Mutation operations have the same constraints as exports operations with respect to newlines, but - * not the other additional constraints (i.e. ins/del ordering, forbidden no-ops, non-mergeability, - * final newline). Can be used to mutate lists of strings where the last char of each string is not - * actually a newline, but for the purposes of N and L values, the caller should pretend it is, and - * for things to work right in that case, the input to the `insert` method should be a single line - * with no newlines. - */ -class TextLinesMutator { - /** - * @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place). - */ - constructor(lines) { - this._lines = lines; - /** - * this._curSplice holds values that will be passed as arguments to this._lines.splice() to - * insert, delete, or change lines: - * - this._curSplice[0] is an index into the this._lines array. - * - this._curSplice[1] is the number of lines that will be removed from the this._lines array - * starting at the index. - * - The other elements represent mutated (changed by ops) lines or new lines (added by ops) - * to insert at the index. - * - * @type {[number, number?, ...string[]?]} - */ - this._curSplice = [0, 0]; - this._inSplice = false; - // position in lines after curSplice is applied: - this._curLine = 0; - this._curCol = 0; - // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) && - // curLine >= curSplice[0] - // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then - // curCol == 0 - } - - /** - * Get a line from `lines` at given index. - * - * @param {number} idx - an index - * @returns {string} - */ - _linesGet(idx) { - if ('get' in this._lines) { - return this._lines.get(idx); - } else { - return this._lines[idx]; - } - } - - /** - * Return a slice from `lines`. - * - * @param {number} start - the start index - * @param {number} end - the end index - * @returns {string[]} - */ - _linesSlice(start, end) { - // can be unimplemented if removeLines's return value not needed - if (this._lines.slice) { - return this._lines.slice(start, end); - } else { - return []; - } - } - - /** - * Return the length of `lines`. - * - * @returns {number} - */ - _linesLength() { - if (typeof this._lines.length === 'number') { - return this._lines.length; - } else { - return this._lines.length(); - } - } - - /** - * Starts a new splice. - */ - _enterSplice() { - this._curSplice[0] = this._curLine; - this._curSplice[1] = 0; - // TODO(doc) when is this the case? - // check all enterSplice calls and changes to curCol - if (this._curCol > 0) this._putCurLineInSplice(); - this._inSplice = true; - } - - /** - * Changes the lines array according to the values in curSplice and resets curSplice. Called via - * close or TODO(doc). - */ - _leaveSplice() { - this._lines.splice(...this._curSplice); - this._curSplice.length = 2; - this._curSplice[0] = this._curSplice[1] = 0; - this._inSplice = false; - } - - /** - * Indicates if curLine is already in the splice. This is necessary because the last element in - * curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting). - * - * @returns {boolean} true if curLine is in splice - */ - _isCurLineInSplice() { - // The value of `this._curSplice[1]` does not matter when determining the return value because - // `this._curLine` refers to the line number *after* the splice is applied (so after those lines - // are deleted). - return this._curLine - this._curSplice[0] < this._curSplice.length - 2; - } - - /** - * Incorporates current line into the splice and marks its old position to be deleted. - * - * @returns {number} the index of the added line in curSplice - */ - _putCurLineInSplice() { - if (!this._isCurLineInSplice()) { - this._curSplice.push(this._linesGet(this._curSplice[0] + this._curSplice[1])); - this._curSplice[1]++; - } - // TODO should be the same as this._curSplice.length - 1 - return 2 + this._curLine - this._curSplice[0]; - } - - /** - * It will skip some newlines by putting them into the splice. - * - * @param {number} L - - * @param {boolean} includeInSplice - Indicates that attributes are present. - */ - skipLines(L, includeInSplice) { - if (!L) return; - if (includeInSplice) { - if (!this._inSplice) this._enterSplice(); - // TODO(doc) should this count the number of characters that are skipped to check? - for (let i = 0; i < L; i++) { - this._curCol = 0; - this._putCurLineInSplice(); - this._curLine++; - } - } else { - if (this._inSplice) { - if (L > 1) { - // TODO(doc) figure out why single lines are incorporated into splice instead of ignored - this._leaveSplice(); - } else { - this._putCurLineInSplice(); - } - } - this._curLine += L; - this._curCol = 0; - } - // tests case foo in remove(), which isn't otherwise covered in current impl - } - - /** - * Skip some characters. Can contain newlines. - * - * @param {number} N - number of characters to skip - * @param {number} L - number of newlines to skip - * @param {boolean} includeInSplice - indicates if attributes are present - */ - skip(N, L, includeInSplice) { - if (!N) return; - if (L) { - this.skipLines(L, includeInSplice); - } else { - if (includeInSplice && !this._inSplice) this._enterSplice(); - if (this._inSplice) { - // although the line is put into splice curLine is not increased, because - // only some chars are skipped, not the whole line - this._putCurLineInSplice(); - } - this._curCol += N; - } - } - - /** - * Remove whole lines from lines array. - * - * @param {number} L - number of lines to remove - * @returns {string} - */ - removeLines(L) { - if (!L) return ''; - if (!this._inSplice) this._enterSplice(); - - /** - * Gets a string of joined lines after the end of the splice. - * - * @param {number} k - number of lines - * @returns {string} joined lines - */ - const nextKLinesText = (k) => { - const m = this._curSplice[0] + this._curSplice[1]; - return this._linesSlice(m, m + k).join(''); - }; - - let removed = ''; - if (this._isCurLineInSplice()) { - if (this._curCol === 0) { - removed = this._curSplice[this._curSplice.length - 1]; - this._curSplice.length--; - removed += nextKLinesText(L - 1); - this._curSplice[1] += L - 1; - } else { - removed = nextKLinesText(L - 1); - this._curSplice[1] += L - 1; - const sline = this._curSplice.length - 1; - removed = this._curSplice[sline].substring(this._curCol) + removed; - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + - this._linesGet(this._curSplice[0] + this._curSplice[1]); - this._curSplice[1] += 1; - } - } else { - removed = nextKLinesText(L); - this._curSplice[1] += L; - } - return removed; - } - - /** - * Remove text from lines array. - * - * @param {number} N - characters to delete - * @param {number} L - lines to delete - * @returns {string} - */ - remove(N, L) { - if (!N) return ''; - if (L) return this.removeLines(L); - if (!this._inSplice) this._enterSplice(); - // although the line is put into splice, curLine is not increased, because - // only some chars are removed not the whole line - const sline = this._putCurLineInSplice(); - const removed = this._curSplice[sline].substring(this._curCol, this._curCol + N); - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + - this._curSplice[sline].substring(this._curCol + N); - return removed; - } - - /** - * Inserts text into lines array. - * - * @param {string} text - the text to insert - * @param {number} L - number of newlines in text - */ - insert(text, L) { - if (!text) return; - if (!this._inSplice) this._enterSplice(); - if (L) { - const newLines = exports.splitTextLines(text); - if (this._isCurLineInSplice()) { - const sline = this._curSplice.length - 1; - /** @type {string} */ - const theLine = this._curSplice[sline]; - const lineCol = this._curCol; - // Insert the chars up to `curCol` and the first new line. - this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; - this._curLine++; - newLines.splice(0, 1); - // insert the remaining new lines - this._curSplice.push(...newLines); - this._curLine += newLines.length; - // insert the remaining chars from the "old" line (e.g. the line we were in - // when we started to insert new lines) - this._curSplice.push(theLine.substring(lineCol)); - this._curCol = 0; // TODO(doc) why is this not set to the length of last line? - } else { - this._curSplice.push(...newLines); - this._curLine += newLines.length; - } - } else { - // There are no additional lines. Although the line is put into splice, curLine is not - // increased because there may be more chars in the line (newline is not reached). - const sline = this._putCurLineInSplice(); - if (!this._curSplice[sline]) { - const err = new Error( - 'curSplice[sline] not populated, actual curSplice contents is ' + - `${JSON.stringify(this._curSplice)}. Possibly related to ` + - 'https://github.com/ether/etherpad-lite/issues/2802'); - console.error(err.stack || err.toString()); - } - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + text + - this._curSplice[sline].substring(this._curCol); - this._curCol += text.length; - } - } - - /** - * Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`. - * - * @returns {boolean} indicates if there are lines left - */ - hasMore() { - let docLines = this._linesLength(); - if (this._inSplice) { - docLines += this._curSplice.length - 2 - this._curSplice[1]; - } - return this._curLine < docLines; - } - - /** - * Closes the splice - */ - close() { - if (this._inSplice) this._leaveSplice(); - } -} /** * Apply operations to other operations. @@ -641,12 +332,12 @@ class TextLinesMutator { * either `opOut` must be nullish or `opOut.opcode` must be the empty string. * @returns {string} the integrated changeset */ -const applyZip = (in1, in2, func) => { - const ops1 = exports.deserializeOps(in1); - const ops2 = exports.deserializeOps(in2); +const applyZip = (in1: string, in2: string, func: Function): string => { + const ops1 = deserializeOps(in1); + const ops2 = deserializeOps(in2); let next1 = ops1.next(); let next2 = ops2.next(); - const assem = exports.smartOpAssembler(); + const assem = new SmartOpAssembler(); while (!next1.done || !next2.done) { if (!next1.done && !next1.value.opcode) next1 = ops1.next(); if (!next2.done && !next2.value.opcode) next2 = ops2.next(); @@ -666,15 +357,15 @@ const applyZip = (in1, in2, func) => { * @param {string} cs - The encoded changeset. * @returns {Changeset} */ -exports.unpack = (cs) => { +export const unpack = (cs: string): ChangeSet => { const headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/; const headerMatch = headerRegex.exec(cs); if ((!headerMatch) || (!headerMatch[0])) error(`Not a changeset: ${cs}`); - const oldLen = parseNum(headerMatch[1]); - const changeSign = (headerMatch[2] === '>') ? 1 : -1; - const changeMag = parseNum(headerMatch[3]); + const oldLen = parseNum(headerMatch![1]); + const changeSign = (headerMatch![2] === '>') ? 1 : -1; + const changeMag = parseNum(headerMatch![3]); const newLen = oldLen + changeSign * changeMag; - const opsStart = headerMatch[0].length; + const opsStart = headerMatch![0].length; let opsEnd = cs.indexOf('$'); if (opsEnd < 0) opsEnd = cs.length; return { @@ -694,12 +385,12 @@ exports.unpack = (cs) => { * @param {string} bank - Characters for insert operations. * @returns {string} The encoded changeset. */ -exports.pack = (oldLen, newLen, opsStr, bank) => { +export const pack = (oldLen: number, newLen: number, opsStr: string, bank: string): string => { const lenDiff = newLen - oldLen; - const lenDiffStr = (lenDiff >= 0 ? `>${exports.numToString(lenDiff)}` - : `<${exports.numToString(-lenDiff)}`); + const lenDiffStr = (lenDiff >= 0 ? `>${numToString(lenDiff)}` + : `<${numToString(-lenDiff)}`); const a = []; - a.push('Z:', exports.numToString(oldLen), lenDiffStr, opsStr, '$', bank); + a.push('Z:', numToString(oldLen), lenDiffStr, opsStr, '$', bank); return a.join(''); }; @@ -710,13 +401,13 @@ exports.pack = (oldLen, newLen, opsStr, bank) => { * @param {string} str - String to which a Changeset should be applied * @returns {string} */ -exports.applyToText = (cs, str) => { - const unpacked = exports.unpack(cs); +export const applyToText = (cs: string, str: string): string => { + const unpacked = unpack(cs); assert(str.length === unpacked.oldLen, `mismatched apply: ${str.length} / ${unpacked.oldLen}`); - const bankIter = exports.stringIterator(unpacked.charBank); - const strIter = exports.stringIterator(str); + const bankIter = new StringIterator(unpacked.charBank); + const strIter = new StringIterator(str); const assem = new StringAssembler(); - for (const op of exports.deserializeOps(unpacked.ops)) { + for (const op of deserializeOps(unpacked.ops)) { switch (op.opcode) { case '+': // op is + and op.lines 0: no newlines must be in op.chars @@ -754,11 +445,11 @@ exports.applyToText = (cs, str) => { * @param {string} cs - the changeset to apply * @param {string[]} lines - The lines to which the changeset needs to be applied */ -exports.mutateTextLines = (cs, lines) => { - const unpacked = exports.unpack(cs); - const bankIter = exports.stringIterator(unpacked.charBank); +const mutateTextLines = (cs: string, lines:string[]) => { + const unpacked = unpack(cs); + const bankIter = new StringIterator(unpacked.charBank); const mut = new TextLinesMutator(lines); - for (const op of exports.deserializeOps(unpacked.ops)) { + for (const op of deserializeOps(unpacked.ops)) { switch (op.opcode) { case '+': mut.insert(bankIter.take(op.chars), op.lines); @@ -783,7 +474,7 @@ exports.mutateTextLines = (cs, lines) => { * @param {AttributePool.ts} pool - attribute pool * @returns {string} */ -exports.composeAttributes = (att1, att2, resultIsMutation, pool) => { +export const composeAttributes = (att1: string, att2: string, resultIsMutation: boolean, pool?: AttributePool|null): string => { // att1 and att2 are strings like "*3*f*1c", asMutation is a boolean. // Sometimes attribute (key,value) pairs are treated as attribute presence // information, while other times they are treated as operations that @@ -817,7 +508,7 @@ exports.composeAttributes = (att1, att2, resultIsMutation, pool) => { * @param {AttributePool.ts} pool - Can be null if definitely not needed. * @returns {Op} The result of applying `csOp` to `attOp`. */ -const slicerZipperFunc = (attOp, csOp, pool) => { +export const slicerZipperFunc = (attOp: Op, csOp: Op, pool: AttributePool|null):Op => { const opOut = new Op(); if (!attOp.opcode) { copyOp(csOp, opOut); @@ -852,7 +543,7 @@ const slicerZipperFunc = (attOp, csOp, pool) => { '-': '-', '=': '=', }, - }[attOp.opcode][csOp.opcode]; + }[attOp.opcode][csOp.opcode] as OpCode; const [fullyConsumedOp, partiallyConsumedOp] = [attOp, csOp].sort((a, b) => a.chars - b.chars); opOut.chars = fullyConsumedOp.chars; opOut.lines = fullyConsumedOp.lines; @@ -861,7 +552,7 @@ const slicerZipperFunc = (attOp, csOp, pool) => { // normally be the empty string. However, padDiff.js adds attributes to remove ops and needs // them preserved so they are copied here. ? csOp.attribs - : exports.composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode === '=', pool); + : composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode === '=', pool); partiallyConsumedOp.chars -= fullyConsumedOp.chars; partiallyConsumedOp.lines -= fullyConsumedOp.lines; if (!partiallyConsumedOp.chars) partiallyConsumedOp.opcode = ''; @@ -878,120 +569,9 @@ const slicerZipperFunc = (attOp, csOp, pool) => { * @param {AttributePool.ts} pool - the attibutes pool * @returns {string} */ -exports.applyToAttribution = (cs, astr, pool) => { - const unpacked = exports.unpack(cs); - return applyZip(astr, unpacked.ops, (op1, op2) => slicerZipperFunc(op1, op2, pool)); -}; - -/** - * Applies a changeset to an array of attribute lines. - * - * @param {string} cs - The encoded changeset. - * @param {Array} lines - Attribute lines. Modified in place. - * @param {AttributePool.ts} pool - Attribute pool. - */ -exports.mutateAttributionLines = (cs, lines, pool) => { - const unpacked = exports.unpack(cs); - const csOps = exports.deserializeOps(unpacked.ops); - let csOpsNext = csOps.next(); - const csBank = unpacked.charBank; - let csBankIndex = 0; - // treat the attribution lines as text lines, mutating a line at a time - const mut = new TextLinesMutator(lines); - - /** - * The Ops in the current line from `lines`. - * - * @type {?Generator} - */ - let lineOps = null; - let lineOpsNext = null; - - const lineOpsHasNext = () => lineOpsNext && !lineOpsNext.done; - /** - * Returns false if we are on the last attribute line in `lines` and there is no additional op in - * that line. - * - * @returns {boolean} True if there are more ops to go through. - */ - const isNextMutOp = () => lineOpsHasNext() || mut.hasMore(); - - /** - * @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to - * iterate over the next line, which is consumed from `mut`. If there are no more lines, - * returns a null Op. - */ - const nextMutOp = () => { - if (!lineOpsHasNext() && mut.hasMore()) { - // There are more attribute lines in `lines` to do AND either we just started so `lineIter` is - // still null or there are no more ops in current `lineIter`. - const line = mut.removeLines(1); - lineOps = exports.deserializeOps(line); - lineOpsNext = lineOps.next(); - } - if (!lineOpsHasNext()) return new Op(); // No more ops and no more lines. - const op = lineOpsNext.value; - lineOpsNext = lineOps.next(); - return op; - }; - let lineAssem = null; - - /** - * Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the - * `lines` mutator. - */ - const outputMutOp = (op) => { - if (!lineAssem) { - lineAssem = exports.mergingOpAssembler(); - } - lineAssem.append(op); - if (op.lines <= 0) return; - assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`); - // ship it to the mut - mut.insert(lineAssem.toString(), 1); - lineAssem = null; - }; - - let csOp = new Op(); - let attOp = new Op(); - while (csOp.opcode || !csOpsNext.done || attOp.opcode || isNextMutOp()) { - if (!csOp.opcode && !csOpsNext.done) { - // coOp done, but more ops in cs. - csOp = csOpsNext.value; - csOpsNext = csOps.next(); - } - if (!csOp.opcode && !attOp.opcode && !lineAssem && !lineOpsHasNext()) { - break; // done - } else if (csOp.opcode === '=' && csOp.lines > 0 && !csOp.attribs && !attOp.opcode && - !lineAssem && !lineOpsHasNext()) { - // Skip multiple lines without attributes; this is what makes small changes not order of the - // document size. - mut.skipLines(csOp.lines); - csOp.opcode = ''; - } else if (csOp.opcode === '+') { - const opOut = copyOp(csOp); - if (csOp.lines > 1) { - // Copy the first line from `csOp` to `opOut`. - const firstLineLen = csBank.indexOf('\n', csBankIndex) + 1 - csBankIndex; - csOp.chars -= firstLineLen; - csOp.lines--; - opOut.lines = 1; - opOut.chars = firstLineLen; - } else { - // Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`. - csOp.opcode = ''; - } - outputMutOp(opOut); - csBankIndex += opOut.chars; - } else { - if (!attOp.opcode && isNextMutOp()) attOp = nextMutOp(); - const opOut = slicerZipperFunc(attOp, csOp, pool); - if (opOut.opcode) outputMutOp(opOut); - } - } - - assert(!lineAssem, `line assembler not finished:${cs}`); - mut.close(); +export const applyToAttribution = (cs: string, astr: string, pool: AttributePool): string => { + const unpacked = unpack(cs); + return applyZip(astr, unpacked.ops, (op1: Op, op2:Op) => slicerZipperFunc(op1, op2, pool)); }; /** @@ -1000,20 +580,20 @@ exports.mutateAttributionLines = (cs, lines, pool) => { * @param {string[]} theAlines - collection of Attribution lines * @returns {string} joined Attribution lines */ -exports.joinAttributionLines = (theAlines) => { - const assem = exports.mergingOpAssembler(); +export const joinAttributionLines = (theAlines: string[]): string => { + const assem = new MergingOpAssembler(); for (const aline of theAlines) { - for (const op of exports.deserializeOps(aline)) assem.append(op); + for (const op of deserializeOps(aline)) assem.append(op); } return assem.toString(); }; -exports.splitAttributionLines = (attrOps, text) => { - const assem = exports.mergingOpAssembler(); - const lines = []; +export const splitAttributionLines = (attrOps: string, text: string) => { + const assem = new MergingOpAssembler(); + const lines: string[] = []; let pos = 0; - const appendOp = (op) => { + const appendOp = (op:Op) => { assem.append(op); if (op.lines > 0) { lines.push(assem.toString()); @@ -1022,7 +602,7 @@ exports.splitAttributionLines = (attrOps, text) => { pos += op.chars; }; - for (const op of exports.deserializeOps(attrOps)) { + for (const op of deserializeOps(attrOps)) { let numChars = op.chars; let numLines = op.lines; while (numLines > 1) { @@ -1050,7 +630,7 @@ exports.splitAttributionLines = (attrOps, text) => { * @param {string} text - text to split * @returns {string[]} */ -exports.splitTextLines = (text) => text.match(/[^\n]*(?:\n|[^\n]$)/g); +export const splitTextLines = (text:string) => text.match(/[^\n]*(?:\n|[^\n]$)/g); /** * Compose two Changesets. @@ -1060,18 +640,18 @@ exports.splitTextLines = (text) => text.match(/[^\n]*(?:\n|[^\n]$)/g); * @param {AttributePool.ts} pool - Attribs pool * @returns {string} */ -exports.compose = (cs1, cs2, pool) => { - const unpacked1 = exports.unpack(cs1); - const unpacked2 = exports.unpack(cs2); +export const compose = (cs1: string, cs2:string, pool: AttributePool): string => { + const unpacked1 = unpack(cs1); + const unpacked2 = unpack(cs2); const len1 = unpacked1.oldLen; const len2 = unpacked1.newLen; assert(len2 === unpacked2.oldLen, 'mismatched composition of two changesets'); const len3 = unpacked2.newLen; - const bankIter1 = exports.stringIterator(unpacked1.charBank); - const bankIter2 = exports.stringIterator(unpacked2.charBank); + const bankIter1 = new StringIterator(unpacked1.charBank); + const bankIter2 = new StringIterator(unpacked2.charBank); const bankAssem = new StringAssembler(); - const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { + const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1: Op, op2: Op) => { const op1code = op1.opcode; const op2code = op2.opcode; if (op1code === '+' && op2code === '-') { @@ -1088,7 +668,7 @@ exports.compose = (cs1, cs2, pool) => { return opOut; }); - return exports.pack(len1, len3, newOps, bankAssem.toString()); + return pack(len1, len3, newOps, bankAssem.toString()); }; /** @@ -1099,13 +679,13 @@ exports.compose = (cs1, cs2, pool) => { * @param {AttributePool.ts} pool - Attribute pool * @returns {Function} */ -exports.attributeTester = (attribPair, pool) => { - const never = (attribs) => false; +export const attributeTester = (attribPair: Attribute, pool: AttributePool): Function => { + const never = (attribs: Attribute[]) => false; if (!pool) return never; const attribNum = pool.putAttrib(attribPair, true); if (attribNum < 0) return never; - const re = new RegExp(`\\*${exports.numToString(attribNum)}(?!\\w)`); - return (attribs) => re.test(attribs); + const re = new RegExp(`\\*${numToString(attribNum)}(?!\\w)`); + return (attribs: string) => re.test(attribs); }; /** @@ -1114,7 +694,7 @@ exports.attributeTester = (attribPair, pool) => { * @param {number} N - length of the identity changeset * @returns {string} */ -exports.identity = (N) => exports.pack(N, N, '', ''); +export const identity = (N: number): string => pack(N, N, '', ''); /** * Creates a Changeset which works on oldFullText and removes text from spliceStart to @@ -1129,21 +709,21 @@ exports.identity = (N) => exports.pack(N, N, '', ''); * @param {AttributePool.ts} [pool] - Attribute pool. * @returns {string} */ -exports.makeSplice = (orig, start, ndel, ins, attribs, pool) => { +export const makeSplice = (orig: string, start: number, ndel: number, ins: string, attribs: string | Attribute[] | undefined, pool: AttributePool | null | undefined): string => { if (start < 0) throw new RangeError(`start index must be non-negative (is ${start})`); if (ndel < 0) throw new RangeError(`characters to delete must be non-negative (is ${ndel})`); if (start > orig.length) start = orig.length; if (ndel > orig.length - start) ndel = orig.length - start; const deleted = orig.substring(start, start + ndel); - const assem = exports.smartOpAssembler(); + const assem = new SmartOpAssembler(); const ops = (function* () { yield* opsFromText('=', orig.substring(0, start)); yield* opsFromText('-', deleted); - yield* opsFromText('+', ins, attribs, pool); + yield* opsFromText('+', ins as string, attribs, pool); })(); for (const op of ops) assem.append(op); assem.endDocument(); - return exports.pack(orig.length, orig.length + ins.length - ndel, assem.toString(), ins); + return pack(orig.length, orig.length + ins.length - ndel, assem.toString(), ins); }; /** @@ -1153,15 +733,15 @@ exports.makeSplice = (orig, start, ndel, ins, attribs, pool) => { * @param {string} cs - Changeset * @returns {[number, number, string][]} */ -const toSplices = (cs) => { - const unpacked = exports.unpack(cs); +const toSplices = (cs: string): [number, number, string][] => { + const unpacked = unpack(cs); /** @type {[number, number, string][]} */ - const splices = []; + const splices: [number, number, string][] = []; let oldPos = 0; - const charIter = exports.stringIterator(unpacked.charBank); + const charIter = new StringIterator(unpacked.charBank); let inSplice = false; - for (const op of exports.deserializeOps(unpacked.ops)) { + for (const op of deserializeOps(unpacked.ops)) { if (op.opcode === '=') { oldPos += op.chars; inSplice = false; @@ -1189,7 +769,7 @@ const toSplices = (cs) => { * @param {number} insertionsAfter - * @returns {[number, number]} */ -exports.characterRangeFollow = (cs, startChar, endChar, insertionsAfter) => { +export const characterRangeFollow = (cs: string, startChar: number, endChar: number, insertionsAfter: number):[number, number] => { let newStartChar = startChar; let newEndChar = endChar; let lengthChangeSoFar = 0; @@ -1239,7 +819,7 @@ exports.characterRangeFollow = (cs, startChar, endChar, insertionsAfter) => { * @param {AttributePool} newPool - new attributes pool * @returns {string} the new Changeset */ -exports.moveOpsToNewPool = (cs, oldPool, newPool) => { +export const moveOpsToNewPool = (cs: string, oldPool: AttributePool, newPool: AttributePool): string => { // works on exports or attribution string let dollarPos = cs.indexOf('$'); if (dollarPos < 0) { @@ -1265,8 +845,8 @@ exports.moveOpsToNewPool = (cs, oldPool, newPool) => { * @param {string} text - text to insert * @returns {string} */ -exports.makeAttribution = (text) => { - const assem = exports.smartOpAssembler(); +export const makeAttribution = (text: string) => { + const assem = new SmartOpAssembler(); for (const op of opsFromText('+', text)) assem.append(op); return assem.toString(); }; @@ -1279,7 +859,7 @@ exports.makeAttribution = (text) => { * @param {string} cs - changeset * @param {Function} func - function to call */ -exports.eachAttribNumber = (cs, func) => { +export const eachAttribNumber = (cs: string, func: Function) => { padutils.warnDeprecated( 'Changeset.eachAttribNumber() is deprecated; use attributes.decodeAttribString() instead'); let dollarPos = cs.indexOf('$'); @@ -1305,16 +885,16 @@ exports.eachAttribNumber = (cs, func) => { * Changeset * @returns {string} */ -exports.filterAttribNumbers = (cs, filter) => exports.mapAttribNumbers(cs, filter); +export const filterAttribNumbers = (cs: string, filter: Function) => mapAttribNumbers(cs, filter); /** - * Does exactly the same as exports.filterAttribNumbers. + * Does exactly the same as filterAttribNumbers. * * @param {string} cs - * @param {Function} func - * @returns {string} */ -exports.mapAttribNumbers = (cs, func) => { +export const mapAttribNumbers = (cs: string, func: Function): string => { let dollarPos = cs.indexOf('$'); if (dollarPos < 0) { dollarPos = cs.length; @@ -1352,9 +932,9 @@ exports.mapAttribNumbers = (cs, func) => { * attributes * @returns {AText} */ -exports.makeAText = (text, attribs) => ({ +export const makeAText = (text: string, attribs: string): AText => ({ text, - attribs: (attribs || exports.makeAttribution(text)), + attribs: (attribs || makeAttribution(text)), }); /** @@ -1365,9 +945,9 @@ exports.makeAText = (text, attribs) => ({ * @param {AttributePool.ts} pool - Attribute Pool to add to * @returns {AText} */ -exports.applyToAText = (cs, atext, pool) => ({ - text: exports.applyToText(cs, atext.text), - attribs: exports.applyToAttribution(cs, atext.attribs, pool), +export const applyToAText = (cs: string, atext: AText, pool: AttributePool): AText => ({ + text: applyToText(cs, atext.text), + attribs: applyToAttribution(cs, atext.attribs, pool), }); /** @@ -1376,7 +956,7 @@ exports.applyToAText = (cs, atext, pool) => ({ * @param {AText} atext - * @returns {AText} */ -exports.cloneAText = (atext) => { +export const cloneAText = (atext: AText): AText => { if (!atext) error('atext is null'); return { text: atext.text, @@ -1390,7 +970,7 @@ exports.cloneAText = (atext) => { * @param {AText} atext1 - * @param {AText} atext2 - */ -exports.copyAText = (atext1, atext2) => { +export const copyAText = (atext1: AText, atext2: AText) => { atext2.text = atext1.text; atext2.attribs = atext1.attribs; }; @@ -1402,10 +982,10 @@ exports.copyAText = (atext1, atext2) => { * @yields {Op} * @returns {Generator} */ -exports.opsFromAText = function* (atext) { +export const opsFromAText = function* (atext: AText): Generator { // intentionally skips last newline char of atext let lastOp = null; - for (const op of exports.deserializeOps(atext.attribs)) { + for (const op of deserializeOps(atext.attribs)) { if (lastOp != null) yield lastOp; lastOp = op; } @@ -1433,12 +1013,17 @@ exports.opsFromAText = function* (atext) { * @param {AText} atext - * @param assem - Assembler like SmartOpAssembler TODO add desc */ -exports.appendATextToAssembler = (atext, assem) => { +export const appendATextToAssembler = (atext: AText, assem: SmartOpAssembler) => { padutils.warnDeprecated( 'Changeset.appendATextToAssembler() is deprecated; use Changeset.opsFromAText() instead'); - for (const op of exports.opsFromAText(atext)) assem.append(op); + for (const op of opsFromAText(atext)) assem.append(op); }; +type WirePrep = { + translated: string, + pool: AttributePool +} + /** * Creates a clone of a Changeset and it's APool. * @@ -1446,9 +1031,9 @@ exports.appendATextToAssembler = (atext, assem) => { * @param {AttributePool.ts} pool - * @returns {{translated: string, pool: AttributePool.ts}} */ -exports.prepareForWire = (cs, pool) => { +export const prepareForWire = (cs: string, pool: AttributePool): WirePrep => { const newPool = new AttributePool(); - const newCs = exports.moveOpsToNewPool(cs, pool, newPool); + const newCs = moveOpsToNewPool(cs, pool, newPool); return { translated: newCs, pool: newPool, @@ -1461,17 +1046,17 @@ exports.prepareForWire = (cs, pool) => { * @param {string} cs - * @returns {boolean} */ -exports.isIdentity = (cs) => { - const unpacked = exports.unpack(cs); +export const isIdentity = (cs: string): boolean => { + const unpacked = unpack(cs); return unpacked.ops === '' && unpacked.oldLen === unpacked.newLen; }; /** * @deprecated Use an AttributeMap instead. */ -const attribsAttributeValue = (attribs, key, pool) => { +const _attribsAttributeValue = (attribs: string, key: string, pool: AttributePool) => { if (!attribs) return ''; - for (const [k, v] of attributes.attribsFromString(attribs, pool)) { + for (const [k, v] of attribsFromString(attribs, pool)) { if (k === key) return v; } return ''; @@ -1486,10 +1071,10 @@ const attribsAttributeValue = (attribs, key, pool) => { * @param {AttributePool.ts} pool - attribute pool * @returns {string} */ -exports.opAttributeValue = (op, key, pool) => { +export const opAttributeValue = (op: Op, key: string, pool: AttributePool):string => { padutils.warnDeprecated( 'Changeset.opAttributeValue() is deprecated; use an AttributeMap instead'); - return attribsAttributeValue(op.attribs, key, pool); + return _attribsAttributeValue(op.attribs, key, pool); }; /** @@ -1501,104 +1086,13 @@ exports.opAttributeValue = (op, key, pool) => { * @param {AttributePool.ts} pool - attribute pool * @returns {string} */ -exports.attribsAttributeValue = (attribs, key, pool) => { +export const attribsAttributeValue = (attribs: string, key: string, pool: AttributePool) => { padutils.warnDeprecated( 'Changeset.attribsAttributeValue() is deprecated; use an AttributeMap instead'); - return attribsAttributeValue(attribs, key, pool); + return _attribsAttributeValue(attribs, key, pool); }; -/** - * Incrementally builds a Changeset. - * - * @typedef {object} Builder - * @property {Function} insert - - * @property {Function} keep - - * @property {Function} keepText - - * @property {Function} remove - - * @property {Function} toString - - */ -/** - * @param {number} oldLen - Old length - * @returns {Builder} - */ -exports.builder = (oldLen) => { - const assem = exports.smartOpAssembler(); - const o = new Op(); - const charBank = new StringAssembler(); - - const self = { - /** - * @param {number} N - Number of characters to keep. - * @param {number} L - Number of newlines among the `N` characters. If positive, the last - * character must be a newline. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - keep: (N, L, attribs, pool) => { - o.opcode = '='; - o.attribs = typeof attribs === 'string' - ? attribs : new AttributeMap(pool).update(attribs || []).toString(); - o.chars = N; - o.lines = (L || 0); - assem.append(o); - return self; - }, - - /** - * @param {string} text - Text to keep. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - keepText: (text, attribs, pool) => { - for (const op of opsFromText('=', text, attribs, pool)) assem.append(op); - return self; - }, - - /** - * @param {string} text - Text to insert. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - insert: (text, attribs, pool) => { - for (const op of opsFromText('+', text, attribs, pool)) assem.append(op); - charBank.append(text); - return self; - }, - - /** - * @param {number} N - Number of characters to remove. - * @param {number} L - Number of newlines among the `N` characters. If positive, the last - * character must be a newline. - * @returns {Builder} this - */ - remove: (N, L) => { - o.opcode = '-'; - o.attribs = ''; - o.chars = N; - o.lines = (L || 0); - assem.append(o); - return self; - }, - - toString: () => { - assem.endDocument(); - const newLen = oldLen + assem.getLengthChange(); - return exports.pack(oldLen, newLen, assem.toString(), charBank.toString()); - }, - }; - - return self; -}; /** * Constructs an attribute string from a sequence of attributes. @@ -1613,7 +1107,7 @@ exports.builder = (oldLen) => { * ignored if `attribs` is an attribute string. * @returns {AttributeString} */ -exports.makeAttribsString = (opcode, attribs, pool) => { +export const makeAttribsString = (opcode: string, attribs: Iterable<[string, string]>|string, pool: AttributePool | null | undefined): string => { padutils.warnDeprecated( 'Changeset.makeAttribsString() is deprecated; ' + 'use AttributeMap.prototype.toString() or attributes.attribsToString() instead'); @@ -1625,10 +1119,10 @@ exports.makeAttribsString = (opcode, attribs, pool) => { /** * Like "substring" but on a single-line attribution string. */ -exports.subattribution = (astr, start, optEnd) => { - const attOps = exports.deserializeOps(astr); +export const subattribution = (astr: string, start: number, optEnd: number) => { + const attOps = deserializeOps(astr); let attOpsNext = attOps.next(); - const assem = exports.smartOpAssembler(); + const assem = new SmartOpAssembler(); let attOp = new Op(); const csOp = new Op(); @@ -1636,7 +1130,7 @@ exports.subattribution = (astr, start, optEnd) => { if (!csOp.chars) return; while (csOp.opcode && (attOp.opcode || !attOpsNext.done)) { if (!attOp.opcode) { - attOp = attOpsNext.value; + attOp = attOpsNext.value as Op; attOpsNext = attOps.next(); } if (csOp.opcode && attOp.opcode && csOp.chars >= attOp.chars && @@ -1670,13 +1164,18 @@ exports.subattribution = (astr, start, optEnd) => { return assem.toString(); }; -exports.inverse = (cs, lines, alines, pool) => { +export const inverse = (cs: string, lines: string|{ + get: (idx: number) => string, +}, alines: string|{ + get: (idx: number) => string, +}, pool: AttributePool) => { // lines and alines are what the exports is meant to apply to. // They may be arrays or objects with .get(i) and .length methods. // They include final newlines on lines. - const linesGet = (idx) => { - if (lines.get) { + const linesGet = (idx: number) => { + // @ts-ignore + if ("get" in lines) { return lines.get(idx); } else { return lines[idx]; @@ -1687,8 +1186,9 @@ exports.inverse = (cs, lines, alines, pool) => { * @param {number} idx - * @returns {string} */ - const alinesGet = (idx) => { - if (alines.get) { + const alinesGet = (idx: number): string => { + // @ts-ignore + if ("get" in alines) { return alines.get(idx); } else { return alines[idx]; @@ -1697,17 +1197,17 @@ exports.inverse = (cs, lines, alines, pool) => { let curLine = 0; let curChar = 0; - let curLineOps = null; - let curLineOpsNext = null; - let curLineOpsLine; + let curLineOps: null|Generator = null; + let curLineOpsNext:IteratorResult|null = null; + let curLineOpsLine: number; let curLineNextOp = new Op('+'); - const unpacked = exports.unpack(cs); - const builder = exports.builder(unpacked.newLen); + const unpacked = unpack(cs); + const builder = new Builder(unpacked.newLen); - const consumeAttribRuns = (numChars, func /* (len, attribs, endsLine)*/) => { + const consumeAttribRuns = (numChars: number, func: Function /* (len, attribs, endsLine)*/) => { if (!curLineOps || curLineOpsLine !== curLine) { - curLineOps = exports.deserializeOps(alinesGet(curLine)); + curLineOps = deserializeOps(alinesGet(curLine)); curLineOpsNext = curLineOps.next(); curLineOpsLine = curLine; let indexIntoLine = 0; @@ -1723,19 +1223,19 @@ exports.inverse = (cs, lines, alines, pool) => { } while (numChars > 0) { - if (!curLineNextOp.chars && curLineOpsNext.done) { + if (!curLineNextOp.chars && curLineOpsNext!.done) { curLine++; curChar = 0; curLineOpsLine = curLine; curLineNextOp.chars = 0; - curLineOps = exports.deserializeOps(alinesGet(curLine)); - curLineOpsNext = curLineOps.next(); + curLineOps = deserializeOps(alinesGet(curLine)); + curLineOpsNext = curLineOps!.next(); } if (!curLineNextOp.chars) { - if (curLineOpsNext.done) { + if (curLineOpsNext!.done) { curLineNextOp = new Op(); } else { - curLineNextOp = curLineOpsNext.value; + curLineNextOp = curLineOpsNext!.value; curLineOpsNext = curLineOps.next(); } } @@ -1747,13 +1247,13 @@ exports.inverse = (cs, lines, alines, pool) => { curChar += charsToUse; } - if (!curLineNextOp.chars && curLineOpsNext.done) { + if (!curLineNextOp.chars && curLineOpsNext!.done) { curLine++; curChar = 0; } }; - const skip = (N, L) => { + const skip = (N: number, L: number) => { if (L) { curLine += L; curChar = 0; @@ -1764,7 +1264,7 @@ exports.inverse = (cs, lines, alines, pool) => { } }; - const nextText = (numChars) => { + const nextText = (numChars: number) => { let len = 0; const assem = new StringAssembler(); const firstString = linesGet(curLine).substring(curChar); @@ -1782,9 +1282,11 @@ exports.inverse = (cs, lines, alines, pool) => { return assem.toString().substring(0, numChars); }; - const cachedStrFunc = (func) => { - const cache = {}; - return (s) => { + const cachedStrFunc = (func: Function) => { + const cache:{ + [key: string]: string + } = {}; + return (s: string | number) => { if (!cache[s]) { cache[s] = func(s); } @@ -1792,11 +1294,11 @@ exports.inverse = (cs, lines, alines, pool) => { }; }; - for (const csOp of exports.deserializeOps(unpacked.ops)) { + for (const csOp of deserializeOps(unpacked.ops)) { if (csOp.opcode === '=') { if (csOp.attribs) { const attribs = AttributeMap.fromString(csOp.attribs, pool); - const undoBackToAttribs = cachedStrFunc((oldAttribsStr) => { + const undoBackToAttribs = cachedStrFunc((oldAttribsStr: string) => { const oldAttribs = AttributeMap.fromString(oldAttribsStr, pool); const backAttribs = new AttributeMap(pool); for (const [key, value] of attribs) { @@ -1807,7 +1309,7 @@ exports.inverse = (cs, lines, alines, pool) => { // are in oldAttribs but not in attribs). I don't know if that is intentional. return backAttribs.toString(); }); - consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { + consumeAttribRuns(csOp.chars, (len: number, attribs: string, endsLine: number) => { builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs)); }); } else { @@ -1819,33 +1321,33 @@ exports.inverse = (cs, lines, alines, pool) => { } else if (csOp.opcode === '-') { const textBank = nextText(csOp.chars); let textBankIndex = 0; - consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { + consumeAttribRuns(csOp.chars, (len: number, attribs: string) => { builder.insert(textBank.substr(textBankIndex, len), attribs); textBankIndex += len; }); } } - return exports.checkRep(builder.toString()); + return checkRep(builder.toString()); }; // %CLIENT FILE ENDS HERE% -exports.follow = (cs1, cs2, reverseInsertOrder, pool) => { - const unpacked1 = exports.unpack(cs1); - const unpacked2 = exports.unpack(cs2); +export const follow = (cs1: string, cs2:string, reverseInsertOrder: boolean, pool: AttributePool) => { + const unpacked1 = unpack(cs1); + const unpacked2 = unpack(cs2); const len1 = unpacked1.oldLen; const len2 = unpacked2.oldLen; assert(len1 === len2, 'mismatched follow - cannot transform cs1 on top of cs2'); - const chars1 = exports.stringIterator(unpacked1.charBank); - const chars2 = exports.stringIterator(unpacked2.charBank); + const chars1 = new StringIterator(unpacked1.charBank); + const chars2 = new StringIterator(unpacked2.charBank); const oldLen = unpacked1.newLen; let oldPos = 0; let newLen = 0; - const hasInsertFirst = exports.attributeTester(['insertorder', 'first'], pool); + const hasInsertFirst = attributeTester(['insertorder', 'first'], pool); - const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { + const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1: Op, op2: Op) => { const opOut = new Op(); if (op1.opcode === '+' || op2.opcode === '+') { let whichToDo; @@ -1968,10 +1470,10 @@ exports.follow = (cs1, cs2, reverseInsertOrder, pool) => { }); newLen += oldLen - oldPos; - return exports.pack(oldLen, newLen, newOps, unpacked2.charBank); + return pack(oldLen, newLen, newOps, unpacked2.charBank); }; -const followAttributes = (att1, att2, pool) => { +const followAttributes = (att1: string, att2: string, pool: AttributePool) => { // The merge of two sets of attribute changes to the same text // takes the lexically-earlier value if there are two values // for the same key. Otherwise, all key/value changes from @@ -2000,7 +1502,7 @@ const followAttributes = (att1, att2, pool) => { return buf.toString(); }; -exports.exportedForTestingOnly = { +export const exportedForTestingOnly = { TextLinesMutator, followAttributes, toSplices, diff --git a/src/static/js/Op.ts b/src/static/js/Op.ts index 73233027e..fa33da560 100644 --- a/src/static/js/Op.ts +++ b/src/static/js/Op.ts @@ -1,3 +1,6 @@ +export type OpCode = ''|'='|'+'|'-'; + + /** * An operation to apply to a shared document. */ diff --git a/src/static/js/OpIter.ts b/src/static/js/OpIter.ts index 18a63a686..40b0abaf4 100644 --- a/src/static/js/OpIter.ts +++ b/src/static/js/OpIter.ts @@ -1,4 +1,5 @@ import Op from "./Op"; +import {clearOp, copyOp, deserializeOps} from "./Changeset"; /** * Iterator over a changeset's operations. @@ -9,19 +10,20 @@ import Op from "./Op"; */ export class OpIter { private gen + private _next: IteratorResult /** * @param {string} ops - String encoding the change operations to iterate over. */ constructor(ops: string) { - this.gen = exports.deserializeOps(ops); - this.next = this.gen.next(); + this.gen = deserializeOps(ops); + this._next = this.gen.next(); } /** * @returns {boolean} Whether there are any remaining operations. */ - hasNext() { - return !this.next.done; + hasNext(): boolean { + return !this._next.done; } /** @@ -33,10 +35,10 @@ export class OpIter { * @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are * no more operations. */ - next(opOut = new Op()) { + next(opOut: Op = new Op()): Op { if (this.hasNext()) { - copyOp(this._next.value, opOut); - this._next = this._gen.next(); + copyOp(this._next.value!, opOut); + this._next = this.gen.next(); } else { clearOp(opOut); } diff --git a/src/static/js/TextLinesMutator.ts b/src/static/js/TextLinesMutator.ts new file mode 100644 index 000000000..d0b822cff --- /dev/null +++ b/src/static/js/TextLinesMutator.ts @@ -0,0 +1,335 @@ + +/** + * Class to iterate and modify texts which have several lines. It is used for applying Changesets on + * arrays of lines. + * + * Mutation operations have the same constraints as exports operations with respect to newlines, but + * not the other additional constraints (i.e. ins/del ordering, forbidden no-ops, non-mergeability, + * final newline). Can be used to mutate lists of strings where the last char of each string is not + * actually a newline, but for the purposes of N and L values, the caller should pretend it is, and + * for things to work right in that case, the input to the `insert` method should be a single line + * with no newlines. + */ +export class TextLinesMutator { + private readonly lines: string[] + private readonly curSplice: [number, number] + private inSplice: boolean + private curLine: number + private curCol: number + /** + * @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place). + */ + constructor(lines: string[]) { + this.lines = lines; + /** + * this._curSplice holds values that will be passed as arguments to this._lines.splice() to + * insert, delete, or change lines: + * - this._curSplice[0] is an index into the this._lines array. + * - this._curSplice[1] is the number of lines that will be removed from the this._lines array + * starting at the index. + * - The other elements represent mutated (changed by ops) lines or new lines (added by ops) + * to insert at the index. + * + * @type {[number, number?, ...string[]?]} + */ + this.curSplice = [0, 0]; + this.inSplice = false; + // position in lines after curSplice is applied: + this.curLine = 0; + this.curCol = 0; + // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) && + // curLine >= curSplice[0] + // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then + // curCol == 0 + } + + /** + * Get a line from `lines` at given index. + * + * @param {number} idx - an index + * @returns {string} + */ + private linesGet(idx: number) { + if ('get' in this.lines) { + // @ts-ignore + return this.lines.get(idx) as string; + } else { + return this.lines[idx]; + } + } + + /** + * Return a slice from `lines`. + * + * @param {number} start - the start index + * @param {number} end - the end index + * @returns {string[]} + */ + private linesSlice(start: number, end: number): string[] { + // can be unimplemented if removeLines's return value not needed + if (this.lines.slice) { + return this.lines.slice(start, end); + } else { + return []; + } + } + + /** + * Return the length of `lines`. + * + * @returns {number} + */ + private linesLength() { + if (typeof this.lines.length === 'number') { + return this.lines.length; + } else { + // @ts-ignore + return this.lines.length(); + } + } + + /** + * Starts a new splice. + */ + enterSplice() { + this.curSplice[0] = this.curLine; + this.curSplice[1] = 0; + // TODO(doc) when is this the case? + // check all enterSplice calls and changes to curCol + if (this.curCol > 0) this.putCurLineInSplice(); + this.inSplice = true; + } + + /** + * Changes the lines array according to the values in curSplice and resets curSplice. Called via + * close or TODO(doc). + */ + private leaveSplice() { + this.lines.splice(...this.curSplice); + this.curSplice.length = 2; + this.curSplice[0] = this.curSplice[1] = 0; + this.inSplice = false; + } + + /** + * Indicates if curLine is already in the splice. This is necessary because the last element in + * curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting). + * + * @returns {boolean} true if curLine is in splice + */ + private isCurLineInSplice() { + // The value of `this._curSplice[1]` does not matter when determining the return value because + // `this._curLine` refers to the line number *after* the splice is applied (so after those lines + // are deleted). + return this.curLine - this.curSplice[0] < this.curSplice.length - 2; + } + + /** + * Incorporates current line into the splice and marks its old position to be deleted. + * + * @returns {number} the index of the added line in curSplice + */ + private putCurLineInSplice() { + if (!this.isCurLineInSplice()) { + this.curSplice.push(Number(this.linesGet(this.curSplice[0] + this.curSplice[1]!))); + this.curSplice[1]!++; + } + // TODO should be the same as this._curSplice.length - 1 + return 2 + this.curLine - this.curSplice[0]; + } + + /** + * It will skip some newlines by putting them into the splice. + * + * @param {number} L - + * @param {boolean} includeInSplice - Indicates that attributes are present. + */ + public skipLines(L: number, includeInSplice?: boolean) { + if (!L) return; + if (includeInSplice) { + if (!this.inSplice) this.enterSplice(); + // TODO(doc) should this count the number of characters that are skipped to check? + for (let i = 0; i < L; i++) { + this.curCol = 0; + this.putCurLineInSplice(); + this.curLine++; + } + } else { + if (this.inSplice) { + if (L > 1) { + // TODO(doc) figure out why single lines are incorporated into splice instead of ignored + this.leaveSplice(); + } else { + this.putCurLineInSplice(); + } + } + this.curLine += L; + this.curCol = 0; + } + // tests case foo in remove(), which isn't otherwise covered in current impl + } + + /** + * Skip some characters. Can contain newlines. + * + * @param {number} N - number of characters to skip + * @param {number} L - number of newlines to skip + * @param {boolean} includeInSplice - indicates if attributes are present + */ + skip(N: number, L: number, includeInSplice: boolean) { + if (!N) return; + if (L) { + this.skipLines(L, includeInSplice); + } else { + if (includeInSplice && !this.inSplice) this.enterSplice(); + if (this.inSplice) { + // although the line is put into splice curLine is not increased, because + // only some chars are skipped, not the whole line + this.putCurLineInSplice(); + } + this.curCol += N; + } + } + + /** + * Remove whole lines from lines array. + * + * @param {number} L - number of lines to remove + * @returns {string} + */ + removeLines(L: number):string { + if (!L) return ''; + if (!this.inSplice) this.enterSplice(); + + /** + * Gets a string of joined lines after the end of the splice. + * + * @param {number} k - number of lines + * @returns {string} joined lines + */ + const nextKLinesText = (k: number): string => { + const m = this.curSplice[0] + this.curSplice[1]!; + return this.linesSlice(m, m + k).join(''); + }; + + let removed: any = ''; + if (this.isCurLineInSplice()) { + if (this.curCol === 0) { + removed = this.curSplice[this.curSplice.length - 1]; + this.curSplice.length--; + removed += nextKLinesText(L - 1); + this.curSplice[1]! += L - 1; + } else { + removed = nextKLinesText(L - 1); + this.curSplice[1]! += L - 1; + const sline = this.curSplice.length - 1; + // @ts-ignore + removed = this.curSplice[sline]!.substring(this.curCol) + removed; + // @ts-ignore + this.curSplice[sline] = this.curSplice[sline]!.substring(0, this.curCol) + + this.linesGet(this.curSplice[0] + this.curSplice[1]!); + // @ts-ignore + this.curSplice[1] += 1; + } + } else { + removed = nextKLinesText(L); + this.curSplice[1]! += L; + } + return removed; + } + + /** + * Remove text from lines array. + * + * @param {number} N - characters to delete + * @param {number} L - lines to delete + * @returns {string} + */ + remove(N: number, L: number) { + if (!N) return ''; + if (L) return this.removeLines(L); + if (!this.inSplice) this.enterSplice(); + // although the line is put into splice, curLine is not increased, because + // only some chars are removed not the whole line + const sline = this.putCurLineInSplice(); + // @ts-ignore + const removed = this.curSplice[sline].substring(this.curCol, this.curCol + N); + // @ts-ignore + this.curSplice[sline] = this.curSplice[sline]!.substring(0, this.curCol) + + // @ts-ignore + this.curSplice[sline].substring(this.curCol + N); + return removed; + } + + /** + * Inserts text into lines array. + * + * @param {string} text - the text to insert + * @param {number} L - number of newlines in text + */ + insert(text: string, L: number) { + if (!text) return; + if (!this.inSplice) this.enterSplice(); + if (L) { + const newLines = exports.splitTextLines(text); + if (this.isCurLineInSplice()) { + const sline = this.curSplice.length - 1; + /** @type {string} */ + const theLine = this.curSplice[sline]; + const lineCol = this.curCol; + // Insert the chars up to `curCol` and the first new line. + // @ts-ignore + this.curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; + this.curLine++; + newLines.splice(0, 1); + // insert the remaining new lines + this.curSplice.push(...newLines); + this.curLine += newLines.length; + // insert the remaining chars from the "old" line (e.g. the line we were in + // when we started to insert new lines) + // @ts-ignore + this.curSplice.push(theLine.substring(lineCol)); + this.curCol = 0; // TODO(doc) why is this not set to the length of last line? + } else { + this.curSplice.push(...newLines); + this.curLine += newLines.length; + } + } else { + // There are no additional lines. Although the line is put into splice, curLine is not + // increased because there may be more chars in the line (newline is not reached). + const sline = this.putCurLineInSplice(); + if (!this.curSplice[sline]) { + const err = new Error( + 'curSplice[sline] not populated, actual curSplice contents is ' + + `${JSON.stringify(this.curSplice)}. Possibly related to ` + + 'https://github.com/ether/etherpad-lite/issues/2802'); + console.error(err.stack || err.toString()); + } + // @ts-ignore + this.curSplice[sline] = this.curSplice[sline].substring(0, this.curCol) + text + + // @ts-ignore + this.curSplice[sline].substring(this.curCol); + this.curCol += text.length; + } + } + + /** + * Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`. + * + * @returns {boolean} indicates if there are lines left + */ + hasMore(): boolean { + let docLines = this.linesLength(); + if (this.inSplice) { + docLines += this.curSplice.length - 2 - this.curSplice[1]; + } + return this.curLine < docLines; + } + + /** + * Closes the splice + */ + close() { + if (this.inSplice) this.leaveSplice(); + } +} diff --git a/src/static/js/ace.js b/src/static/js/ace.ts similarity index 65% rename from src/static/js/ace.js rename to src/static/js/ace.ts index d756d40be..091ab12f1 100644 --- a/src/static/js/ace.js +++ b/src/static/js/ace.ts @@ -5,6 +5,8 @@ * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ +import {InnerWindow} from "./types/InnerWindow"; + /** * Copyright 2009 Google Inc. * @@ -28,21 +30,21 @@ const hooks = require('./pluginfw/hooks'); const pluginUtils = require('./pluginfw/shared'); const ace2_inner = require('ep_etherpad-lite/static/js/ace2_inner') -const debugLog = (...args) => {}; +const debugLog = (...args: string[]|Object[]|null[]) => {}; const cl_plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins') const {Cssmanager} = require("./cssmanager"); // The inner and outer iframe's locations are about:blank, so relative URLs are relative to that. // Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari // errors out unless given an absolute URL for a JavaScript-created element. -const absUrl = (url) => new URL(url, window.location.href).href; +const absUrl = (url: string) => new URL(url, window.location.href).href; -const eventFired = async (obj, event, cleanups = [], predicate = () => true) => { +const eventFired = async (obj: any, event: string, cleanups: Function[] = [], predicate = () => true) => { if (typeof cleanups === 'function') { predicate = cleanups; cleanups = []; } - await new Promise((resolve, reject) => { - let cleanup; + await new Promise((resolve: Function, reject: Function) => { + let cleanup: Function; const successCb = () => { if (!predicate()) return; debugLog(`Ace2Editor.init() ${event} event on`, obj); @@ -57,23 +59,23 @@ const eventFired = async (obj, event, cleanups = [], predicate = () => true) => }; cleanup = () => { cleanup = () => {}; - obj.removeEventListener(event, successCb); - obj.removeEventListener('error', errorCb); + obj!.removeEventListener(event, successCb); + obj!.removeEventListener('error', errorCb); }; cleanups.push(cleanup); - obj.addEventListener(event, successCb); - obj.addEventListener('error', errorCb); + obj!.addEventListener(event, successCb); + obj!.addEventListener('error', errorCb); }); }; // Resolves when the frame's document is ready to be mutated. Browsers seem to be quirky about // iframe ready events so this function throws the kitchen sink at the problem. Maybe one day we'll // find a concise general solution. -const frameReady = async (frame) => { +const frameReady = async (frame: HTMLIFrameElement) => { // Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace // the document object after the frame is first created for some reason. ¯\_(ツ)_/¯ - const doc = () => frame.contentDocument; - const cleanups = []; + const doc: any = () => frame.contentDocument; + const cleanups: Function[] = []; try { await Promise.race([ eventFired(frame, 'load', cleanups), @@ -87,26 +89,39 @@ const frameReady = async (frame) => { } }; -const Ace2Editor = function () { - let info = {editor: this}; - let loaded = false; +export class Ace2Editor { + info = {editor: this}; + loaded = false; + actionsPendingInit: Function[] = []; - let actionsPendingInit = []; - const pendingInit = (func) => function (...args) { + constructor() { + for (const fnName of this.aceFunctionsPendingInit) { + // Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to + // pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until + // method invocation. + // @ts-ignore + this[fnName] = this.pendingInit(function (...args) { + // @ts-ignore + this.info[`ace_${fnName}`].apply(this, args); + }); + } + } + + pendingInit = (func: Function) => (...args: any[])=> { const action = () => func.apply(this, args); - if (loaded) return action(); - actionsPendingInit.push(action); - }; + if (this.loaded) return action(); + this.actionsPendingInit.push(action); + } - const doActionsPendingInit = () => { - for (const fn of actionsPendingInit) fn(); - actionsPendingInit = []; - }; + doActionsPendingInit = () => { + for (const fn of this.actionsPendingInit) fn(); + this.actionsPendingInit = []; + } // The following functions (prefixed by 'ace_') are exposed by editor, but // execution is delayed until init is complete - const aceFunctionsPendingInit = [ + aceFunctionsPendingInit = [ 'importText', 'importAText', 'focus', @@ -124,21 +139,13 @@ const Ace2Editor = function () { 'callWithAce', 'execCommand', 'replaceRange', - ]; + ] - for (const fnName of aceFunctionsPendingInit) { - // Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to - // pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until - // method invocation. - this[fnName] = pendingInit(function (...args) { - info[`ace_${fnName}`].apply(this, args); - }); - } + // @ts-ignore + exportText = () => this.loaded ? this.info.ace_exportText() : '(awaiting init)\n'; + // @ts-ignore + getInInternationalComposition = () => this.loaded ? this.info.ace_getInInternationalComposition() : null; - this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\n'; - - this.getInInternationalComposition = - () => loaded ? info.ace_getInInternationalComposition() : null; // prepareUserChangeset: // Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes @@ -148,9 +155,9 @@ const Ace2Editor = function () { // be called again before applyPreparedChangesetToBase. Multiple consecutive calls to // prepareUserChangeset will return an updated changeset that takes into account the latest user // changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly. - this.prepareUserChangeset = () => loaded ? info.ace_prepareUserChangeset() : null; - - const addStyleTagsFor = (doc, files) => { + // @ts-ignore + prepareUserChangeset = () => this.loaded ? this.info.ace_prepareUserChangeset() : null; + addStyleTagsFor = (doc: Document, files: string[]) => { for (const file of files) { const link = doc.createElement('link'); link.rel = 'stylesheet'; @@ -158,32 +165,36 @@ const Ace2Editor = function () { link.href = absUrl(encodeURI(file)); doc.head.appendChild(link); } - }; + } - this.destroy = pendingInit(() => { - info.ace_dispose(); - info.frame.parentNode.removeChild(info.frame); - info = null; // prevent IE 6 closure memory leaks + destroy = this.pendingInit(() => { + // @ts-ignore + this.info.ace_dispose(); + // @ts-ignore + this.info.frame.parentNode.removeChild(this.info.frame); + // @ts-ignore + this.info = null; // prevent IE 6 closure memory leaks }); - this.init = async function (containerId, initialCode) { + init = async (containerId: string, initialCode: string)=> { debugLog('Ace2Editor.init()'); + // @ts-ignore this.importText(initialCode); const includedCSS = [ - `../static/css/iframe_editor.css?v=${clientVars.randomVersionString}`, - `../static/css/pad.css?v=${clientVars.randomVersionString}`, + `../static/css/iframe_editor.css?v=${window.clientVars.randomVersionString}`, + `../static/css/pad.css?v=${window.clientVars.randomVersionString}`, ...hooks.callAll('aceEditorCSS').map( - // Allow urls to external CSS - http(s):// and //some/path.css - (p) => /\/\//.test(p) ? p : `../static/plugins/${p}`), - `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`, + // Allow urls to external CSS - http(s):// and //some/path.css + (p: string) => /\/\//.test(p) ? p : `../static/plugins/${p}`), + `../static/skins/${window.clientVars.skinName}/pad.css?v=${window.clientVars.randomVersionString}`, ]; - const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== ''); + const skinVariants = window.clientVars.skinVariants.split(' ').filter((x: string) => x !== ''); const outerFrame = document.createElement('iframe'); outerFrame.name = 'ace_outer'; - outerFrame.frameBorder = 0; // for IE + outerFrame.frameBorder = String(0); // for IE outerFrame.title = 'Ether'; // Some browsers do strange things unless the iframe has a src or srcdoc property: // - Firefox replaces the frame's contentWindow.document object with a different object after @@ -195,8 +206,9 @@ const Ace2Editor = function () { // srcdoc is avoided because Firefox's Content Security Policy engine does not properly handle // 'self' with nested srcdoc iframes: https://bugzilla.mozilla.org/show_bug.cgi?id=1721296 outerFrame.src = '../static/empty.html'; - info.frame = outerFrame; - document.getElementById(containerId).appendChild(outerFrame); + // @ts-ignore + this.info.frame = outerFrame; + document.getElementById(containerId)!.appendChild(outerFrame); const outerWindow = outerFrame.contentWindow; debugLog('Ace2Editor.init() waiting for outer frame'); @@ -205,13 +217,13 @@ const Ace2Editor = function () { // Firefox might replace the outerWindow.document object after iframe creation so this variable // is assigned after the Window's load event. - const outerDocument = outerWindow.document; + const outerDocument = outerWindow!.document; // tag outerDocument.documentElement.classList.add('outer-editor', 'outerdoc', ...skinVariants); // tag - addStyleTagsFor(outerDocument, includedCSS); + this.addStyleTagsFor(outerDocument, includedCSS); const outerStyle = outerDocument.createElement('style'); outerStyle.type = 'text/css'; outerStyle.title = 'dynamicsyntax'; @@ -236,14 +248,11 @@ const Ace2Editor = function () { const innerFrame = outerDocument.createElement('iframe'); innerFrame.name = 'ace_inner'; innerFrame.title = 'pad'; - innerFrame.scrolling = 'no'; - innerFrame.frameBorder = 0; - innerFrame.allowTransparency = true; // for IE // The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above // outerFrame.srcdoc. innerFrame.src = 'empty.html'; outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild); - const innerWindow = innerFrame.contentWindow; + const innerWindow: InnerWindow = innerFrame.contentWindow!; debugLog('Ace2Editor.init() waiting for inner frame'); await frameReady(innerFrame); @@ -251,69 +260,47 @@ const Ace2Editor = function () { // Firefox might replace the innerWindow.document object after iframe creation so this variable // is assigned after the Window's load event. - const innerDocument = innerWindow.document; + const innerDocument = innerWindow!.document; // tag innerDocument.documentElement.classList.add('inner-editor', ...skinVariants); // tag - addStyleTagsFor(innerDocument, includedCSS); - //const requireKernel = innerDocument.createElement('script'); - //requireKernel.type = 'text/javascript'; - //requireKernel.src = - // absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`); - //innerDocument.head.appendChild(requireKernel); - // Pre-fetch modules to improve load performance. - /*for (const module of ['ace2_inner', 'ace2_common']) { - const script = innerDocument.createElement('script'); - script.type = 'text/javascript'; - script.src = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` + - `?callback=require.define&v=${clientVars.randomVersionString}`); - innerDocument.head.appendChild(script); - }*/ + this.addStyleTagsFor(innerDocument, includedCSS); + const innerStyle = innerDocument.createElement('style'); innerStyle.type = 'text/css'; innerStyle.title = 'dynamicsyntax'; innerDocument.head.appendChild(innerStyle); - const headLines = []; + const headLines: string[] = []; hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines}); innerDocument.head.appendChild( - innerDocument.createRange().createContextualFragment(headLines.join('\n'))); + innerDocument.createRange().createContextualFragment(headLines.join('\n'))); // tag innerDocument.body.id = 'innerdocbody'; innerDocument.body.classList.add('innerdocbody'); innerDocument.body.setAttribute('spellcheck', 'false'); innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); //   -/* - debugLog('Ace2Editor.init() waiting for require kernel load'); - await eventFired(requireKernel, 'load'); - debugLog('Ace2Editor.init() require kernel loaded'); - const require = innerWindow.require; - require.setRootURI(absUrl('../javascripts/src')); - require.setLibraryURI(absUrl('../javascripts/lib')); - require.setGlobalKeyPath('require'); -*/ - // intentially moved before requiring client_plugins to save a 307 - innerWindow.Ace2Inner = ace2_inner; - innerWindow.plugins = cl_plugins; - innerWindow.$ = innerWindow.jQuery = window.$; + // intentially moved before requiring client_plugins to save a 307 + innerWindow!.Ace2Inner = ace2_inner; + innerWindow!.plugins = cl_plugins; + + innerWindow!.$ = innerWindow.jQuery = window.$; debugLog('Ace2Editor.init() waiting for plugins'); - /*await new Promise((resolve, reject) => innerWindow.plugins.ensure( - (err) => err != null ? reject(err) : resolve()));*/ + + debugLog('Ace2Editor.init() waiting for Ace2Inner.init()'); - await innerWindow.Ace2Inner.init(info, { + await innerWindow.Ace2Inner.init(this.info, { inner: new Cssmanager(innerStyle.sheet), outer: new Cssmanager(outerStyle.sheet), - parent: new Cssmanager(document.querySelector('style[title="dynamicsyntax"]').sheet), + parent: new Cssmanager((document.querySelector('style[title="dynamicsyntax"]') as HTMLStyleElement)!.sheet), }); debugLog('Ace2Editor.init() Ace2Inner.init() returned'); - loaded = true; - doActionsPendingInit(); + this.loaded = true; + this.doActionsPendingInit(); debugLog('Ace2Editor.init() done'); - }; -}; - -exports.Ace2Editor = Ace2Editor; + } +} diff --git a/src/static/js/pad.js b/src/static/js/pad.ts similarity index 61% rename from src/static/js/pad.js rename to src/static/js/pad.ts index f6970ebbf..8f1c7af7b 100644 --- a/src/static/js/pad.js +++ b/src/static/js/pad.ts @@ -6,6 +6,8 @@ * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ +import {Socket} from "socket.io"; + /** * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd) * @@ -22,7 +24,7 @@ * limitations under the License. */ -let socket; +let socket: null | Socket; // These jQuery things should create local references, but for now `require()` @@ -38,6 +40,7 @@ const chat = require('./chat').chat; const getCollabClient = require('./collab_client').getCollabClient; const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus; import padcookie from "./pad_cookie"; + const padeditbar = require('./pad_editbar').padeditbar; const padeditor = require('./pad_editor').padeditor; const padimpexp = require('./pad_impexp').padimpexp; @@ -45,9 +48,12 @@ const padmodals = require('./pad_modals').padmodals; const padsavedrevs = require('./pad_savedrevs'); const paduserlist = require('./pad_userlist').paduserlist; import {padUtils as padutils} from "./pad_utils"; + const colorutils = require('./colorutils').colorutils; const randomString = require('./pad_utils').randomString; import connect from './socketio' +import {SocketClientReadyMessage} from "./types/SocketIOMessage"; +import {MapArrayType} from "../../node/types/MapType"; const hooks = require('./pluginfw/hooks'); @@ -61,22 +67,22 @@ const getParameters = [ { name: 'noColors', checkVal: 'true', - callback: (val) => { - settings.noColors = true; + callback: (val: any) => { + pad.settings.noColors = true; $('#clearAuthorship').hide(); }, }, { name: 'showControls', checkVal: 'true', - callback: (val) => { + callback: (val: any) => { $('#editbar').css('display', 'flex'); }, }, { name: 'showChat', checkVal: null, - callback: (val) => { + callback: (val: any) => { if (val === 'false') { settings.hideChat = true; chat.hide(); @@ -103,7 +109,7 @@ const getParameters = [ checkVal: null, callback: (val) => { settings.globalUserName = val; - clientVars.userName = val; + window.clientVars.userName = val; }, }, { @@ -111,7 +117,7 @@ const getParameters = [ checkVal: null, callback: (val) => { settings.globalUserColor = val; - clientVars.userColor = val; + window.clientVars.userColor = val; }, }, { @@ -149,7 +155,7 @@ const getParameters = [ const getParams = () => { // Tries server enforced options first.. for (const setting of getParameters) { - let value = clientVars.padOptions[setting.name]; + let value = window.clientVars.padOptions[setting.name]; if (value == null) continue; value = value.toString(); if (value === setting.checkVal || setting.checkVal == null) { @@ -169,7 +175,7 @@ const getParams = () => { const getUrlVars = () => new URL(window.location.href).searchParams; -const sendClientReady = (isReconnect) => { +const sendClientReady = (isReconnect: boolean) => { let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1); // unescape necessary due to Safari and Opera interpretation of spaces padId = decodeURIComponent(padId); @@ -196,7 +202,7 @@ const sendClientReady = (isReconnect) => { name: params.get('userName'), }; - const msg = { + const msg: SocketClientReadyMessage = { component: 'pad', type: 'CLIENT_READY', padId, @@ -207,11 +213,11 @@ const sendClientReady = (isReconnect) => { // this is a reconnect, lets tell the server our revisionnumber if (isReconnect) { - msg.client_rev = pad.collabClient.getCurrentRevisionNumber(); + msg.client_rev = this.collabClient!.getCurrentRevisionNumber(); msg.reconnect = true; } - socket.emit("message", msg); + socket!.emit("message", msg); }; const handshake = async () => { @@ -261,7 +267,7 @@ const handshake = async () => { socket.on('shout', (obj) => { - if(obj.type === "COLLABROOM") { + if (obj.type === "COLLABROOM") { let date = new Date(obj.data.payload.timestamp); $.gritter.add({ // (string | mandatory) the heading of the notification @@ -315,12 +321,13 @@ const handshake = async () => { window.clientVars = obj.data; if (window.clientVars.sessionRefreshInterval) { const ping = - () => $.ajax('../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {}); + () => $.ajax('../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => { + }); setInterval(ping, window.clientVars.sessionRefreshInterval); } - if(window.clientVars.mode === "development") { + if (window.clientVars.mode === "development") { console.warn('Enabling development mode with live update') - socket.on('liveupdate', ()=>{ + socket.on('liveupdate', () => { console.log('Live reload update received') location.reload() @@ -381,30 +388,52 @@ class MessageQueue { } } -const pad = { - // don't access these directly from outside this file, except - // for debugging - collabClient: null, - myUserInfo: null, - diagnosticInfo: {}, - initTime: 0, - clientTimeOffset: null, - padOptions: {}, - _messageQ: new MessageQueue(), +export class Pad { + private collabClient: null; + private myUserInfo: null | { + userId: string, + name: string, + ip: string, + colorId: string, + }; + private diagnosticInfo: {}; + private initTime: number; + private clientTimeOffset: null | number; + private _messageQ: MessageQueue; + private padOptions: MapArrayType>; + settings: PadSettings = { + LineNumbersDisabled: false, + noColors: false, + useMonospaceFontGlobal: false, + globalUserName: false, + globalUserColor: false, + rtlIsTrue: false, + } + + constructor() { + // don't access these directly from outside this file, except + // for debugging + this.collabClient = null + this.myUserInfo = null + this.diagnosticInfo = {} + this.initTime = 0 + this.clientTimeOffset = null + this.padOptions = {} + this._messageQ = new MessageQueue() + } // these don't require init; clientVars should all go through here - getPadId: () => clientVars.padId, - getClientIp: () => clientVars.clientIp, - getColorPalette: () => clientVars.colorPalette, - getPrivilege: (name) => clientVars.accountPrivs[name], - getUserId: () => pad.myUserInfo.userId, - getUserName: () => pad.myUserInfo.name, - userList: () => paduserlist.users(), - sendClientMessage: (msg) => { - pad.collabClient.sendClientMessage(msg); - }, - - init() { + getPadId = () => window.clientVars.padId + getClientIp = () => window.clientVars.clientIp + getColorPalette = () => window.clientVars.colorPalette + getPrivilege = (name: string) => window.clientVars.accountPrivs[name] + getUserId = () => this.myUserInfo!.userId + getUserName = () => this.myUserInfo!.name + userList = () => paduserlist.users() + sendClientMessage = (msg: string) => { + this.collabClient.sendClientMessage(msg); + } + init = () => { padutils.setupGlobalExceptionHandler(); // $(handler), $().ready(handler), $.wait($.ready).then(handler), etc. don't work if handler is @@ -412,28 +441,32 @@ const pad = { // function. $(() => (async () => { if (window.customStart != null) window.customStart(); + // @ts-ignore $('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220}); - $('#readonlyinput').on('click', () => { padeditbar.setEmbedLinks(); }); + $('#readonlyinput').on('click', () => { + padeditbar.setEmbedLinks(); + }); padcookie.init(); await handshake(); this._afterHandshake(); })()); - }, + } + _afterHandshake() { - pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp; + this.clientTimeOffset = Date.now() - window.clientVars.serverTimestamp; // initialize the chat chat.init(this); getParams(); padcookie.init(); // initialize the cookies - pad.initTime = +(new Date()); - pad.padOptions = clientVars.initialOptions; + this.initTime = +(new Date()); + this.padOptions = window.clientVars.initialOptions; - pad.myUserInfo = { - userId: clientVars.userId, - name: clientVars.userName, - ip: pad.getClientIp(), - colorId: clientVars.userColor, + this.myUserInfo = { + userId: window.clientVars.userId, + name: window.clientVars.userName, + ip: this.getClientIp(), + colorId: window.clientVars.userColor, }; const postAceInit = () => { @@ -442,7 +475,9 @@ const pad = { padeditor.ace.focus(); }, 0); const optionsStickyChat = $('#options-stickychat'); - optionsStickyChat.on('click', () => { chat.stickToScreen(); }); + optionsStickyChat.on('click', () => { + chat.stickToScreen(); + }); // if we have a cookie for always showing chat then show it if (padcookie.getPref('chatAlwaysVisible')) { chat.stickToScreen(true); // stick it to the screen @@ -454,15 +489,16 @@ const pad = { $('#options-chatandusers').prop('checked', true); // set the checkbox to on } if (padcookie.getPref('showAuthorshipColors') === false) { - pad.changeViewOption('showAuthorColors', false); + this.changeViewOption('showAuthorColors', false); } if (padcookie.getPref('showLineNumbers') === false) { - pad.changeViewOption('showLineNumbers', false); + this.changeViewOption('showLineNumbers', false); } if (padcookie.getPref('rtlIsTrue') === true) { - pad.changeViewOption('rtlIsTrue', true); + this.changeViewOption('rtlIsTrue', true); } - pad.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily')); + this.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily')); + // @ts-ignore $('#viewfontmenu').val(padcookie.getPref('padFontFamily')).niceSelect('update'); // Prevent sticky chat or chat and users to be checked for mobiles @@ -474,37 +510,39 @@ const pad = { }; const mobileMatch = window.matchMedia('(max-width: 800px)'); mobileMatch.addListener(checkChatAndUsersVisibility); // check if window resized - setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0); // check now after load + setTimeout(() => { + checkChatAndUsersVisibility(mobileMatch); + }, 0); // check now after load $('#editorcontainer').addClass('initialized'); - hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad}); + hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars: window.clientVars, pad}); }; // order of inits is important here: padimpexp.init(this); padsavedrevs.init(this); - padeditor.init(pad.padOptions.view || {}, this).then(postAceInit); - paduserlist.init(pad.myUserInfo, this); + padeditor.init(this.padOptions.view || {}, this).then(postAceInit); + paduserlist.init(this.myUserInfo, this); padconnectionstatus.init(); padmodals.init(this); - pad.collabClient = getCollabClient( - padeditor.ace, clientVars.collab_client_vars, pad.myUserInfo, - {colorPalette: pad.getColorPalette()}, pad); + this.collabClient = getCollabClient( + padeditor.ace, window.clientVars.collab_client_vars, this.myUserInfo, + {colorPalette: this.getColorPalette()}, pad); this._messageQ.setCollabClient(this.collabClient); - pad.collabClient.setOnUserJoin(pad.handleUserJoin); - pad.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate); - pad.collabClient.setOnUserLeave(pad.handleUserLeave); - pad.collabClient.setOnClientMessage(pad.handleClientMessage); - pad.collabClient.setOnChannelStateChange(pad.handleChannelStateChange); - pad.collabClient.setOnInternalAction(pad.handleCollabAction); + this.collabClient.setOnUserJoin(this.handleUserJoin); + this.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate); + this.collabClient.setOnUserLeave(pad.handleUserLeave); + this.collabClient.setOnClientMessage(pad.handleClientMessage); + this.collabClient.setOnChannelStateChange(pad.handleChannelStateChange); + this.collabClient.setOnInternalAction(pad.handleCollabAction); // load initial chat-messages - if (clientVars.chatHead !== -1) { - const chatHead = clientVars.chatHead; + if (window.clientVars.chatHead !== -1) { + const chatHead = window.clientVars.chatHead; const start = Math.max(chatHead - 100, 0); - pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead}); + this.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead}); } else { // there are no messages $('#chatloadmessagesbutton').css('display', 'none'); @@ -517,7 +555,9 @@ const pad = { $('#chaticon').hide(); $('#options-chatandusers').parent().hide(); $('#options-stickychat').parent().hide(); - } else if (!settings.hideChat) { $('#chaticon').show(); } + } else if (!settings.hideChat) { + $('#chaticon').show(); + } $('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite'); @@ -558,223 +598,259 @@ const pad = { this.notifyChangeColor(settings.globalUserColor); // Updates this.myUserInfo.colorId paduserlist.setMyUserInfo(this.myUserInfo); } - }, + } - dispose: () => { + dispose = () => { padeditor.dispose(); - }, - notifyChangeName: (newName) => { - pad.myUserInfo.name = newName; - pad.collabClient.updateUserInfo(pad.myUserInfo); - }, - notifyChangeColor: (newColorId) => { - pad.myUserInfo.colorId = newColorId; - pad.collabClient.updateUserInfo(pad.myUserInfo); - }, - changePadOption: (key, value) => { - const options = {}; + } + notifyChangeName = (newName) => { + this.myUserInfo.name = newName; + this.collabClient.updateUserInfo(this.myUserInfo); + } + notifyChangeColor = (newColorId) => { + this.myUserInfo.colorId = newColorId; + this.collabClient.updateUserInfo(this.myUserInfo); + } + + changePadOption = (key: string, value: string) => { + const options: MapArrayType = {}; options[key] = value; - pad.handleOptionsChange(options); - pad.collabClient.sendClientMessage( - { - type: 'padoptions', - options, - changedBy: pad.myUserInfo.name || 'unnamed', - }); - }, - changeViewOption: (key, value) => { - const options = { - view: {}, - }; + this.handleOptionsChange(options); + this.collabClient.sendClientMessage( + { + type: 'padoptions', + options, + changedBy: this.myUserInfo.name || 'unnamed', + }) + } + + changeViewOption = (key: string, value: string) => { + const options: MapArrayType> = + { + view: {} + , + } + ; options.view[key] = value; - pad.handleOptionsChange(options); - }, - handleOptionsChange: (opts) => { + this.handleOptionsChange(options); + } + + handleOptionsChange = (opts: MapArrayType>) => { // opts object is a full set of options or just // some options to change if (opts.view) { - if (!pad.padOptions.view) { - pad.padOptions.view = {}; + if (!this.padOptions.view) { + this.padOptions.view = {}; } for (const [k, v] of Object.entries(opts.view)) { - pad.padOptions.view[k] = v; + this.padOptions.view[k] = v; padcookie.setPref(k, v); } - padeditor.setViewOptions(pad.padOptions.view); + padeditor.setViewOptions(this.padOptions.view); } - }, - // caller shouldn't mutate the object - getPadOptions: () => pad.padOptions, - suggestUserName: (userId, name) => { - pad.collabClient.sendClientMessage( + } + getPadOptions = () => this.padOptions + suggestUserName = + (userId: string, name: string) => { + this.collabClient.sendClientMessage( { type: 'suggestUserName', unnamedId: userId, newName: name, }); - }, - handleUserJoin: (userInfo) => { - paduserlist.userJoinOrUpdate(userInfo); - }, - handleUserUpdate: (userInfo) => { - paduserlist.userJoinOrUpdate(userInfo); - }, - handleUserLeave: (userInfo) => { - paduserlist.userLeave(userInfo); - }, - handleClientMessage: (msg) => { - if (msg.type === 'suggestUserName') { - if (msg.unnamedId === pad.myUserInfo.userId && msg.newName && !pad.myUserInfo.name) { - pad.notifyChangeName(msg.newName); - paduserlist.setMyUserInfo(pad.myUserInfo); - } - } else if (msg.type === 'newRevisionList') { - padsavedrevs.newRevisionList(msg.revisionList); - } else if (msg.type === 'revisionLabel') { - padsavedrevs.newRevisionList(msg.revisionList); - } else if (msg.type === 'padoptions') { - const opts = msg.options; - pad.handleOptionsChange(opts); } - }, - handleChannelStateChange: (newState, message) => { - const oldFullyConnected = !!padconnectionstatus.isFullyConnected(); - const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting'); - if (newState === 'CONNECTED') { - padeditor.enable(); - padeditbar.enable(); - padimpexp.enable(); - padconnectionstatus.connected(); - } else if (newState === 'RECONNECTING') { - padeditor.disable(); - padeditbar.disable(); - padimpexp.disable(); - padconnectionstatus.reconnecting(); - } else if (newState === 'DISCONNECTED') { - pad.diagnosticInfo.disconnectedMessage = message; - pad.diagnosticInfo.padId = pad.getPadId(); - pad.diagnosticInfo.socket = {}; - - // we filter non objects from the socket object and put them in the diagnosticInfo - // this ensures we have no cyclic data - this allows us to stringify the data - for (const [i, value] of Object.entries(socket.socket || {})) { - const type = typeof value; - - if (type === 'string' || type === 'number') { - pad.diagnosticInfo.socket[i] = value; + handleUserJoin = (userInfo) => { + paduserlist.userJoinOrUpdate(userInfo); + } + handleUserUpdate = (userInfo) => { + paduserlist.userJoinOrUpdate(userInfo); + } + handleUserLeave = + (userInfo) => { + paduserlist.userLeave(userInfo); + } + // caller shouldn't mutate the object + handleClientMessage = + (msg) => { + if (msg.type === 'suggestUserName') { + if (msg.unnamedId === pad.myUserInfo.userId && msg.newName && !pad.myUserInfo.name) { + pad.notifyChangeName(msg.newName); + paduserlist.setMyUserInfo(pad.myUserInfo); } + } else if (msg.type === 'newRevisionList') { + padsavedrevs.newRevisionList(msg.revisionList); + } else if (msg.type === 'revisionLabel') { + padsavedrevs.newRevisionList(msg.revisionList); + } else if (msg.type === 'padoptions') { + const opts = msg.options; + pad.handleOptionsChange(opts); } + } - pad.asyncSendDiagnosticInfo(); - if (typeof window.ajlog === 'string') { - window.ajlog += (`Disconnected: ${message}\n`); + handleChannelStateChange + = + (newState, message) => { + const oldFullyConnected = !!padconnectionstatus.isFullyConnected(); + const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting'); + if (newState === 'CONNECTED') { + padeditor.enable(); + padeditbar.enable(); + padimpexp.enable(); + padconnectionstatus.connected(); + } else if (newState === 'RECONNECTING') { + padeditor.disable(); + padeditbar.disable(); + padimpexp.disable(); + padconnectionstatus.reconnecting(); + } else if (newState === 'DISCONNECTED') { + pad.diagnosticInfo.disconnectedMessage = message; + pad.diagnosticInfo.padId = pad.getPadId(); + pad.diagnosticInfo.socket = {}; + + // we filter non objects from the socket object and put them in the diagnosticInfo + // this ensures we have no cyclic data - this allows us to stringify the data + for (const [i, value] of Object.entries(socket.socket || {})) { + const type = typeof value; + + if (type === 'string' || type === 'number') { + pad.diagnosticInfo.socket[i] = value; + } + } + + pad.asyncSendDiagnosticInfo(); + if (typeof window.ajlog === 'string') { + window.ajlog += (`Disconnected: ${message}\n`); + } + padeditor.disable(); + padeditbar.disable(); + padimpexp.disable(); + + padconnectionstatus.disconnected(message); + } + const newFullyConnected = !!padconnectionstatus.isFullyConnected(); + if (newFullyConnected !== oldFullyConnected) { + pad.handleIsFullyConnected(newFullyConnected, wasConnecting); } - padeditor.disable(); - padeditbar.disable(); - padimpexp.disable(); - - padconnectionstatus.disconnected(message); } - const newFullyConnected = !!padconnectionstatus.isFullyConnected(); - if (newFullyConnected !== oldFullyConnected) { - pad.handleIsFullyConnected(newFullyConnected, wasConnecting); + handleIsFullyConnected + = + (isConnected, isInitialConnect) => { + pad.determineChatVisibility(isConnected && !isInitialConnect); + pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect); + pad.determineAuthorshipColorsVisibility(); + setTimeout(() => { + padeditbar.toggleDropDown('none'); + }, 1000); } - }, - handleIsFullyConnected: (isConnected, isInitialConnect) => { - pad.determineChatVisibility(isConnected && !isInitialConnect); - pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect); - pad.determineAuthorshipColorsVisibility(); - setTimeout(() => { - padeditbar.toggleDropDown('none'); - }, 1000); - }, - determineChatVisibility: (asNowConnectedFeedback) => { - const chatVisCookie = padcookie.getPref('chatAlwaysVisible'); - if (chatVisCookie) { // if the cookie is set for chat always visible - chat.stickToScreen(true); // stick it to the screen - $('#options-stickychat').prop('checked', true); // set the checkbox to on - } else { - $('#options-stickychat').prop('checked', false); // set the checkbox for off + determineChatVisibility + = + (asNowConnectedFeedback) => { + const chatVisCookie = padcookie.getPref('chatAlwaysVisible'); + if (chatVisCookie) { // if the cookie is set for chat always visible + chat.stickToScreen(true); // stick it to the screen + $('#options-stickychat').prop('checked', true); // set the checkbox to on + } else { + $('#options-stickychat').prop('checked', false); // set the checkbox for off + } } - }, - determineChatAndUsersVisibility: (asNowConnectedFeedback) => { - const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible'); - if (chatAUVisCookie) { // if the cookie is set for chat always visible - chat.chatAndUsers(true); // stick it to the screen - $('#options-chatandusers').prop('checked', true); // set the checkbox to on - } else { - $('#options-chatandusers').prop('checked', false); // set the checkbox for off + determineChatAndUsersVisibility + = + (asNowConnectedFeedback) => { + const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible'); + if (chatAUVisCookie) { // if the cookie is set for chat always visible + chat.chatAndUsers(true); // stick it to the screen + $('#options-chatandusers').prop('checked', true); // set the checkbox to on + } else { + $('#options-chatandusers').prop('checked', false); // set the checkbox for off + } } - }, - determineAuthorshipColorsVisibility: () => { - const authColCookie = padcookie.getPref('showAuthorshipColors'); - if (authColCookie) { - pad.changeViewOption('showAuthorColors', true); - $('#options-colorscheck').prop('checked', true); - } else { - $('#options-colorscheck').prop('checked', false); + determineAuthorshipColorsVisibility + = + () => { + const authColCookie = padcookie.getPref('showAuthorshipColors'); + if (authColCookie) { + pad.changeViewOption('showAuthorColors', true); + $('#options-colorscheck').prop('checked', true); + } else { + $('#options-colorscheck').prop('checked', false); + } } - }, - handleCollabAction: (action) => { - if (action === 'commitPerformed') { - padeditbar.setSyncStatus('syncing'); - } else if (action === 'newlyIdle') { - padeditbar.setSyncStatus('done'); + handleCollabAction + = + (action) => { + if (action === 'commitPerformed') { + padeditbar.setSyncStatus('syncing'); + } else if (action === 'newlyIdle') { + padeditbar.setSyncStatus('done'); + } } - }, - asyncSendDiagnosticInfo: () => { - window.setTimeout(() => { - $.ajax( + asyncSendDiagnosticInfo + = + () => { + window.setTimeout(() => { + $.ajax( { type: 'post', url: '../ep/pad/connection-diagnostic-info', data: { diagnosticInfo: JSON.stringify(pad.diagnosticInfo), }, - success: () => {}, - error: () => {}, + success: () => { + }, + error: () => { + }, }); - }, 0); - }, - forceReconnect: () => { - $('form#reconnectform input.padId').val(pad.getPadId()); - pad.diagnosticInfo.collabDiagnosticInfo = pad.collabClient.getDiagnosticInfo(); - $('form#reconnectform input.diagnosticInfo').val(JSON.stringify(pad.diagnosticInfo)); - $('form#reconnectform input.missedChanges') - .val(JSON.stringify(pad.collabClient.getMissedChanges())); - $('form#reconnectform').trigger('submit'); - }, - callWhenNotCommitting: (f) => { - pad.collabClient.callWhenNotCommitting(f); - }, - getCollabRevisionNumber: () => pad.collabClient.getCurrentRevisionNumber(), - isFullyConnected: () => padconnectionstatus.isFullyConnected(), - addHistoricalAuthors: (data) => { - if (!pad.collabClient) { - window.setTimeout(() => { - pad.addHistoricalAuthors(data); - }, 1000); - } else { - pad.collabClient.addHistoricalAuthors(data); + }, 0); } - }, -}; + forceReconnect + = + () => { + $('form#reconnectform input.padId').val(pad.getPadId()); + pad.diagnosticInfo.collabDiagnosticInfo = pad.collabClient.getDiagnosticInfo(); + $('form#reconnectform input.diagnosticInfo').val(JSON.stringify(pad.diagnosticInfo)); + $('form#reconnectform input.missedChanges') + .val(JSON.stringify(pad.collabClient.getMissedChanges())); + $('form#reconnectform').trigger('submit'); + } + callWhenNotCommitting + = + (f) => { + pad.collabClient.callWhenNotCommitting(f); + } + getCollabRevisionNumber + = + () => pad.collabClient.getCurrentRevisionNumber() + isFullyConnected + = + () => padconnectionstatus.isFullyConnected() + addHistoricalAuthors + = + (data) => { + if (!pad.collabClient) { + window.setTimeout(() => { + pad.addHistoricalAuthors(data); + }, 1000); + } else { + pad.collabClient.addHistoricalAuthors(data); + } + } +} -const init = () => pad.init(); -const settings = { - LineNumbersDisabled: false, - noColors: false, - useMonospaceFontGlobal: false, - globalUserName: false, - globalUserColor: false, - rtlIsTrue: false, -}; +export type PadSettings = { + LineNumbersDisabled: boolean, + noColors: boolean, + useMonospaceFontGlobal: boolean, + globalUserName: string | boolean, + globalUserColor: string | boolean, + rtlIsTrue: boolean, + hideChat?: boolean, +} + +export const pad = new Pad() -pad.settings = settings; exports.baseURL = ''; -exports.settings = settings; exports.randomString = randomString; exports.getParams = getParams; exports.pad = pad; diff --git a/src/static/js/pad_editor.js b/src/static/js/pad_editor.js deleted file mode 100644 index 739b73a6a..000000000 --- a/src/static/js/pad_editor.js +++ /dev/null @@ -1,211 +0,0 @@ -'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 Cookies = require('./pad_utils').Cookies; - -import padcookie from "./pad_cookie"; -import {padUtils as padutils} from "./pad_utils"; -const Ace2Editor = require('./ace').Ace2Editor; -import html10n from '../js/vendors/html10n' - -const padeditor = (() => { - let pad = undefined; - let settings = undefined; - - const self = { - ace: null, - // this is accessed directly from other files - viewZoom: 100, - init: async (initialViewOptions, _pad) => { - pad = _pad; - settings = pad.settings; - self.ace = new Ace2Editor(); - await self.ace.init('editorcontainer', ''); - $('#editorloadingbox').hide(); - // Listen for clicks on sidediv items - const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody'); - $outerdoc.find('#sidedivinner').on('click', 'div', function () { - const targetLineNumber = $(this).index() + 1; - window.location.hash = `L${targetLineNumber}`; - }); - exports.focusOnLine(self.ace); - self.ace.setProperty('wraps', true); - self.initViewOptions(); - self.setViewOptions(initialViewOptions); - // view bar - $('#viewbarcontents').show(); - }, - initViewOptions: () => { - // Line numbers - padutils.bindCheckboxChange($('#options-linenoscheck'), () => { - pad.changeViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck'))); - }); - - // Author colors - padutils.bindCheckboxChange($('#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'))); - }); - html10n.bind('localized', () => { - pad.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection())); - padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection())); - }); - - // font family change - $('#viewfontmenu').on('change', () => { - pad.changeViewOption('padFontFamily', $('#viewfontmenu').val()); - }); - - // Language - html10n.bind('localized', () => { - $('#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 - // html10n just ingores s - // also, a value which has been set by the user will be not overwritten - // since a user-edited does *not* have the editempty-class - $('input[data-l10n-id]').each((key, input) => { - input = $(input); - if (input.hasClass('editempty')) { - input.val(html10n.get(input.attr('data-l10n-id'))); - } - }); - }); - $('#languagemenu').val(html10n.getLanguage()); - $('#languagemenu').on('change', () => { - Cookies.set('language', $('#languagemenu').val()); - html10n.localize([$('#languagemenu').val(), 'en']); - if ($('select').niceSelect) { - $('select').niceSelect('update'); - } - }); - }, - setViewOptions: (newOptions) => { - const getOption = (key, 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); - }); - } - } - } - } - // End of setSelection / set Y position of editor -}; diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts new file mode 100644 index 000000000..f74fb7b4d --- /dev/null +++ b/src/static/js/pad_editor.ts @@ -0,0 +1,229 @@ +'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 + */ + +import {PadType} from "../../node/types/PadType"; + +/** + * 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. + */ + +import {Cookies} from "./pad_utils"; + +import padcookie from "./pad_cookie"; +import {padUtils as padutils} from "./pad_utils"; + +import {Ace2Editor} from "./ace"; +import html10n from '../js/vendors/html10n' +import {MapArrayType} from "../../node/types/MapType"; +import {ClientVarData, ClientVarMessage} from "./types/SocketIOMessage"; + +export class PadEditor { + private pad?: PadType + private settings: undefined| ClientVarData + private ace: any + private viewZoom: number + + constructor() { + this.pad = undefined; + this.settings = undefined; + this.ace = null + // this is accessed directly from other files + this.viewZoom = 100 + } + + init = async (initialViewOptions: MapArrayType, _pad: PadType) => { + this.pad = _pad; + this.settings = this.pad.settings; + this.ace = new Ace2Editor(); + await this.ace.init('editorcontainer', ''); + $('#editorloadingbox').hide(); + // Listen for clicks on sidediv items + const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody'); + $outerdoc.find('#sidedivinner').on('click', 'div', function () { + const targetLineNumber = $(this).index() + 1; + window.location.hash = `L${targetLineNumber}`; + }); + this.focusOnLine(this.ace); + this.ace.setProperty('wraps', true); + this.initViewOptions(); + this.setViewOptions(initialViewOptions); + // view bar + $('#viewbarcontents').show(); + } + + + initViewOptions = () => { + // Line numbers + padutils.bindCheckboxChange($('#options-linenoscheck'), () => { + this.pad!.changeViewOption('showLineNumbers', padutils.getCheckbox('#options-linenoscheck')); + }); + +// Author colors + padutils.bindCheckboxChange($('#options-colorscheck'), () => { + padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck') as any); + this.pad!.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck')); + }); + +// Right to left + padutils.bindCheckboxChange($('#options-rtlcheck'), () => { + this.pad!.changeViewOption('rtlIsTrue', padutils.getCheckbox('#options-rtlcheck')); + }); + html10n.bind('localized', () => { + this.pad!.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection())); + padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection())); + }); + +// font family change + $('#viewfontmenu').on('change', () => { + this.pad!.changeViewOption('padFontFamily', $('#viewfontmenu').val()); + }); + +// Language + html10n.bind('localized', () => { + $('#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 + // html10n just ingores s + // also, a value which has been set by the user will be not overwritten + // since a user-edited does *not* have the editempty-class + $('input[data-l10n-id]').each((key, input) => { + // @ts-ignore + input = $(input); + // @ts-ignore + if (input.hasClass('editempty')) { + // @ts-ignore + input.val(html10n.get(input.attr('data-l10n-id'))); + } + }); + }); + $('#languagemenu').val(html10n.getLanguage()!); + $('#languagemenu').on('change', () => { + Cookies.set('language', $('#languagemenu').val() as string); + html10n.localize([$('#languagemenu').val() as string, 'en']); + // @ts-ignore + if ($('select').niceSelect) { + // @ts-ignore + $('select').niceSelect('update'); + } + }); + } + + setViewOptions = (newOptions: MapArrayType) => { + const getOption = (key: string, defaultValue: boolean) => { + 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())); + this.ace.setProperty('rtlIsTrue', v); + padutils.setCheckbox($('#options-rtlcheck'), v); + + v = getOption('showLineNumbers', true); + this.ace.setProperty('showslinenumbers', v); + padutils.setCheckbox($('#options-linenoscheck'), v); + + v = getOption('showAuthorColors', true); + this.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 (this.settings!.noColors !== false) { + this.ace.setProperty('showsauthorcolors', !settings.noColors); + } + + this.ace.setProperty('textface', newOptions.padFontFamily || ''); + } + + dispose = () => { + if (this.ace) { + this.ace.destroy(); + this.ace = null; + } + } + enable = () => { + if (this.ace) { + this.ace.setEditable(true); + } + } + disable = () => { + if (this.ace) { + this.ace.setEditable(false); + } + } + restoreRevisionText= (dataFromServer: ClientVarData) => { + this.pad!.addHistoricalAuthors(dataFromServer.historicalAuthorData); + this.ace.importAText(dataFromServer.atext, dataFromServer.apool, true); + } + + 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 + } +} + +export const padEditor = new PadEditor(); diff --git a/src/static/js/pad_utils.ts b/src/static/js/pad_utils.ts index ab5dde6b6..a4e43192e 100644 --- a/src/static/js/pad_utils.ts +++ b/src/static/js/pad_utils.ts @@ -343,9 +343,9 @@ class PadUtils { clear, }; } - getCheckbox = (node: JQueryNode) => $(node).is(':checked') + getCheckbox = (node: string) => $(node).is(':checked') setCheckbox = - (node: JQueryNode, value: string) => { + (node: JQueryNode, value: boolean) => { if (value) { $(node).attr('checked', 'checked'); } else { diff --git a/src/static/js/types/AText.ts b/src/static/js/types/AText.ts new file mode 100644 index 000000000..d58626de2 --- /dev/null +++ b/src/static/js/types/AText.ts @@ -0,0 +1,4 @@ +export type AText = { + text: string, + attribs: string, +} diff --git a/src/static/js/types/ChangeSet.ts b/src/static/js/types/ChangeSet.ts new file mode 100644 index 000000000..15e67756c --- /dev/null +++ b/src/static/js/types/ChangeSet.ts @@ -0,0 +1,6 @@ +export type ChangeSet = { + oldLen: number; + newLen: number; + ops: string; + charBank: string; +} diff --git a/src/static/js/types/InnerWindow.ts b/src/static/js/types/InnerWindow.ts new file mode 100644 index 000000000..056ff1ac3 --- /dev/null +++ b/src/static/js/types/InnerWindow.ts @@ -0,0 +1,5 @@ +export type InnerWindow = Window & { + Ace2Inner?: any, + plugins?: any, + jQuery?: any +} diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index ca5c629e9..48f8378e6 100644 --- a/src/static/js/types/SocketIOMessage.ts +++ b/src/static/js/types/SocketIOMessage.ts @@ -1,13 +1,55 @@ +import {MapArrayType} from "../../../node/types/MapType"; +import {AText} from "./AText"; +import AttributePool from "../AttributePool"; + export type SocketIOMessage = { type: string accessStatus: string } +export type ClientVarData = { + sessionRefreshInterval: number, + historicalAuthorData:MapArrayType<{ + name: string; + colorId: string; + }>, + atext: AText, + apool: AttributePool, + noColors: boolean, + userName: string, + userColor:string, + hideChat: boolean, + padOptions: MapArrayType, + padId: string, + clientIp: string, + colorPalette: MapArrayType, + accountPrivs: MapArrayType, + collab_client_vars: MapArrayType, + chatHead: number, + readonly: boolean, + serverTimestamp: number, + initialOptions: MapArrayType, + userId: string, +} export type ClientVarMessage = { - data: { - sessionRefreshInterval: number - } + data: ClientVarData, type: string accessStatus: string } + +export type SocketClientReadyMessage = { + type: string + component: string + padId: string + sessionID: string + token: string + userInfo: { + colorId: string|null + name: string|null + }, + reconnect?: boolean + client_rev?: number +} + + diff --git a/src/static/js/types/Window.ts b/src/static/js/types/Window.ts index df19bc8cf..feb143831 100644 --- a/src/static/js/types/Window.ts +++ b/src/static/js/types/Window.ts @@ -1,6 +1,9 @@ +import {ClientVarData} from "./SocketIOMessage"; + declare global { interface Window { - clientVars: any; - $: any + clientVars: ClientVarData; + $: any, + customStart?:any } }