diff --git a/doc/api/changeset_library.md b/doc/api/changeset_library.md index 7929aa48b..89846a55b 100644 --- a/doc/api/changeset_library.md +++ b/doc/api/changeset_library.md @@ -7,7 +7,7 @@ provides tools to create, read, and apply changesets. ## Changeset ```javascript -const Changeset = require('ep_etherpad-lite/static/js/Changeset'); +const Changeset = require('src/static/js/Changeset'); ``` A changeset describes the difference between two revisions of a document. When a @@ -24,7 +24,7 @@ A transmitted changeset looks like this: ## Attribute Pool ```javascript -const AttributePool = require('ep_etherpad-lite/static/js/AttributePool'); +const AttributePool = require('src/static/js/AttributePool'); ``` Changesets do not include any attribute key–value pairs. Instead, they use diff --git a/doc/api/hooks_server-side.md b/doc/api/hooks_server-side.md index 05a66209f..663715373 100644 --- a/doc/api/hooks_server-side.md +++ b/doc/api/hooks_server-side.md @@ -825,16 +825,16 @@ Context properties: Example: ```javascript -const AttributeMap = require('ep_etherpad-lite/static/js/AttributeMap'); -const Changeset = require('ep_etherpad-lite/static/js/Changeset'); +const AttributeMap = require('src/static/js/AttributeMap'); +const Changeset = require('src/static/js/Changeset'); exports.getLineHTMLForExport = async (hookName, context) => { - if (!context.attribLine) return; - const [op] = Changeset.deserializeOps(context.attribLine); - if (op == null) return; - const heading = AttributeMap.fromString(op.attribs, context.apool).get('heading'); - if (!heading) return; - context.lineContent = `<${heading}>${context.lineContent}`; + if (!context.attribLine) return; + const [op] = Changeset.deserializeOps(context.attribLine); + if (op == null) return; + const heading = AttributeMap.fromString(op.attribs, context.apool).get('heading'); + if (!heading) return; + context.lineContent = `<${heading}>${context.lineContent}`; }; ``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eca4dceb3..459df8954 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,6 +294,9 @@ importers: '@types/jquery': specifier: ^3.5.30 version: 3.5.30 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@types/jsdom': specifier: ^21.1.7 version: 21.1.7 @@ -1498,6 +1501,9 @@ packages: '@types/jquery@3.5.30': resolution: {integrity: sha512-nbWKkkyb919DOUxjmRVk8vwtDb0/k8FKncmUKFi+NY+QXqWltooxTrswvz4LspQwxvLdvzBN1TImr6cw3aQx2A==} + '@types/js-cookie@3.0.6': + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} @@ -5469,6 +5475,8 @@ snapshots: dependencies: '@types/sizzle': 2.3.8 + '@types/js-cookie@3.0.6': {} + '@types/jsdom@21.1.7': dependencies: '@types/node': 20.14.11 diff --git a/src/node/db/AuthorManager.ts b/src/node/db/AuthorManager.ts index 2f4e7d751..c09907d45 100644 --- a/src/node/db/AuthorManager.ts +++ b/src/node/db/AuthorManager.ts @@ -22,7 +22,7 @@ const db = require('./DB'); const CustomError = require('../utils/customError'); const hooks = require('../../static/js/pluginfw/hooks.js'); -const {randomString, padutils: {warnDeprecated}} = require('../../static/js/pad_utils'); +import {padUtils, randomString} from '../../static/js/pad_utils' exports.getColorPalette = () => [ '#ffc7c7', @@ -169,7 +169,7 @@ exports.getAuthorId = async (token: string, user: object) => { * @param {String} token The token */ exports.getAuthor4Token = async (token: string) => { - warnDeprecated( + padUtils.warnDeprecated( 'AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead'); return await getAuthor4Token(token); }; diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index fa4af994d..aca8c3342 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -7,10 +7,10 @@ import {MapArrayType} from "../types/MapType"; * The pad object, defined with joose */ -const AttributeMap = require('../../static/js/AttributeMap'); +import AttributeMap from '../../static/js/AttributeMap'; const Changeset = require('../../static/js/Changeset'); const ChatMessage = require('../../static/js/ChatMessage'); -const AttributePool = require('../../static/js/AttributePool'); +import AttributePool from '../../static/js/AttributePool'; const Stream = require('../utils/Stream'); const assert = require('assert').strict; const db = require('./DB'); @@ -23,7 +23,7 @@ const CustomError = require('../utils/customError'); const readOnlyManager = require('./ReadOnlyManager'); const randomString = require('../utils/randomstring'); const hooks = require('../../static/js/pluginfw/hooks'); -const {padutils: {warnDeprecated}} = require('../../static/js/pad_utils'); +import {padUtils} from "../../static/js/pad_utils"; const promises = require('../utils/promises'); /** @@ -40,7 +40,7 @@ exports.cleanText = (txt:string): string => txt.replace(/\r\n/g, '\n') class Pad { private db: Database; private atext: AText; - private pool: APool; + private pool: AttributePool; private head: number; private chatHead: number; private publicStatus: boolean; @@ -126,11 +126,11 @@ class Pad { pad: this, authorId, get author() { - warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`); + padUtils.warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`); return this.authorId; }, set author(authorId) { - warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`); + padUtils.warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`); this.authorId = authorId; }, ...this.head === 0 ? {} : { @@ -437,11 +437,11 @@ class Pad { // let the plugins know the pad was copied await hooks.aCallAll('padCopy', { get originalPad() { - warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead'); + padUtils.warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead'); return this.srcPad; }, get destinationID() { - warnDeprecated( + padUtils.warnDeprecated( 'padCopy destinationID context property is deprecated; use dstPad.id instead'); return this.dstPad.id; }, @@ -538,11 +538,11 @@ class Pad { await hooks.aCallAll('padCopy', { get originalPad() { - warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead'); + padUtils.warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead'); return this.srcPad; }, get destinationID() { - warnDeprecated( + padUtils.warnDeprecated( 'padCopy destinationID context property is deprecated; use dstPad.id instead'); return this.dstPad.id; }, @@ -603,7 +603,7 @@ class Pad { p.push(padManager.removePad(padID)); p.push(hooks.aCallAll('padRemove', { get padID() { - warnDeprecated('padRemove padID context property is deprecated; use pad.id instead'); + padUtils.warnDeprecated('padRemove padID context property is deprecated; use pad.id instead'); return this.pad.id; }, pad: this, diff --git a/src/node/db/SecurityManager.ts b/src/node/db/SecurityManager.ts index 326bf3659..8b6c9fc45 100644 --- a/src/node/db/SecurityManager.ts +++ b/src/node/db/SecurityManager.ts @@ -30,7 +30,7 @@ const settings = require('../utils/Settings'); const webaccess = require('../hooks/express/webaccess'); const log4js = require('log4js'); const authLogger = log4js.getLogger('auth'); -const {padutils} = require('../../static/js/pad_utils'); +import {padUtils as padutils} from '../../static/js/pad_utils'; const DENY = Object.freeze({accessStatus: 'deny'}); diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 390949607..2909c7458 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -21,12 +21,12 @@ import {MapArrayType} from "../types/MapType"; -const AttributeMap = require('../../static/js/AttributeMap'); +import AttributeMap from '../../static/js/AttributeMap'; const padManager = require('../db/PadManager'); const Changeset = require('../../static/js/Changeset'); const ChatMessage = require('../../static/js/ChatMessage'); -const AttributePool = require('../../static/js/AttributePool'); -const AttributeManager = require('../../static/js/AttributeManager'); +import AttributePool from '../../static/js/AttributePool'; +import AttributeManager from '../../static/js/AttributeManager'; const authorManager = require('../db/AuthorManager'); const {padutils} = require('../../static/js/pad_utils'); const readOnlyManager = require('../db/ReadOnlyManager'); @@ -738,7 +738,7 @@ exports.updatePadClients = async (pad: PadType) => { /** * Copied from the Etherpad Source Code. Don't know what this method does excatly... */ -const _correctMarkersInPad = (atext: AText, apool: APool) => { +const _correctMarkersInPad = (atext: AText, apool: AttributePool) => { const text = atext.text; // collect char positions of line markers (e.g. bullets) in new atext diff --git a/src/node/utils/ExportHelper.ts b/src/node/utils/ExportHelper.ts index f3a438e86..e12332b06 100644 --- a/src/node/utils/ExportHelper.ts +++ b/src/node/utils/ExportHelper.ts @@ -19,7 +19,8 @@ * limitations under the License. */ -const AttributeMap = require('../../static/js/AttributeMap'); +import AttributeMap from '../../static/js/AttributeMap'; +import AttributePool from "../../static/js/AttributePool"; const Changeset = require('../../static/js/Changeset'); const { checkValidRev } = require('./checkValidRev'); @@ -51,7 +52,7 @@ type LineModel = { [id:string]:string|number|LineModel } -exports._analyzeLine = (text:string, aline: LineModel, apool: Function) => { +exports._analyzeLine = (text:string, aline: LineModel, apool: AttributePool) => { const line: LineModel = {}; // identify list diff --git a/src/node/utils/ExportHtml.ts b/src/node/utils/ExportHtml.ts index 3b84c4380..c75cdc1a7 100644 --- a/src/node/utils/ExportHtml.ts +++ b/src/node/utils/ExportHtml.ts @@ -22,7 +22,7 @@ const Changeset = require('../../static/js/Changeset'); const attributes = require('../../static/js/attributes'); const padManager = require('../db/PadManager'); const _ = require('underscore'); -const Security = require('../../static/js/security'); +const Security = require('security'); const hooks = require('../../static/js/pluginfw/hooks'); const eejs = require('../eejs'); const _analyzeLine = require('./ExportHelper')._analyzeLine; diff --git a/src/node/utils/ImportEtherpad.ts b/src/node/utils/ImportEtherpad.ts index 50b9a43d5..9f2467c0d 100644 --- a/src/node/utils/ImportEtherpad.ts +++ b/src/node/utils/ImportEtherpad.ts @@ -18,7 +18,7 @@ import {APool} from "../types/PadType"; * limitations under the License. */ -const AttributePool = require('../../static/js/AttributePool'); +import AttributePool from '../../static/js/AttributePool'; const {Pad} = require('../db/Pad'); const Stream = require('./Stream'); const authorManager = require('../db/AuthorManager'); @@ -61,7 +61,7 @@ exports.setPadRaw = async (padId: string, r: string, authorId = '') => { try { const processRecord = async (key:string, value: null|{ padIDs: string|Record, - pool: APool + pool: AttributePool }) => { if (!value) return; const keyParts = key.split(':'); diff --git a/src/node/utils/padDiff.ts b/src/node/utils/padDiff.ts index d731ebbe4..6074f8bbc 100644 --- a/src/node/utils/padDiff.ts +++ b/src/node/utils/padDiff.ts @@ -3,7 +3,7 @@ import {PadAuthor, PadType} from "../types/PadType"; import {MapArrayType} from "../types/MapType"; -const AttributeMap = require('../../static/js/AttributeMap'); +import AttributeMap from '../../static/js/AttributeMap'; const Changeset = require('../../static/js/Changeset'); const attributes = require('../../static/js/attributes'); const exportHtml = require('./ExportHtml'); diff --git a/src/package.json b/src/package.json index 10806bae7..1f896cfc6 100644 --- a/src/package.json +++ b/src/package.json @@ -87,6 +87,7 @@ "@types/formidable": "^3.4.5", "@types/http-errors": "^2.0.4", "@types/jquery": "^3.5.30", + "@types/js-cookie": "^3.0.6", "@types/jsdom": "^21.1.7", "@types/jsonwebtoken": "^9.0.6", "@types/mocha": "^10.0.7", diff --git a/src/static/js/AttributeManager.js b/src/static/js/AttributeManager.ts similarity index 71% rename from src/static/js/AttributeManager.js rename to src/static/js/AttributeManager.ts index 63af431d9..1b4664b0a 100644 --- a/src/static/js/AttributeManager.js +++ b/src/static/js/AttributeManager.ts @@ -1,10 +1,13 @@ 'use strict'; -const AttributeMap = require('./AttributeMap'); +import AttributeMap from './AttributeMap' const Changeset = require('./Changeset'); const ChangesetUtils = require('./ChangesetUtils'); const attributes = require('./attributes'); -const underscore = require("underscore") +import underscore from "underscore"; +import {RepModel} from "./types/RepModel"; +import {RangePos} from "./types/RangePos"; +import {Attribute} from "./types/Attribute"; const lineMarkerAttribute = 'lmkr'; @@ -33,21 +36,20 @@ const lineAttributes = [lineMarkerAttribute, 'list']; - a SkipList `lines` containing the text lines of the document. */ -const AttributeManager = function (rep, applyChangesetCallback) { - this.rep = rep; - this.applyChangesetCallback = applyChangesetCallback; - this.author = ''; +export class AttributeManager { + private readonly rep: RepModel + private readonly applyChangesetCallback: Function + private readonly author: string + public static DEFAULT_LINE_ATTRIBUTES = DEFAULT_LINE_ATTRIBUTES + public static lineAttributes = lineAttributes - // If the first char in a line has one of the following attributes - // it will be considered as a line marker -}; + constructor(rep: RepModel, applyChangesetCallback: Function) { + this.rep = rep; + this.applyChangesetCallback = applyChangesetCallback; + this.author = ''; + } -AttributeManager.DEFAULT_LINE_ATTRIBUTES = DEFAULT_LINE_ATTRIBUTES; -AttributeManager.lineAttributes = lineAttributes; - -AttributeManager.prototype = underscore.default(AttributeManager.prototype).extend({ - - applyChangeset(changeset) { + applyChangeset(changeset: string) { if (!this.applyChangesetCallback) return changeset; const cs = changeset.toString(); @@ -56,15 +58,15 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte } return changeset; - }, + } /* - Sets attributes on a range - @param start [row, col] tuple pointing to the start of the range - @param end [row, col] tuple pointing to the end of the range - @param attribs: an array of attributes - */ - setAttributesOnRange(start, end, attribs) { + Sets attributes on a range + @param start [row, col] tuple pointing to the start of the range + @param end [row, col] tuple pointing to the end of the range + @param attribs: an array of attributes +*/ + setAttributesOnRange(start: RangePos, end: RangePos, attribs: Attribute[]) { if (start[0] < 0) throw new RangeError('selection start line number is negative'); if (start[1] < 0) throw new RangeError('selection start column number is negative'); if (end[0] < 0) throw new RangeError('selection end line number is negative'); @@ -72,36 +74,36 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte if (start[0] > end[0] || (start[0] === end[0] && start[1] > end[1])) { throw new RangeError('selection ends before it starts'); } - // instead of applying the attributes to the whole range at once, we need to apply them // line by line, to be able to disregard the "*" used as line marker. For more details, // see https://github.com/ether/etherpad-lite/issues/2772 let allChangesets; for (let row = start[0]; row <= end[0]; row++) { - const [startCol, endCol] = this._findRowRange(row, start, end); - const rowChangeset = this._setAttributesOnRangeByLine(row, startCol, endCol, attribs); + const [startCol, endCol] = this.findRowRange(row, start, end); + const rowChangeset = this.setAttributesOnRangeByLine(row, startCol, endCol, attribs); // compose changesets of all rows into a single changeset // as the range might not be continuous // due to the presence of line markers on the rows if (allChangesets) { allChangesets = Changeset.compose( - allChangesets.toString(), rowChangeset.toString(), this.rep.apool); + allChangesets.toString(), rowChangeset.toString(), this.rep.apool); } else { allChangesets = rowChangeset; } } return this.applyChangeset(allChangesets); - }, + } - _findRowRange(row, start, end) { + + private findRowRange(row: number, start: RangePos, end: RangePos) { if (row < start[0] || row > end[0]) throw new RangeError(`line ${row} not in selection`); if (row >= this.rep.lines.length()) throw new RangeError(`selected line ${row} does not exist`); // Subtract 1 for the end-of-line '\n' (it is never selected). const lineLength = - this.rep.lines.offsetOfIndex(row + 1) - this.rep.lines.offsetOfIndex(row) - 1; + this.rep.lines.offsetOfIndex(row + 1) - this.rep.lines.offsetOfIndex(row) - 1; const markerWidth = this.lineHasMarker(row) ? 1 : 0; if (lineLength - markerWidth < 0) throw new Error(`line ${row} has negative length`); @@ -115,7 +117,7 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte if (startCol > endCol) throw new RangeError('selection ends before it starts'); return [startCol, endCol]; - }, + } /** * Sets attributes on a range, by line @@ -124,57 +126,60 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte * @param endCol column where range ends (one past the last selected column) * @param attribs an array of attributes */ - _setAttributesOnRangeByLine(row, startCol, endCol, attribs) { - const builder = Changeset.builder(this.rep.lines.totalWidth()); + setAttributesOnRangeByLine(row: number, startCol: number, endCol: number, attribs: Attribute[]) { + const builder = Changeset.builder(this.rep.lines.totalWidth); ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]); ChangesetUtils.buildKeepRange( - this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool); + this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool); return builder; - }, + } /* - Returns if the line already has a line marker - @param lineNum: the number of the line - */ - lineHasMarker(lineNum) { + Returns if the line already has a line marker + @param lineNum: the number of the line +*/ + lineHasMarker(lineNum: number) { return lineAttributes.find( - (attribute) => this.getAttributeOnLine(lineNum, attribute) !== '') !== undefined; - }, + (attribute) => this.getAttributeOnLine(lineNum, attribute) !== '') !== undefined; + } + + + /* Gets a specified attribute on a line @param lineNum: the number of the line to set the attribute for @param attributeKey: the name of the attribute to get, e.g. list */ - getAttributeOnLine(lineNum, attributeName) { + getAttributeOnLine(lineNum: number, attributeName: string) { // get `attributeName` attribute of first char of line const aline = this.rep.alines[lineNum]; if (!aline) return ''; const [op] = Changeset.deserializeOps(aline); if (op == null) return ''; return AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || ''; - }, + } /* - Gets all attributes on a line - @param lineNum: the number of the line to get the attribute for - */ - getAttributesOnLine(lineNum) { + Gets all attributes on a line + @param lineNum: the number of the line to get the attribute for +*/ + getAttributesOnLine(lineNum: number) { // get attributes of first char of line const aline = this.rep.alines[lineNum]; if (!aline) return []; const [op] = Changeset.deserializeOps(aline); if (op == null) return []; return [...attributes.attribsFromString(op.attribs, this.rep.apool)]; - }, + } /* - Gets a given attribute on a selection - @param attributeName - @param prevChar - returns true or false if an attribute is visible in range - */ - getAttributeOnSelection(attributeName, prevChar) { + Gets a given attribute on a selection + @param attributeName + @param prevChar + returns true or false if an attribute is visible in range +*/ + getAttributeOnSelection(attributeName: string, prevChar?: string) { const rep = this.rep; if (!(rep.selStart && rep.selEnd)) return; // If we're looking for the caret attribute not the selection @@ -191,16 +196,16 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); - const hasIt = (attribs) => withItRegex.test(attribs); + const hasIt = (attribs: string) => withItRegex.test(attribs); - const rangeHasAttrib = (selStart, selEnd) => { + const rangeHasAttrib = (selStart: RangePos, selEnd: RangePos):boolean => { // if range is collapsed -> no attribs in range if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false; if (selStart[0] !== selEnd[0]) { // -> More than one line selected // from selStart to the end of the first line let hasAttrib = rangeHasAttrib( - selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]); + selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]); // for all lines in between for (let n = selStart[0] + 1; n < selEnd[0]; n++) { @@ -238,16 +243,17 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte return hasAttrib; }; return rangeHasAttrib(rep.selStart, rep.selEnd); - }, + } + /* - Gets all attributes at a position containing line number and column - @param lineNumber starting with zero - @param column starting with zero - returns a list of attributes in the format - [ ["key","value"], ["key","value"], ... ] - */ - getAttributesOnPosition(lineNumber, column) { + Gets all attributes at a position containing line number and column + @param lineNumber starting with zero + @param column starting with zero + returns a list of attributes in the format + [ ["key","value"], ["key","value"], ... ] +*/ + getAttributesOnPosition(lineNumber: number, column: number) { // get all attributes of the line const aline = this.rep.alines[lineNumber]; @@ -264,7 +270,7 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte return [...attributes.attribsFromString(currentOperation.attribs, this.rep.apool)]; } return []; - }, + } /* Gets all attributes at caret position @@ -274,18 +280,18 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte */ getAttributesOnCaret() { return this.getAttributesOnPosition(this.rep.selStart[0], this.rep.selStart[1]); - }, + } /* - Sets a specified attribute on a line - @param lineNum: the number of the line to set the attribute for - @param attributeKey: the name of the attribute to set, e.g. list - @param attributeValue: an optional parameter to pass to the attribute (e.g. indention level) + Sets a specified attribute on a line + @param lineNum: the number of the line to set the attribute for + @param attributeKey: the name of the attribute to set, e.g. list + @param attributeValue: an optional parameter to pass to the attribute (e.g. indention level) - */ - setAttributeOnLine(lineNum, attributeName, attributeValue) { +*/ + setAttributeOnLine(lineNum: number, attributeName: string, attributeValue: string) { let loc = [0, 0]; - const builder = Changeset.builder(this.rep.lines.totalWidth()); + const builder = Changeset.builder(this.rep.lines.totalWidth); const hasMarker = this.lineHasMarker(lineNum); ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0])); @@ -305,7 +311,7 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte } return this.applyChangeset(builder); - }, + } /** * Removes a specified attribute on a line @@ -313,8 +319,8 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte * @param attributeName the name of the attribute to remove, e.g. list * @param attributeValue if given only attributes with equal value will be removed */ - removeAttributeOnLine(lineNum, attributeName, attributeValue) { - const builder = Changeset.builder(this.rep.lines.totalWidth()); + removeAttributeOnLine(lineNum: number, attributeName: string, attributeValue?: string) { + const builder = Changeset.builder(this.rep.lines.totalWidth); const hasMarker = this.lineHasMarker(lineNum); let found = false; @@ -336,34 +342,35 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]); const countAttribsWithMarker = underscore.chain(attribs).filter((a) => !!a[1]) - .map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).size().value(); + .map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).size().value(); // if we have marker and any of attributes don't need to have marker. we need delete it if (hasMarker && !countAttribsWithMarker) { ChangesetUtils.buildRemoveRange(this.rep, builder, [lineNum, 0], [lineNum, 1]); } else { ChangesetUtils.buildKeepRange( - this.rep, builder, [lineNum, 0], [lineNum, 1], attribs, this.rep.apool); + this.rep, builder, [lineNum, 0], [lineNum, 1], attribs, this.rep.apool); } return this.applyChangeset(builder); - }, + } /* - Toggles a line attribute for the specified line number - If a line attribute with the specified name exists with any value it will be removed - Otherwise it will be set to the given value - @param lineNum: the number of the line to toggle the attribute for - @param attributeKey: the name of the attribute to toggle, e.g. list - @param attributeValue: the value to pass to the attribute (e.g. indention level) - */ - toggleAttributeOnLine(lineNum, attributeName, attributeValue) { + Toggles a line attribute for the specified line number + If a line attribute with the specified name exists with any value it will be removed + Otherwise it will be set to the given value + @param lineNum: the number of the line to toggle the attribute for + @param attributeKey: the name of the attribute to toggle, e.g. list + @param attributeValue: the value to pass to the attribute (e.g. indention level) +*/ + toggleAttributeOnLine(lineNum: number, attributeName: string, attributeValue: string) { return this.getAttributeOnLine(lineNum, attributeName) ? this.removeAttributeOnLine(lineNum, attributeName) : this.setAttributeOnLine(lineNum, attributeName, attributeValue); - }, + } - hasAttributeOnSelectionOrCaretPosition(attributeName) { + + hasAttributeOnSelectionOrCaretPosition(attributeName: string) { const hasSelection = ( (this.rep.selStart[0] !== this.rep.selEnd[0]) || (this.rep.selEnd[1] !== this.rep.selStart[1]) ); @@ -372,11 +379,12 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte hasAttrib = this.getAttributeOnSelection(attributeName); } else { const attributesOnCaretPosition = this.getAttributesOnCaret(); - const allAttribs = [].concat(...attributesOnCaretPosition); // flatten + const allAttribs = [].concat(...attributesOnCaretPosition) as string[]; // flatten hasAttrib = allAttribs.includes(attributeName); } return hasAttrib; - }, -}); + } +} -module.exports = AttributeManager; + +export default AttributeManager diff --git a/src/static/js/AttributeMap.js b/src/static/js/AttributeMap.ts similarity index 82% rename from src/static/js/AttributeMap.js rename to src/static/js/AttributeMap.ts index 55640eb8b..4bdbfd9b0 100644 --- a/src/static/js/AttributeMap.js +++ b/src/static/js/AttributeMap.ts @@ -1,5 +1,7 @@ 'use strict'; +import AttributePool from "./AttributePool"; + const attributes = require('./attributes'); /** @@ -21,6 +23,7 @@ const attributes = require('./attributes'); * Convenience class to convert an Op's attribute string to/from a Map of key, value pairs. */ class AttributeMap extends Map { + private readonly pool? : AttributePool|null /** * Converts an attribute string into an AttributeMap. * @@ -28,14 +31,14 @@ class AttributeMap extends Map { * @param {AttributePool} pool - Attribute pool. * @returns {AttributeMap} */ - static fromString(str, pool) { + public static fromString(str: string, pool: AttributePool): AttributeMap { return new AttributeMap(pool).updateFromString(str); } /** * @param {AttributePool} pool - Attribute pool. */ - constructor(pool) { + constructor(pool?: AttributePool|null) { super(); /** @public */ this.pool = pool; @@ -46,10 +49,10 @@ class AttributeMap extends Map { * @param {string} v - Attribute value. * @returns {AttributeMap} `this` (for chaining). */ - set(k, v) { + set(k: string, v: string):this { k = k == null ? '' : String(k); v = v == null ? '' : String(v); - this.pool.putAttrib([k, v]); + this.pool!.putAttrib([k, v]); return super.set(k, v); } @@ -63,7 +66,7 @@ class AttributeMap extends Map { * key is removed from this map (if present). * @returns {AttributeMap} `this` (for chaining). */ - update(entries, emptyValueIsDelete = false) { + update(entries: Iterable<[string, string]>, emptyValueIsDelete: boolean = false): AttributeMap { for (let [k, v] of entries) { k = k == null ? '' : String(k); v = v == null ? '' : String(v); @@ -83,9 +86,9 @@ class AttributeMap extends Map { * key is removed from this map (if present). * @returns {AttributeMap} `this` (for chaining). */ - updateFromString(str, emptyValueIsDelete = false) { + updateFromString(str: string, emptyValueIsDelete: boolean = false): AttributeMap { return this.update(attributes.attribsFromString(str, this.pool), emptyValueIsDelete); } } -module.exports = AttributeMap; +export default AttributeMap diff --git a/src/static/js/AttributePool.js b/src/static/js/AttributePool.ts similarity index 91% rename from src/static/js/AttributePool.js rename to src/static/js/AttributePool.ts index ccdd2eb35..5bbe52122 100644 --- a/src/static/js/AttributePool.js +++ b/src/static/js/AttributePool.ts @@ -44,6 +44,8 @@ * @property {number} nextNum - The attribute ID to assign to the next new attribute. */ +import {Attribute} from "./types/Attribute"; + /** * Represents an attribute pool, which is a collection of attributes (pairs of key and value * strings) along with their identifiers (non-negative integers). @@ -55,6 +57,14 @@ * in the pad. */ class AttributePool { + numToAttrib: { + [key: number]: [string, string] + } + private attribToNum: { + [key: number]: [string, string] + } + private nextNum: number + constructor() { /** * Maps an attribute identifier to the attribute's `[key, value]` string pair. @@ -96,7 +106,10 @@ class AttributePool { */ clone() { const c = new AttributePool(); - for (const [n, a] of Object.entries(this.numToAttrib)) c.numToAttrib[n] = [a[0], a[1]]; + for (const [n, a] of Object.entries(this.numToAttrib)){ + // @ts-ignore + c.numToAttrib[n] = [a[0], a[1]]; + } Object.assign(c.attribToNum, this.attribToNum); c.nextNum = this.nextNum; return c; @@ -111,15 +124,17 @@ class AttributePool { * membership in the pool without mutating the pool. * @returns {number} The attribute's identifier, or -1 if the attribute is not in the pool. */ - putAttrib(attrib, dontAddIfAbsent = false) { + putAttrib(attrib: Attribute, dontAddIfAbsent = false) { const str = String(attrib); if (str in this.attribToNum) { + // @ts-ignore return this.attribToNum[str]; } if (dontAddIfAbsent) { return -1; } const num = this.nextNum++; + // @ts-ignore this.attribToNum[str] = num; this.numToAttrib[num] = [String(attrib[0] || ''), String(attrib[1] || '')]; return num; @@ -130,7 +145,7 @@ class AttributePool { * @returns {Attribute} The attribute with the given identifier, or nullish if there is no such * attribute. */ - getAttrib(num) { + getAttrib(num: number): Attribute { const pair = this.numToAttrib[num]; if (!pair) { return pair; @@ -143,7 +158,7 @@ class AttributePool { * @returns {string} Eqivalent to `getAttrib(num)[0]` if the attribute exists, otherwise the empty * string. */ - getAttribKey(num) { + getAttribKey(num: number): string { const pair = this.numToAttrib[num]; if (!pair) return ''; return pair[0]; @@ -154,7 +169,7 @@ class AttributePool { * @returns {string} Eqivalent to `getAttrib(num)[1]` if the attribute exists, otherwise the empty * string. */ - getAttribValue(num) { + getAttribValue(num: number) { const pair = this.numToAttrib[num]; if (!pair) return ''; return pair[1]; @@ -166,8 +181,8 @@ class AttributePool { * @param {Function} func - Callback to call with two arguments: key and value. Its return value * is ignored. */ - eachAttrib(func) { - for (const n of Object.keys(this.numToAttrib)) { + eachAttrib(func: (k: string, v: string)=>void) { + for (const n in this.numToAttrib) { const pair = this.numToAttrib[n]; func(pair[0], pair[1]); } @@ -196,11 +211,12 @@ class AttributePool { * `new AttributePool().fromJsonable(pool.toJsonable())` to copy because the resulting shared * state will lead to pool corruption. */ - fromJsonable(obj) { + fromJsonable(obj: this) { this.numToAttrib = obj.numToAttrib; this.nextNum = obj.nextNum; this.attribToNum = {}; for (const n of Object.keys(this.numToAttrib)) { + // @ts-ignore this.attribToNum[String(this.numToAttrib[n])] = Number(n); } return this; @@ -213,6 +229,7 @@ class AttributePool { if (!Number.isInteger(this.nextNum)) throw new Error('nextNum property is not an integer'); if (this.nextNum < 0) throw new Error('nextNum property is negative'); for (const prop of ['numToAttrib', 'attribToNum']) { + // @ts-ignore const obj = this[prop]; if (obj == null) throw new Error(`${prop} property is null`); if (typeof obj !== 'object') throw new TypeError(`${prop} property is not an object`); @@ -231,9 +248,10 @@ class AttributePool { if (v == null) throw new TypeError(`attrib ${i} value is null`); if (typeof v !== 'string') throw new TypeError(`attrib ${i} value is not a string`); const attrStr = String(attr); + // @ts-ignore if (this.attribToNum[attrStr] !== i) throw new Error(`attribToNum for ${attrStr} !== ${i}`); } } } -module.exports = AttributePool; +export default AttributePool diff --git a/src/static/js/Changeset.js b/src/static/js/Changeset.ts similarity index 82% rename from src/static/js/Changeset.js rename to src/static/js/Changeset.ts index 53b3f2c8f..e4b5871f2 100644 --- a/src/static/js/Changeset.js +++ b/src/static/js/Changeset.ts @@ -22,10 +22,15 @@ * https://github.com/ether/pad/blob/master/infrastructure/ace/www/easysync2.js */ -const AttributeMap = require('./AttributeMap'); -const AttributePool = require('./AttributePool'); -const attributes = require('./attributes'); -const {padutils} = require('./pad_utils'); +import AttributeMap from './AttributeMap' +import AttributePool from "./AttributePool"; +import {} from './attributes'; +import {padUtils as padutils} from "./pad_utils"; +import Op from './Op' +import {numToString, parseNum} from './ChangesetUtils' +import {StringAssembler} from "./StringAssembler"; +import {OpIter} from "./OpIter"; +import {Attribute} from "./types/Attribute"; /** * A `[key, value]` pair of strings describing a text attribute. @@ -47,8 +52,9 @@ const {padutils} = require('./pad_utils'); * * @param {string} msg - Just some message */ -const error = (msg) => { +const error = (msg: string) => { const e = new Error(msg); + // @ts-ignore e.easysync = true; throw e; }; @@ -61,96 +67,10 @@ const error = (msg) => { * @param {string} msg - error message to include in the exception * @type {(b: boolean, msg: string) => asserts b} */ -const assert = (b, msg) => { +export const assert: (b: boolean, msg: string) => asserts b = (b: boolean, msg: string): asserts b => { if (!b) error(`Failed assertion: ${msg}`); }; -/** - * Parses a number from string base 36. - * - * @param {string} str - string of the number in base 36 - * @returns {number} number - */ -exports.parseNum = (str) => parseInt(str, 36); - -/** - * Writes a number in base 36 and puts it in a string. - * - * @param {number} num - number - * @returns {string} string - */ -exports.numToString = (num) => num.toString(36).toLowerCase(); - -/** - * An operation to apply to a shared document. - */ -class Op { - /** - * @param {(''|'='|'+'|'-')} [opcode=''] - Initial value of the `opcode` property. - */ - constructor(opcode = '') { - /** - * The operation's operator: - * - '=': Keep the next `chars` characters (containing `lines` newlines) from the base - * document. - * - '-': Remove the next `chars` characters (containing `lines` newlines) from the base - * document. - * - '+': Insert `chars` characters (containing `lines` newlines) at the current position in - * the document. The inserted characters come from the changeset's character bank. - * - '' (empty string): Invalid operator used in some contexts to signifiy the lack of an - * operation. - * - * @type {(''|'='|'+'|'-')} - * @public - */ - this.opcode = opcode; - - /** - * The number of characters to keep, insert, or delete. - * - * @type {number} - * @public - */ - this.chars = 0; - - /** - * The number of characters among the `chars` characters that are newlines. If non-zero, the - * last character must be a newline. - * - * @type {number} - * @public - */ - this.lines = 0; - - /** - * Identifiers of attributes to apply to the text, represented as a repeated (zero or more) - * sequence of asterisk followed by a non-negative base-36 (lower-case) integer. For example, - * '*2*1o' indicates that attributes 2 and 60 apply to the text affected by the operation. The - * identifiers come from the document's attribute pool. - * - * For keep ('=') operations, the attributes are merged with the base text's existing - * attributes: - * - A keep op attribute with a non-empty value replaces an existing base text attribute that - * has the same key. - * - A keep op attribute with an empty value is interpreted as an instruction to remove an - * existing base text attribute that has the same key, if one exists. - * - * This is the empty string for remove ('-') operations. - * - * @type {string} - * @public - */ - this.attribs = ''; - } - - toString() { - if (!this.opcode) throw new TypeError('null op'); - if (typeof this.attribs !== 'string') throw new TypeError('attribs must be a string'); - const l = this.lines ? `|${exports.numToString(this.lines)}` : ''; - return this.attribs + l + this.opcode + exports.numToString(this.chars); - } -} -exports.Op = Op; /** * Describes changes to apply to a document. Does not include the attribute pool or the original @@ -170,7 +90,7 @@ exports.Op = Op; * @param {string} cs - String representation of the Changeset * @returns {number} oldLen property */ -exports.oldLen = (cs) => exports.unpack(cs).oldLen; +export const oldLen = (cs: string) => unpack(cs).oldLen /** * Returns the length of the text after changeset is applied. @@ -178,7 +98,7 @@ exports.oldLen = (cs) => exports.unpack(cs).oldLen; * @param {string} cs - String representation of the Changeset * @returns {number} newLen property */ -exports.newLen = (cs) => exports.unpack(cs).newLen; +export const newLen = (cs: string) => unpack(cs).newLen /** * Parses a string of serialized changeset operations. @@ -187,63 +107,23 @@ exports.newLen = (cs) => exports.unpack(cs).newLen; * @yields {Op} * @returns {Generator} */ -exports.deserializeOps = function* (ops) { +export const deserializeOps = function* (ops: string) { // TODO: Migrate to String.prototype.matchAll() once there is enough browser support. const regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|(.)/g; let match; while ((match = regex.exec(ops)) != null) { if (match[5] === '$') return; // Start of the insert operation character bank. if (match[5] != null) error(`invalid operation: ${ops.slice(regex.lastIndex - 1)}`); - const op = new Op(match[3]); - op.lines = exports.parseNum(match[2] || '0'); - op.chars = exports.parseNum(match[4]); + const opMatch = match[3] as ""|"=" | "+" | "-" | undefined + const op = new Op(opMatch); + op.lines = parseNum(match[2] || '0'); + op.chars = parseNum(match[4]); op.attribs = match[1]; yield op; } }; -/** - * Iterator over a changeset's operations. - * - * Note: This class does NOT implement the ECMAScript iterable or iterator protocols. - * - * @deprecated Use `deserializeOps` instead. - */ -class OpIter { - /** - * @param {string} ops - String encoding the change operations to iterate over. - */ - constructor(ops) { - this._gen = exports.deserializeOps(ops); - this._next = this._gen.next(); - } - /** - * @returns {boolean} Whether there are any remaining operations. - */ - hasNext() { - return !this._next.done; - } - - /** - * Returns the next operation object and advances the iterator. - * - * Note: This does NOT implement the ECMAScript iterator protocol. - * - * @param {Op} [opOut] - Deprecated. Operation object to recycle for the return value. - * @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are - * no more operations. - */ - next(opOut = new Op()) { - if (this.hasNext()) { - copyOp(this._next.value, opOut); - this._next = this._gen.next(); - } else { - clearOp(opOut); - } - return opOut; - } -} /** * Creates an iterator which decodes string changeset operations. @@ -252,7 +132,7 @@ class OpIter { * @param {string} opsStr - String encoding of the change operations to perform. * @returns {OpIter} Operator iterator object. */ -exports.opIterator = (opsStr) => { +export const opIterator = (opsStr: string) => { padutils.warnDeprecated( 'Changeset.opIterator() is deprecated; use Changeset.deserializeOps() instead'); return new OpIter(opsStr); @@ -263,7 +143,7 @@ exports.opIterator = (opsStr) => { * * @param {Op} op - object to clear */ -const clearOp = (op) => { +export const clearOp = (op: Op) => { op.opcode = ''; op.chars = 0; op.lines = 0; @@ -277,7 +157,7 @@ const clearOp = (op) => { * @param {('+'|'-'|'='|'')} [optOpcode=''] - The operation's operator. * @returns {Op} */ -exports.newOp = (optOpcode) => { +export const newOp = (optOpcode:'+'|'-'|'='|'' ): Op => { padutils.warnDeprecated('Changeset.newOp() is deprecated; use the Changeset.Op class instead'); return new Op(optOpcode); }; @@ -289,7 +169,7 @@ exports.newOp = (optOpcode) => { * @param {Op} [op2] - dest Op. If not given, a new Op is used. * @returns {Op} `op2` */ -const copyOp = (op1, op2 = new Op()) => Object.assign(op2, op1); +export const copyOp = (op1: Op, op2: Op = new Op()): Op => Object.assign(op2, op1); /** * Serializes a sequence of Ops. @@ -320,12 +200,12 @@ const copyOp = (op1, op2 = new Op()) => Object.assign(op2, op1); * (if necessary) and encode. If an attribute string, no checking is performed to ensure that * the attributes exist in the pool, are in the canonical order, and contain no duplicate keys. * If this is an iterable of attributes, `pool` must be non-null. - * @param {?AttributePool} pool - Attribute pool. Required if `attribs` is an iterable of + * @param {?AttributePool.ts} pool - Attribute pool. Required if `attribs` is an iterable of * attributes, ignored if `attribs` is an attribute string. * @yields {Op} One or two ops (depending on the presense of newlines) that cover the given text. * @returns {Generator} */ -const opsFromText = function* (opcode, text, attribs = '', pool = null) { +export const opsFromText = function* (opcode: "" | "=" | "+" | "-" | undefined, text: string, attribs: string|Attribute[] = '', pool: AttributePool|null = null) { const op = new Op(opcode); op.attribs = typeof attribs === 'string' ? attribs : new AttributeMap(pool).update(attribs || [], opcode === '+').toString(); @@ -336,7 +216,7 @@ const opsFromText = function* (opcode, text, attribs = '', pool = null) { yield op; } else { op.chars = lastNewlinePos + 1; - op.lines = text.match(/\n/g).length; + op.lines = text.match(/\n/g)!.length; yield op; const op2 = copyOp(op); op2.chars = text.length - (lastNewlinePos + 1); @@ -345,23 +225,7 @@ const opsFromText = function* (opcode, text, attribs = '', pool = null) { } }; -/** - * Creates an object that allows you to append operations (type Op) and also compresses them if - * possible. Like MergingOpAssembler, but able to produce conforming exportss from slightly looser - * input, at the cost of speed. Specifically: - * - merges consecutive operations that can be merged - * - strips final "=" - * - ignores 0-length changes - * - reorders consecutive + and - (which MergingOpAssembler doesn't do) - * - * @typedef {object} SmartOpAssembler - * @property {Function} append - - * @property {Function} appendOpWithText - - * @property {Function} clear - - * @property {Function} endDocument - - * @property {Function} getLengthChange - - * @property {Function} toString - - */ + /** * Used to check if a Changeset is valid. This function does not check things that require access to @@ -370,7 +234,7 @@ const opsFromText = function* (opcode, text, attribs = '', pool = null) { * @param {string} cs - Changeset to check * @returns {string} the checked Changeset */ -exports.checkRep = (cs) => { +export const checkRep = (cs: string) => { const unpacked = exports.unpack(cs); const oldLen = unpacked.oldLen; const newLen = unpacked.newLen; @@ -418,254 +282,6 @@ exports.checkRep = (cs) => { return cs; }; -/** - * @returns {SmartOpAssembler} - */ -exports.smartOpAssembler = () => { - const minusAssem = exports.mergingOpAssembler(); - const plusAssem = exports.mergingOpAssembler(); - const keepAssem = exports.mergingOpAssembler(); - const assem = exports.stringAssembler(); - let lastOpcode = ''; - let lengthChange = 0; - - const flushKeeps = () => { - assem.append(keepAssem.toString()); - keepAssem.clear(); - }; - - const flushPlusMinus = () => { - assem.append(minusAssem.toString()); - minusAssem.clear(); - assem.append(plusAssem.toString()); - plusAssem.clear(); - }; - - const append = (op) => { - if (!op.opcode) return; - if (!op.chars) return; - - if (op.opcode === '-') { - if (lastOpcode === '=') { - flushKeeps(); - } - minusAssem.append(op); - lengthChange -= op.chars; - } else if (op.opcode === '+') { - if (lastOpcode === '=') { - flushKeeps(); - } - plusAssem.append(op); - lengthChange += op.chars; - } else if (op.opcode === '=') { - if (lastOpcode !== '=') { - flushPlusMinus(); - } - keepAssem.append(op); - } - lastOpcode = op.opcode; - }; - - /** - * Generates operations from the given text and attributes. - * - * @deprecated Use `opsFromText` instead. - * @param {('-'|'+'|'=')} opcode - The operator to use. - * @param {string} text - The text to remove/add/keep. - * @param {(string|Iterable)} attribs - The attributes to apply to the operations. - * @param {?AttributePool} pool - Attribute pool. Only required if `attribs` is an iterable of - * attribute key, value pairs. - */ - const appendOpWithText = (opcode, text, attribs, pool) => { - padutils.warnDeprecated('Changeset.smartOpAssembler().appendOpWithText() is deprecated; ' + - 'use opsFromText() instead.'); - for (const op of opsFromText(opcode, text, attribs, pool)) append(op); - }; - - const toString = () => { - flushPlusMinus(); - flushKeeps(); - return assem.toString(); - }; - - const clear = () => { - minusAssem.clear(); - plusAssem.clear(); - keepAssem.clear(); - assem.clear(); - lengthChange = 0; - }; - - const endDocument = () => { - keepAssem.endDocument(); - }; - - const getLengthChange = () => lengthChange; - - return { - append, - toString, - clear, - endDocument, - appendOpWithText, - getLengthChange, - }; -}; - -/** - * @returns {MergingOpAssembler} - */ -exports.mergingOpAssembler = () => { - const assem = exports.opAssembler(); - const bufOp = new Op(); - - // If we get, for example, insertions [xxx\n,yyy], those don't merge, - // but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n]. - // This variable stores the length of yyy and any other newline-less - // ops immediately after it. - let bufOpAdditionalCharsAfterNewline = 0; - - /** - * @param {boolean} [isEndDocument] - */ - const flush = (isEndDocument) => { - if (!bufOp.opcode) return; - if (isEndDocument && bufOp.opcode === '=' && !bufOp.attribs) { - // final merged keep, leave it implicit - } else { - assem.append(bufOp); - if (bufOpAdditionalCharsAfterNewline) { - bufOp.chars = bufOpAdditionalCharsAfterNewline; - bufOp.lines = 0; - assem.append(bufOp); - bufOpAdditionalCharsAfterNewline = 0; - } - } - bufOp.opcode = ''; - }; - - const append = (op) => { - if (op.chars <= 0) return; - if (bufOp.opcode === op.opcode && bufOp.attribs === op.attribs) { - if (op.lines > 0) { - // bufOp and additional chars are all mergeable into a multi-line op - bufOp.chars += bufOpAdditionalCharsAfterNewline + op.chars; - bufOp.lines += op.lines; - bufOpAdditionalCharsAfterNewline = 0; - } else if (bufOp.lines === 0) { - // both bufOp and op are in-line - bufOp.chars += op.chars; - } else { - // append in-line text to multi-line bufOp - bufOpAdditionalCharsAfterNewline += op.chars; - } - } else { - flush(); - copyOp(op, bufOp); - } - }; - - const endDocument = () => { - flush(true); - }; - - const toString = () => { - flush(); - return assem.toString(); - }; - - const clear = () => { - assem.clear(); - clearOp(bufOp); - }; - return { - append, - toString, - clear, - endDocument, - }; -}; - -/** - * @returns {OpAssembler} - */ -exports.opAssembler = () => { - let serialized = ''; - - /** - * @param {Op} op - Operation to add. Ownership remains with the caller. - */ - const append = (op) => { - assert(op instanceof Op, 'argument must be an instance of Op'); - serialized += op.toString(); - }; - - const toString = () => serialized; - - const clear = () => { - serialized = ''; - }; - return { - append, - toString, - clear, - }; -}; - -/** - * A custom made String Iterator - * - * @typedef {object} StringIterator - * @property {Function} newlines - - * @property {Function} peek - - * @property {Function} remaining - - * @property {Function} skip - - * @property {Function} take - - */ - -/** - * @param {string} str - String to iterate over - * @returns {StringIterator} - */ -exports.stringIterator = (str) => { - let curIndex = 0; - // newLines is the number of \n between curIndex and str.length - let newLines = str.split('\n').length - 1; - const getnewLines = () => newLines; - - const assertRemaining = (n) => { - assert(n <= remaining(), `!(${n} <= ${remaining()})`); - }; - - const take = (n) => { - assertRemaining(n); - const s = str.substr(curIndex, n); - newLines -= s.split('\n').length - 1; - curIndex += n; - return s; - }; - - const peek = (n) => { - assertRemaining(n); - const s = str.substr(curIndex, n); - return s; - }; - - const skip = (n) => { - assertRemaining(n); - curIndex += n; - }; - - const remaining = () => str.length - curIndex; - return { - take, - skip, - remaining, - peek, - newlines: getnewLines, - }; -}; - /** * A custom made StringBuffer * @@ -674,19 +290,6 @@ exports.stringIterator = (str) => { * @property {Function} toString - */ -/** - * @returns {StringAssembler} - */ -exports.stringAssembler = () => ({ - _str: '', - clear() { this._str = ''; }, - /** - * @param {string} x - - */ - append(x) { this._str += String(x); }, - toString() { return this._str; }, -}); - /** * @typedef {object} StringArrayLike * @property {(i: number) => string} get - Returns the line at index `i`. @@ -1067,9 +670,9 @@ exports.unpack = (cs) => { 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 = exports.parseNum(headerMatch[1]); + const oldLen = parseNum(headerMatch[1]); const changeSign = (headerMatch[2] === '>') ? 1 : -1; - const changeMag = exports.parseNum(headerMatch[3]); + const changeMag = parseNum(headerMatch[3]); const newLen = oldLen + changeSign * changeMag; const opsStart = headerMatch[0].length; let opsEnd = cs.indexOf('$'); @@ -1112,7 +715,7 @@ exports.applyToText = (cs, str) => { assert(str.length === unpacked.oldLen, `mismatched apply: ${str.length} / ${unpacked.oldLen}`); const bankIter = exports.stringIterator(unpacked.charBank); const strIter = exports.stringIterator(str); - const assem = exports.stringAssembler(); + const assem = new StringAssembler(); for (const op of exports.deserializeOps(unpacked.ops)) { switch (op.opcode) { case '+': @@ -1177,7 +780,7 @@ exports.mutateTextLines = (cs, lines) => { * @param {AttributeString} att1 - first attribute string * @param {AttributeString} att2 - second attribue string * @param {boolean} resultIsMutation - - * @param {AttributePool} pool - attribute pool + * @param {AttributePool.ts} pool - attribute pool * @returns {string} */ exports.composeAttributes = (att1, att2, resultIsMutation, pool) => { @@ -1211,7 +814,7 @@ exports.composeAttributes = (att1, att2, resultIsMutation, pool) => { * @param {Op} attOp - The op from the sequence that is being operated on, either an attribution * string or the earlier of two exportss being composed. * @param {Op} csOp - - * @param {AttributePool} pool - Can be null if definitely not needed. + * @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) => { @@ -1272,7 +875,7 @@ const slicerZipperFunc = (attOp, csOp, pool) => { * * @param {string} cs - Changeset * @param {string} astr - the attribs string of a AText - * @param {AttributePool} pool - the attibutes pool + * @param {AttributePool.ts} pool - the attibutes pool * @returns {string} */ exports.applyToAttribution = (cs, astr, pool) => { @@ -1285,7 +888,7 @@ exports.applyToAttribution = (cs, astr, pool) => { * * @param {string} cs - The encoded changeset. * @param {Array} lines - Attribute lines. Modified in place. - * @param {AttributePool} pool - Attribute pool. + * @param {AttributePool.ts} pool - Attribute pool. */ exports.mutateAttributionLines = (cs, lines, pool) => { const unpacked = exports.unpack(cs); @@ -1454,7 +1057,7 @@ exports.splitTextLines = (text) => text.match(/[^\n]*(?:\n|[^\n]$)/g); * * @param {string} cs1 - first Changeset * @param {string} cs2 - second Changeset - * @param {AttributePool} pool - Attribs pool + * @param {AttributePool.ts} pool - Attribs pool * @returns {string} */ exports.compose = (cs1, cs2, pool) => { @@ -1466,7 +1069,7 @@ exports.compose = (cs1, cs2, pool) => { const len3 = unpacked2.newLen; const bankIter1 = exports.stringIterator(unpacked1.charBank); const bankIter2 = exports.stringIterator(unpacked2.charBank); - const bankAssem = exports.stringAssembler(); + const bankAssem = new StringAssembler(); const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { const op1code = op1.opcode; @@ -1493,7 +1096,7 @@ exports.compose = (cs1, cs2, pool) => { * key,value that is already present in the pool. * * @param {Attribute} attribPair - `[key, value]` pair of strings. - * @param {AttributePool} pool - Attribute pool + * @param {AttributePool.ts} pool - Attribute pool * @returns {Function} */ exports.attributeTester = (attribPair, pool) => { @@ -1523,7 +1126,7 @@ exports.identity = (N) => exports.pack(N, N, '', ''); * @param {number} ndel - Number of characters to delete at `start`. * @param {string} ins - Text to insert at `start` (after deleting `ndel` characters). * @param {string} [attribs] - Optional attributes to apply to the inserted text. - * @param {AttributePool} [pool] - Attribute pool. + * @param {AttributePool.ts} [pool] - Attribute pool. * @returns {string} */ exports.makeSplice = (orig, start, ndel, ins, attribs, pool) => { @@ -1646,13 +1249,13 @@ exports.moveOpsToNewPool = (cs, oldPool, newPool) => { const fromDollar = cs.substring(dollarPos); // order of attribs stays the same return upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => { - const oldNum = exports.parseNum(a); + const oldNum = parseNum(a); const pair = oldPool.getAttrib(oldNum); // The attribute might not be in the old pool if the user is viewing the current revision in the // timeslider and text is deleted. See: https://github.com/ether/etherpad-lite/issues/3932 if (!pair) return ''; const newNum = newPool.putAttrib(pair); - return `*${exports.numToString(newNum)}`; + return `*${numToString(newNum)}`; }) + fromDollar; }; @@ -1688,7 +1291,7 @@ exports.eachAttribNumber = (cs, func) => { // WARNING: The following cannot be replaced with a call to `attributes.decodeAttribString()` // because that function only works on attribute strings, not serialized operations or changesets. upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => { - func(exports.parseNum(a)); + func(parseNum(a)); return ''; }); }; @@ -1719,11 +1322,11 @@ exports.mapAttribNumbers = (cs, func) => { const upToDollar = cs.substring(0, dollarPos); const newUpToDollar = upToDollar.replace(/\*([0-9a-z]+)/g, (s, a) => { - const n = func(exports.parseNum(a)); + const n = func(parseNum(a)); if (n === true) { return s; } else if ((typeof n) === 'number') { - return `*${exports.numToString(n)}`; + return `*${numToString(n)}`; } else { return ''; } @@ -1759,7 +1362,7 @@ exports.makeAText = (text, attribs) => ({ * * @param {string} cs - Changeset to apply * @param {AText} atext - - * @param {AttributePool} pool - Attribute Pool to add to + * @param {AttributePool.ts} pool - Attribute Pool to add to * @returns {AText} */ exports.applyToAText = (cs, atext, pool) => ({ @@ -1840,8 +1443,8 @@ exports.appendATextToAssembler = (atext, assem) => { * Creates a clone of a Changeset and it's APool. * * @param {string} cs - - * @param {AttributePool} pool - - * @returns {{translated: string, pool: AttributePool}} + * @param {AttributePool.ts} pool - + * @returns {{translated: string, pool: AttributePool.ts}} */ exports.prepareForWire = (cs, pool) => { const newPool = new AttributePool(); @@ -1880,7 +1483,7 @@ const attribsAttributeValue = (attribs, key, pool) => { * @deprecated Use an AttributeMap instead. * @param {Op} op - Op * @param {string} key - string to search for - * @param {AttributePool} pool - attribute pool + * @param {AttributePool.ts} pool - attribute pool * @returns {string} */ exports.opAttributeValue = (op, key, pool) => { @@ -1895,7 +1498,7 @@ exports.opAttributeValue = (op, key, pool) => { * @deprecated Use an AttributeMap instead. * @param {AttributeString} attribs - Attribute string * @param {string} key - string to search for - * @param {AttributePool} pool - attribute pool + * @param {AttributePool.ts} pool - attribute pool * @returns {string} */ exports.attribsAttributeValue = (attribs, key, pool) => { @@ -1922,7 +1525,7 @@ exports.attribsAttributeValue = (attribs, key, pool) => { exports.builder = (oldLen) => { const assem = exports.smartOpAssembler(); const o = new Op(); - const charBank = exports.stringAssembler(); + const charBank = new StringAssembler(); const self = { /** @@ -1931,7 +1534,7 @@ exports.builder = (oldLen) => { * 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} pool - Attribute pool, only required if `attribs` is a list of + * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of * attribute key, value pairs. * @returns {Builder} this */ @@ -1949,7 +1552,7 @@ exports.builder = (oldLen) => { * @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} pool - Attribute pool, only required if `attribs` is a list of + * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of * attribute key, value pairs. * @returns {Builder} this */ @@ -1962,7 +1565,7 @@ exports.builder = (oldLen) => { * @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} pool - Attribute pool, only required if `attribs` is a list of + * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of * attribute key, value pairs. * @returns {Builder} this */ @@ -2006,7 +1609,7 @@ exports.builder = (oldLen) => { * (if necessary) and encode. If an attribute string, no checking is performed to ensure that * the attributes exist in the pool, are in the canonical order, and contain no duplicate keys. * If this is an iterable of attributes, `pool` must be non-null. - * @param {AttributePool} pool - Attribute pool. Required if `attribs` is an iterable of attributes, + * @param {AttributePool.ts} pool - Attribute pool. Required if `attribs` is an iterable of attributes, * ignored if `attribs` is an attribute string. * @returns {AttributeString} */ @@ -2163,7 +1766,7 @@ exports.inverse = (cs, lines, alines, pool) => { const nextText = (numChars) => { let len = 0; - const assem = exports.stringAssembler(); + const assem = new StringAssembler(); const firstString = linesGet(curLine).substring(curChar); len += firstString.length; assem.append(firstString); @@ -2379,20 +1982,20 @@ const followAttributes = (att1, att2, pool) => { if (!att1) return att2; const atts = new Map(); att2.replace(/\*([0-9a-z]+)/g, (_, a) => { - const [key, val] = pool.getAttrib(exports.parseNum(a)); + const [key, val] = pool.getAttrib(parseNum(a)); atts.set(key, val); return ''; }); att1.replace(/\*([0-9a-z]+)/g, (_, a) => { - const [key, val] = pool.getAttrib(exports.parseNum(a)); + const [key, val] = pool.getAttrib(parseNum(a)); if (atts.has(key) && val <= atts.get(key)) atts.delete(key); return ''; }); // we've only removed attributes, so they're already sorted - const buf = exports.stringAssembler(); + const buf = new StringAssembler(); for (const att of atts) { buf.append('*'); - buf.append(exports.numToString(pool.putAttrib(att))); + buf.append(numToString(pool.putAttrib(att))); } return buf.toString(); }; diff --git a/src/static/js/ChangesetUtils.js b/src/static/js/ChangesetUtils.ts similarity index 60% rename from src/static/js/ChangesetUtils.js rename to src/static/js/ChangesetUtils.ts index ef2be2ebe..ad8b13c3c 100644 --- a/src/static/js/ChangesetUtils.js +++ b/src/static/js/ChangesetUtils.ts @@ -5,6 +5,11 @@ * based on a SkipList */ +import {RepModel} from "./types/RepModel"; +import {ChangeSetBuilder} from "./types/ChangeSetBuilder"; +import {Attribute} from "./types/Attribute"; +import AttributePool from "./AttributePool"; + /** * Copyright 2009 Google Inc. * @@ -20,7 +25,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -exports.buildRemoveRange = (rep, builder, start, end) => { +export const buildRemoveRange = (rep: RepModel, builder: ChangeSetBuilder, start: [number,number], end: [number, number]) => { const startLineOffset = rep.lines.offsetOfIndex(start[0]); const endLineOffset = rep.lines.offsetOfIndex(end[0]); @@ -32,7 +37,7 @@ exports.buildRemoveRange = (rep, builder, start, end) => { } }; -exports.buildKeepRange = (rep, builder, start, end, attribs, pool) => { +export const buildKeepRange = (rep: RepModel, builder: ChangeSetBuilder, start: [number, number], end:[number, number], attribs?: Attribute[], pool?: AttributePool) => { const startLineOffset = rep.lines.offsetOfIndex(start[0]); const endLineOffset = rep.lines.offsetOfIndex(end[0]); @@ -44,9 +49,25 @@ exports.buildKeepRange = (rep, builder, start, end, attribs, pool) => { } }; -exports.buildKeepToStartOfRange = (rep, builder, start) => { +export const buildKeepToStartOfRange = (rep: RepModel, builder: ChangeSetBuilder, start: [number, number]) => { const startLineOffset = rep.lines.offsetOfIndex(start[0]); builder.keep(startLineOffset, start[0]); builder.keep(start[1]); }; + +/** + * Parses a number from string base 36. + * + * @param {string} str - string of the number in base 36 + * @returns {number} number + */ +export const parseNum = (str: string) => parseInt(str, 36); + +/** + * Writes a number in base 36 and puts it in a string. + * + * @param {number} num - number + * @returns {string} string + */ +export const numToString = (num: number): string => num.toString(36).toLowerCase(); diff --git a/src/static/js/ChatMessage.js b/src/static/js/ChatMessage.ts similarity index 68% rename from src/static/js/ChatMessage.js rename to src/static/js/ChatMessage.ts index a627f88f9..294057bec 100644 --- a/src/static/js/ChatMessage.js +++ b/src/static/js/ChatMessage.ts @@ -1,6 +1,6 @@ 'use strict'; -const {padutils: {warnDeprecated}} = require('./pad_utils'); +import {padUtils} from './pad_utils' /** * Represents a chat message stored in the database and transmitted among users. Plugins can extend @@ -9,13 +9,24 @@ const {padutils: {warnDeprecated}} = require('./pad_utils'); * Supports serialization to JSON. */ class ChatMessage { - static fromObject(obj) { + + private text: string|null + private authorId: string|null + private displayName: string|null + private time: number|null + static fromObject(obj: ChatMessage) { // The userId property was renamed to authorId, and userName was renamed to displayName. Accept // the old names in case the db record was written by an older version of Etherpad. obj = Object.assign({}, obj); // Don't mutate the caller's object. - if ('userId' in obj && !('authorId' in obj)) obj.authorId = obj.userId; + if ('userId' in obj && !('authorId' in obj)) { // @ts-ignore + obj.authorId = obj.userId; + } + // @ts-ignore delete obj.userId; - if ('userName' in obj && !('displayName' in obj)) obj.displayName = obj.userName; + if ('userName' in obj && !('displayName' in obj)) { // @ts-ignore + obj.displayName = obj.userName; + } + // @ts-ignore delete obj.userName; return Object.assign(new ChatMessage(), obj); } @@ -25,7 +36,7 @@ class ChatMessage { * @param {?string} [authorId] - Initial value of the `authorId` property. * @param {?number} [time] - Initial value of the `time` property. */ - constructor(text = null, authorId = null, time = null) { + constructor(text: string | null = null, authorId: string | null = null, time: number | null = null) { /** * The raw text of the user's chat message (before any rendering or processing). * @@ -62,11 +73,11 @@ class ChatMessage { * @type {string} */ get userId() { - warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); + padUtils.warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); return this.authorId; } set userId(val) { - warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); + padUtils.warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); this.authorId = val; } @@ -77,11 +88,11 @@ class ChatMessage { * @type {string} */ get userName() { - warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); + padUtils.warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); return this.displayName; } set userName(val) { - warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); + padUtils.warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); this.displayName = val; } @@ -89,7 +100,9 @@ class ChatMessage { // doesn't support authorId and displayName. toJSON() { const {authorId, displayName, ...obj} = this; + // @ts-ignore obj.userId = authorId; + // @ts-ignore obj.userName = displayName; return obj; } diff --git a/src/static/js/MergingOpAssembler.ts b/src/static/js/MergingOpAssembler.ts new file mode 100644 index 000000000..4d0b84146 --- /dev/null +++ b/src/static/js/MergingOpAssembler.ts @@ -0,0 +1,73 @@ +import {OpAssembler} from "./OpAssembler"; +import Op from "./Op"; +import {clearOp, copyOp} from "./Changeset"; + +export class MergingOpAssembler { + private assem: OpAssembler; + private readonly bufOp: Op; + private bufOpAdditionalCharsAfterNewline: number; + + constructor() { + this.assem = new OpAssembler() + this.bufOp = new Op() + // If we get, for example, insertions [xxx\n,yyy], those don't merge, + // but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n]. + // This variable stores the length of yyy and any other newline-less + // ops immediately after it. + this.bufOpAdditionalCharsAfterNewline = 0; + } + + /** + * @param {boolean} [isEndDocument] + */ + flush = (isEndDocument?: boolean) => { + if (!this.bufOp.opcode) return; + if (isEndDocument && this.bufOp.opcode === '=' && !this.bufOp.attribs) { + // final merged keep, leave it implicit + } else { + this.assem.append(this.bufOp); + if (this.bufOpAdditionalCharsAfterNewline) { + this.bufOp.chars = this.bufOpAdditionalCharsAfterNewline; + this.bufOp.lines = 0; + this.assem.append(this.bufOp); + this.bufOpAdditionalCharsAfterNewline = 0; + } + } + this.bufOp.opcode = ''; + } + + append = (op: Op) => { + if (op.chars <= 0) return; + if (this.bufOp.opcode === op.opcode && this.bufOp.attribs === op.attribs) { + if (op.lines > 0) { + // bufOp and additional chars are all mergeable into a multi-line op + this.bufOp.chars += this.bufOpAdditionalCharsAfterNewline + op.chars; + this.bufOp.lines += op.lines; + this.bufOpAdditionalCharsAfterNewline = 0; + } else if (this.bufOp.lines === 0) { + // both bufOp and op are in-line + this.bufOp.chars += op.chars; + } else { + // append in-line text to multi-line bufOp + this.bufOpAdditionalCharsAfterNewline += op.chars; + } + } else { + this.flush(); + copyOp(op, this.bufOp); + } + } + + endDocument = () => { + this.flush(true); + }; + + toString = () => { + this.flush(); + return this.assem.toString(); + }; + + clear = () => { + this.assem.clear(); + clearOp(this.bufOp); + }; +} diff --git a/src/static/js/Op.ts b/src/static/js/Op.ts new file mode 100644 index 000000000..73233027e --- /dev/null +++ b/src/static/js/Op.ts @@ -0,0 +1,73 @@ +/** + * An operation to apply to a shared document. + */ +export default class Op { + opcode: ''|'='|'+'|'-' + chars: number + lines: number + attribs: string + /** + * @param {(''|'='|'+'|'-')} [opcode=''] - Initial value of the `opcode` property. + */ + constructor(opcode:''|'='|'+'|'-' = '') { + /** + * The operation's operator: + * - '=': Keep the next `chars` characters (containing `lines` newlines) from the base + * document. + * - '-': Remove the next `chars` characters (containing `lines` newlines) from the base + * document. + * - '+': Insert `chars` characters (containing `lines` newlines) at the current position in + * the document. The inserted characters come from the changeset's character bank. + * - '' (empty string): Invalid operator used in some contexts to signifiy the lack of an + * operation. + * + * @type {(''|'='|'+'|'-')} + * @public + */ + this.opcode = opcode; + + /** + * The number of characters to keep, insert, or delete. + * + * @type {number} + * @public + */ + this.chars = 0; + + /** + * The number of characters among the `chars` characters that are newlines. If non-zero, the + * last character must be a newline. + * + * @type {number} + * @public + */ + this.lines = 0; + + /** + * Identifiers of attributes to apply to the text, represented as a repeated (zero or more) + * sequence of asterisk followed by a non-negative base-36 (lower-case) integer. For example, + * '*2*1o' indicates that attributes 2 and 60 apply to the text affected by the operation. The + * identifiers come from the document's attribute pool. + * + * For keep ('=') operations, the attributes are merged with the base text's existing + * attributes: + * - A keep op attribute with a non-empty value replaces an existing base text attribute that + * has the same key. + * - A keep op attribute with an empty value is interpreted as an instruction to remove an + * existing base text attribute that has the same key, if one exists. + * + * This is the empty string for remove ('-') operations. + * + * @type {string} + * @public + */ + this.attribs = ''; + } + + toString() { + if (!this.opcode) throw new TypeError('null op'); + if (typeof this.attribs !== 'string') throw new TypeError('attribs must be a string'); + const l = this.lines ? `|${exports.numToString(this.lines)}` : ''; + return this.attribs + l + this.opcode + exports.numToString(this.chars); + } +} diff --git a/src/static/js/OpAssembler.ts b/src/static/js/OpAssembler.ts new file mode 100644 index 000000000..2c3549655 --- /dev/null +++ b/src/static/js/OpAssembler.ts @@ -0,0 +1,21 @@ +import Op from "./Op"; +import {assert} from './Changeset' + +/** + * @returns {OpAssembler} + */ +export class OpAssembler { + private serialized: string; + constructor() { + this.serialized = '' + + } + append = (op: Op) => { + assert(op instanceof Op, 'argument must be an instance of Op'); + this.serialized += op.toString(); + } + toString = () => this.serialized + clear = () => { + this.serialized = ''; + } +} diff --git a/src/static/js/OpIter.ts b/src/static/js/OpIter.ts new file mode 100644 index 000000000..18a63a686 --- /dev/null +++ b/src/static/js/OpIter.ts @@ -0,0 +1,45 @@ +import Op from "./Op"; + +/** + * Iterator over a changeset's operations. + * + * Note: This class does NOT implement the ECMAScript iterable or iterator protocols. + * + * @deprecated Use `deserializeOps` instead. + */ +export class OpIter { + private gen + /** + * @param {string} ops - String encoding the change operations to iterate over. + */ + constructor(ops: string) { + this.gen = exports.deserializeOps(ops); + this.next = this.gen.next(); + } + + /** + * @returns {boolean} Whether there are any remaining operations. + */ + hasNext() { + return !this.next.done; + } + + /** + * Returns the next operation object and advances the iterator. + * + * Note: This does NOT implement the ECMAScript iterator protocol. + * + * @param {Op} [opOut] - Deprecated. Operation object to recycle for the return value. + * @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are + * no more operations. + */ + next(opOut = new Op()) { + if (this.hasNext()) { + copyOp(this._next.value, opOut); + this._next = this._gen.next(); + } else { + clearOp(opOut); + } + return opOut; + } +} diff --git a/src/static/js/SmartOpAssembler.ts b/src/static/js/SmartOpAssembler.ts new file mode 100644 index 000000000..02a4aee7c --- /dev/null +++ b/src/static/js/SmartOpAssembler.ts @@ -0,0 +1,115 @@ +import {MergingOpAssembler} from "./MergingOpAssembler"; +import {StringAssembler} from "./StringAssembler"; +import {padUtils as padutils} from "./pad_utils"; +import Op from "./Op"; +import { Attribute } from "./types/Attribute"; +import AttributePool from "./AttributePool"; +import {opsFromText} from "./Changeset"; + +/** + * Creates an object that allows you to append operations (type Op) and also compresses them if + * possible. Like MergingOpAssembler, but able to produce conforming exportss from slightly looser + * input, at the cost of speed. Specifically: + * - merges consecutive operations that can be merged + * - strips final "=" + * - ignores 0-length changes + * - reorders consecutive + and - (which MergingOpAssembler doesn't do) + * + * @typedef {object} SmartOpAssembler + * @property {Function} append - + * @property {Function} appendOpWithText - + * @property {Function} clear - + * @property {Function} endDocument - + * @property {Function} getLengthChange - + * @property {Function} toString - + */ +export class SmartOpAssembler { + private minusAssem: MergingOpAssembler; + private plusAssem: MergingOpAssembler; + private keepAssem: MergingOpAssembler; + private lastOpcode: string; + private lengthChange: number; + private assem: StringAssembler; + + constructor() { + this.minusAssem = new MergingOpAssembler() + this.plusAssem = new MergingOpAssembler() + this.keepAssem = new MergingOpAssembler() + this.assem = new StringAssembler() + this.lastOpcode = '' + this.lengthChange = 0 + } + + flushKeeps = () => { + this.assem.append(this.keepAssem.toString()); + this.keepAssem.clear(); + }; + + flushPlusMinus = () => { + this.assem.append(this.minusAssem.toString()); + this.minusAssem.clear(); + this.assem.append(this.plusAssem.toString()); + this.plusAssem.clear(); + }; + + append = (op: Op) => { + if (!op.opcode) return; + if (!op.chars) return; + + if (op.opcode === '-') { + if (this.lastOpcode === '=') { + this.flushKeeps(); + } + this.minusAssem.append(op); + this.lengthChange -= op.chars; + } else if (op.opcode === '+') { + if (this.lastOpcode === '=') { + this.flushKeeps(); + } + this.plusAssem.append(op); + this.lengthChange += op.chars; + } else if (op.opcode === '=') { + if (this.lastOpcode !== '=') { + this.flushPlusMinus(); + } + this.keepAssem.append(op); + } + this.lastOpcode = op.opcode; + }; + + /** + * Generates operations from the given text and attributes. + * + * @deprecated Use `opsFromText` instead. + * @param {('-'|'+'|'=')} opcode - The operator to use. + * @param {string} text - The text to remove/add/keep. + * @param {(string|Iterable)} attribs - The attributes to apply to the operations. + * @param {?AttributePool.ts} pool - Attribute pool. Only required if `attribs` is an iterable of + * attribute key, value pairs. + */ + appendOpWithText = (opcode: '-'|'+'|'=', text: string, attribs: Attribute[], pool?: AttributePool) => { + padutils.warnDeprecated('Changeset.smartOpAssembler().appendOpWithText() is deprecated; ' + + 'use opsFromText() instead.'); + for (const op of opsFromText(opcode, text, attribs, pool)) this.append(op); + }; + + toString = () => { + this.flushPlusMinus(); + this.flushKeeps(); + return this.assem.toString(); + }; + + clear = () => { + this.minusAssem.clear(); + this.plusAssem.clear(); + this.keepAssem.clear(); + this.assem.clear(); + this.lengthChange = 0; + }; + + endDocument = () => { + this.keepAssem.endDocument(); + }; + + getLengthChange = () => this.lengthChange; +} diff --git a/src/static/js/StringAssembler.ts b/src/static/js/StringAssembler.ts new file mode 100644 index 000000000..a316d5a52 --- /dev/null +++ b/src/static/js/StringAssembler.ts @@ -0,0 +1,18 @@ +/** + * @returns {StringAssembler} + */ +export class StringAssembler { + private str = '' + clear = ()=> { + this.str = ''; + } + /** + * @param {string} x - + */ + append(x: string) { + this.str += String(x); + } + toString() { + return this.str + } +} diff --git a/src/static/js/StringIterator.ts b/src/static/js/StringIterator.ts new file mode 100644 index 000000000..1543e8784 --- /dev/null +++ b/src/static/js/StringIterator.ts @@ -0,0 +1,54 @@ +import {assert} from "./Changeset"; + +/** + * A custom made String Iterator + * + * @typedef {object} StringIterator + * @property {Function} newlines - + * @property {Function} peek - + * @property {Function} remaining - + * @property {Function} skip - + * @property {Function} take - + */ + +/** + * @param {string} str - String to iterate over + * @returns {StringIterator} + */ +export class StringIterator { + private curIndex: number; + private newLines: number; + private str: String + + constructor(str: string) { + this.curIndex = 0; + this.str = str + this.newLines = str.split('\n').length - 1; + } + remaining = () => this.str.length - this.curIndex; + + getnewLines = () => this.newLines; + + assertRemaining = (n: number) => { + assert(n <= this.remaining(), `!(${n} <= ${this.remaining()})`); + } + + take = (n: number) => { + this.assertRemaining(n); + const s = this.str.substring(this.curIndex, n); + this.newLines -= s.split('\n').length - 1; + this.curIndex += n; + return s; + } + + peek = (n: number) => { + this.assertRemaining(n); + return this.str.substring(this.curIndex, n); + } + + skip = (n: number) => { + this.assertRemaining(n); + this.curIndex += n; + } + +} diff --git a/src/static/js/ace.js b/src/static/js/ace.js index a1b5d99c8..d756d40be 100644 --- a/src/static/js/ace.js +++ b/src/static/js/ace.js @@ -25,12 +25,12 @@ // requires: undefined const hooks = require('./pluginfw/hooks'); -const makeCSSManager = require('./cssmanager').makeCSSManager; + const pluginUtils = require('./pluginfw/shared'); const ace2_inner = require('ep_etherpad-lite/static/js/ace2_inner') const debugLog = (...args) => {}; const cl_plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins') -const rJQuery = require('ep_etherpad-lite/static/js/rjquery') +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. @@ -298,16 +298,16 @@ const Ace2Editor = function () { innerWindow.Ace2Inner = ace2_inner; innerWindow.plugins = cl_plugins; - innerWindow.$ = innerWindow.jQuery = rJQuery.jQuery; + 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, { - inner: makeCSSManager(innerStyle.sheet), - outer: makeCSSManager(outerStyle.sheet), - parent: makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet), + inner: new Cssmanager(innerStyle.sheet), + outer: new Cssmanager(outerStyle.sheet), + parent: new Cssmanager(document.querySelector('style[title="dynamicsyntax"]').sheet), }); debugLog('Ace2Editor.init() Ace2Inner.init() returned'); loaded = true; diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js index 641c5ecdb..139e3bc18 100644 --- a/src/static/js/ace2_inner.js +++ b/src/static/js/ace2_inner.js @@ -1,5 +1,7 @@ 'use strict'; +import linestylefilter from "./linestylefilter"; + /** * Copyright 2009 Google Inc. * Copyright 2020 John McLear - The Etherpad Foundation. @@ -18,32 +20,31 @@ */ let documentAttributeManager; -const AttributeMap = require('./AttributeMap'); +import AttributeMap from './AttributeMap' const browser = require('./vendors/browser'); -const padutils = require('./pad_utils').padutils; +import {padUtils as padutils} from './pad_utils' const Ace2Common = require('./ace2_common'); -const $ = require('./rjquery').$; const isNodeText = Ace2Common.isNodeText; const getAssoc = Ace2Common.getAssoc; const setAssoc = Ace2Common.setAssoc; const noop = Ace2Common.noop; const hooks = require('./pluginfw/hooks'); +import AttributePool from "./AttributePool"; import Scroll from './scroll' +import AttributeManager from "./AttributeManager"; +import ChangesetTracker from './changesettracker' +import SkipList from "./skiplist"; +import {undoModule, pool as undoModPool, setPool} from './undomodule' function Ace2Inner(editorInfo, cssManagers) { - const makeChangesetTracker = require('./changesettracker').makeChangesetTracker; const colorutils = require('./colorutils').colorutils; const makeContentCollector = require('./contentcollector').makeContentCollector; const domline = require('./domline').domline; - const AttribPool = require('./AttributePool'); const Changeset = require('./Changeset'); const ChangesetUtils = require('./ChangesetUtils'); - const linestylefilter = require('./linestylefilter').linestylefilter; - const SkipList = require('./skiplist'); - const undoModule = require('./undomodule').undoModule; - const AttributeManager = require('./AttributeManager'); + const DEBUG = false; const THE_TAB = ' '; // 4 @@ -126,12 +127,12 @@ function Ace2Inner(editorInfo, cssManagers) { selFocusAtStart: false, alltext: '', alines: [], - apool: new AttribPool(), + apool: new AttributePool(), }; // lines, alltext, alines, and DOM are set up in init() if (undoModule.enabled) { - undoModule.apool = rep.apool; + setPool(rep.apool) } let isEditable = true; @@ -174,7 +175,7 @@ function Ace2Inner(editorInfo, cssManagers) { // CCCCCCCCCCCCCCCCCCCC\n // CCCC\n // end[0]: -------\n - const builder = Changeset.builder(rep.lines.totalWidth()); + const builder = Changeset.builder(rep.lines.totalWidth); ChangesetUtils.buildKeepToStartOfRange(rep, builder, start); ChangesetUtils.buildRemoveRange(rep, builder, start, end); builder.insert(newText, [ @@ -185,7 +186,7 @@ function Ace2Inner(editorInfo, cssManagers) { performDocumentApplyChangeset(cs); }; - const changesetTracker = makeChangesetTracker(scheduler, rep.apool, { + const changesetTracker = new ChangesetTracker(scheduler, rep.apool, { withCallbacks: (operationName, f) => { inCallStackIfNecessary(operationName, () => { fastIncorp(1); @@ -497,7 +498,7 @@ function Ace2Inner(editorInfo, cssManagers) { const importAText = (atext, apoolJsonObj, undoable) => { atext = Changeset.cloneAText(atext); if (apoolJsonObj) { - const wireApool = (new AttribPool()).fromJsonable(apoolJsonObj); + const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj); atext.attribs = Changeset.moveOpsToNewPool(atext.attribs, wireApool, rep.apool); } inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => { @@ -523,7 +524,7 @@ function Ace2Inner(editorInfo, cssManagers) { fastIncorp(8); - const oldLen = rep.lines.totalWidth(); + const oldLen = rep.lines.totalWidth; const numLines = rep.lines.length(); const upToLastLine = rep.lines.offsetOfIndex(numLines - 1); const lastLineLength = rep.lines.atIndex(numLines - 1).text.length; @@ -827,7 +828,7 @@ function Ace2Inner(editorInfo, cssManagers) { const recolorLinesInRange = (startChar, endChar) => { if (endChar <= startChar) return; - if (startChar < 0 || startChar >= rep.lines.totalWidth()) return; + if (startChar < 0 || startChar >= rep.lines.totalWidth) return; let lineEntry = rep.lines.atOffset(startChar); // rounds down to line boundary let lineStart = rep.lines.offsetOfEntry(lineEntry); let lineIndex = rep.lines.indexOfEntry(lineEntry); @@ -1271,7 +1272,7 @@ function Ace2Inner(editorInfo, cssManagers) { if (shouldIndent && /[[(:{]\s*$/.exec(prevLineText)) { theIndent += THE_TAB; } - const cs = Changeset.builder(rep.lines.totalWidth()).keep( + const cs = Changeset.builder(rep.lines.totalWidth).keep( rep.lines.offsetOfIndex(lineNum), lineNum).insert( theIndent, [ ['author', thisAuthor], @@ -2297,7 +2298,7 @@ function Ace2Inner(editorInfo, cssManagers) { // 3-renumber every list item of the same level from the beginning, level 1 // IMPORTANT: never skip a level because there imbrication may be arbitrary - const builder = Changeset.builder(rep.lines.totalWidth()); + const builder = Changeset.builder(rep.lines.totalWidth); let loc = [0, 0]; const applyNumberList = (line, level) => { // init diff --git a/src/static/js/attributes.js b/src/static/js/attributes.ts similarity index 80% rename from src/static/js/attributes.js rename to src/static/js/attributes.ts index 4ab347019..eb9516a57 100644 --- a/src/static/js/attributes.js +++ b/src/static/js/attributes.ts @@ -17,6 +17,9 @@ * @typedef {string} AttributeString */ +import AttributePool from "./AttributePool"; +import {Attribute} from "./types/Attribute"; + /** * Converts an attribute string into a sequence of attribute identifier numbers. * @@ -28,7 +31,7 @@ * appear in `str`. * @returns {Generator} */ -exports.decodeAttribString = function* (str) { +export const decodeAttribString = function* (str: string): Generator { const re = /\*([0-9a-z]+)|./gy; let match; while ((match = re.exec(str)) != null) { @@ -38,7 +41,7 @@ exports.decodeAttribString = function* (str) { } }; -const checkAttribNum = (n) => { +const checkAttribNum = (n: number|object) => { if (typeof n !== 'number') throw new TypeError(`not a number: ${n}`); if (n < 0) throw new Error(`attribute number is negative: ${n}`); if (n !== Math.trunc(n)) throw new Error(`attribute number is not an integer: ${n}`); @@ -50,7 +53,7 @@ const checkAttribNum = (n) => { * @param {Iterable} attribNums - Sequence of attribute numbers. * @returns {AttributeString} */ -exports.encodeAttribString = (attribNums) => { +export const encodeAttribString = (attribNums: Iterable): string => { let str = ''; for (const n of attribNums) { checkAttribNum(n); @@ -67,7 +70,7 @@ exports.encodeAttribString = (attribNums) => { * @yields {Attribute} The identified attributes, in the same order as `attribNums`. * @returns {Generator} */ -exports.attribsFromNums = function* (attribNums, pool) { +export const attribsFromNums = function* (attribNums: Iterable, pool: AttributePool): Generator { for (const n of attribNums) { checkAttribNum(n); const attrib = pool.getAttrib(n); @@ -87,7 +90,7 @@ exports.attribsFromNums = function* (attribNums, pool) { * @yields {number} The attribute number of each attribute in `attribs`, in order. * @returns {Generator} */ -exports.attribsToNums = function* (attribs, pool) { +export const attribsToNums = function* (attribs: Iterable, pool: AttributePool) { for (const attrib of attribs) yield pool.putAttrib(attrib); }; @@ -102,8 +105,8 @@ exports.attribsToNums = function* (attribs, pool) { * @yields {Attribute} The attributes identified in `str`, in order. * @returns {Generator} */ -exports.attribsFromString = function* (str, pool) { - yield* exports.attribsFromNums(exports.decodeAttribString(str), pool); +export const attribsFromString = function* (str: string, pool: AttributePool): Generator { + yield* attribsFromNums(decodeAttribString(str), pool); }; /** @@ -116,8 +119,8 @@ exports.attribsFromString = function* (str, pool) { * @param {AttributePool} pool - Attribute pool. * @returns {AttributeString} */ -exports.attribsToString = - (attribs, pool) => exports.encodeAttribString(exports.attribsToNums(attribs, pool)); +export const attribsToString = + (attribs: Iterable, pool: AttributePool): string => encodeAttribString(attribsToNums(attribs, pool)); /** * Sorts the attributes in canonical order. The order of entries with the same attribute name is @@ -126,5 +129,4 @@ exports.attribsToString = * @param {Attribute[]} attribs - Attributes to sort in place. * @returns {Attribute[]} `attribs` (for chaining). */ -exports.sort = - (attribs) => attribs.sort(([keyA], [keyB]) => (keyA > keyB ? 1 : 0) - (keyA < keyB ? 1 : 0)); +export const sort = (attribs: Attribute[]): Attribute[] => attribs.sort(([keyA], [keyB]) => (keyA > keyB ? 1 : 0) - (keyA < keyB ? 1 : 0)); diff --git a/src/static/js/basic_error_handler.js b/src/static/js/basic_error_handler.ts similarity index 92% rename from src/static/js/basic_error_handler.js rename to src/static/js/basic_error_handler.ts index ab400aa8a..a7e6ef0e4 100644 --- a/src/static/js/basic_error_handler.js +++ b/src/static/js/basic_error_handler.ts @@ -26,7 +26,7 @@ const msgBlock = document.createElement('blockquote'); box.appendChild(msgBlock); msgBlock.style.fontWeight = 'bold'; - msgBlock.appendChild(document.createTextNode(msg)); + msgBlock.appendChild(document.createTextNode(msg as string)); const loc = document.createElement('p'); box.appendChild(loc); loc.appendChild(document.createTextNode(`in ${url}`)); @@ -39,7 +39,7 @@ box.appendChild(stackBlock); const stack = document.createElement('pre'); stackBlock.appendChild(stack); - stack.appendChild(document.createTextNode(err.stack || err.toString())); + stack.appendChild(document.createTextNode(err!.stack || err!.toString())); if (typeof originalHandler === 'function') originalHandler(...args); }; diff --git a/src/static/js/broadcast.js b/src/static/js/broadcast.js index 2163fd78e..52c0a490e 100644 --- a/src/static/js/broadcast.js +++ b/src/static/js/broadcast.js @@ -6,6 +6,8 @@ * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ +import {Cssmanager} from "./cssmanager"; + /** * Copyright 2009 Google Inc. * @@ -22,14 +24,14 @@ * limitations under the License. */ -const makeCSSManager = require('./cssmanager').makeCSSManager; + const domline = require('./domline').domline; -const AttribPool = require('./AttributePool'); +import AttributePool from "./AttributePool"; const Changeset = require('./Changeset'); const attributes = require('./attributes'); -const linestylefilter = require('./linestylefilter').linestylefilter; +import linestylefilter from './linestylefilter' const colorutils = require('./colorutils').colorutils; -const _ = require('./underscore'); +const _ = require('underscore'); const hooks = require('./pluginfw/hooks'); import html10n from './vendors/html10n'; @@ -56,7 +58,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro Changeset.splitTextLines(clientVars.collab_client_vars.initialAttributedText.text), currentDivs: null, // to be filled in once the dom loads - apool: (new AttribPool()).fromJsonable(clientVars.collab_client_vars.apool), + apool: (new AttributePool()).fromJsonable(clientVars.collab_client_vars.apool), alines: Changeset.splitAttributionLines( clientVars.collab_client_vars.initialAttributedText.attribs, clientVars.collab_client_vars.initialAttributedText.text), @@ -389,7 +391,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro setTimeout(() => this.loadFromQueue(), 10); }, handleResponse: (data, start, granularity, callback) => { - const pool = (new AttribPool()).fromJsonable(data.apool); + const pool = (new AttributePool()).fromJsonable(data.apool); for (let i = 0; i < data.forwardsChangesets.length; i++) { const astart = start + i * granularity - 1; // rev -1 is a blank single line let aend = start + (i + 1) * granularity - 1; // totalRevs is the most recent revision @@ -409,13 +411,13 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro if (obj.type === 'NEW_CHANGES') { const changeset = Changeset.moveOpsToNewPool( - obj.changeset, (new AttribPool()).fromJsonable(obj.apool), padContents.apool); + obj.changeset, (new AttributePool()).fromJsonable(obj.apool), padContents.apool); let changesetBack = Changeset.inverse( obj.changeset, padContents.currentLines, padContents.alines, padContents.apool); changesetBack = Changeset.moveOpsToNewPool( - changesetBack, (new AttribPool()).fromJsonable(obj.apool), padContents.apool); + changesetBack, (new AttributePool()).fromJsonable(obj.apool), padContents.apool); loadedNewChangeset(changeset, changesetBack, obj.newRev - 1, obj.timeDelta); } else if (obj.type === 'NEW_AUTHORDATA') { @@ -465,7 +467,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro BroadcastSlider.onSlider(goToRevisionIfEnabled); - const dynamicCSS = makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet); + const dynamicCSS = new Cssmanager(document.querySelector('style[title="dynamicsyntax"]').sheet); const authorData = {}; const receiveAuthorData = (newAuthorData) => { diff --git a/src/static/js/broadcast_slider.js b/src/static/js/broadcast_slider.js index 848ba06cf..0dcd59f3c 100644 --- a/src/static/js/broadcast_slider.js +++ b/src/static/js/broadcast_slider.js @@ -23,7 +23,7 @@ // These parameters were global, now they are injected. A reference to the // Timeslider controller would probably be more appropriate. -const _ = require('./underscore'); +const _ = require('underscore'); const padmodals = require('./pad_modals').padmodals; const colorutils = require('./colorutils').colorutils; import html10n from './vendors/html10n'; diff --git a/src/static/js/changesettracker.js b/src/static/js/changesettracker.js deleted file mode 100644 index 30c70aa74..000000000 --- a/src/static/js/changesettracker.js +++ /dev/null @@ -1,203 +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 AttributeMap = require('./AttributeMap'); -const AttributePool = require('./AttributePool'); -const Changeset = require('./Changeset'); - -const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => { - // latest official text from server - let baseAText = Changeset.makeAText('\n'); - // changes applied to baseText that have been submitted - let submittedChangeset = null; - // changes applied to submittedChangeset since it was prepared - let userChangeset = Changeset.identity(1); - // is the changesetTracker enabled - let tracking = false; - // stack state flag so that when we change the rep we don't - // handle the notification recursively. When setting, always - // unset in a "finally" block. When set to true, the setter - // takes change of userChangeset. - let applyingNonUserChanges = false; - - let changeCallback = null; - - let changeCallbackTimeout = null; - - const setChangeCallbackTimeout = () => { - // can call this multiple times per call-stack, because - // we only schedule a call to changeCallback if it exists - // and if there isn't a timeout already scheduled. - if (changeCallback && changeCallbackTimeout == null) { - changeCallbackTimeout = scheduler.setTimeout(() => { - try { - changeCallback(); - } catch (pseudoError) { - // as empty as my soul - } finally { - changeCallbackTimeout = null; - } - }, 0); - } - }; - - let self; - return self = { - isTracking: () => tracking, - setBaseText: (text) => { - self.setBaseAttributedText(Changeset.makeAText(text), null); - }, - setBaseAttributedText: (atext, apoolJsonObj) => { - aceCallbacksProvider.withCallbacks('setBaseText', (callbacks) => { - tracking = true; - baseAText = Changeset.cloneAText(atext); - if (apoolJsonObj) { - const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj); - baseAText.attribs = Changeset.moveOpsToNewPool(baseAText.attribs, wireApool, apool); - } - submittedChangeset = null; - userChangeset = Changeset.identity(atext.text.length); - applyingNonUserChanges = true; - try { - callbacks.setDocumentAttributedText(atext); - } finally { - applyingNonUserChanges = false; - } - }); - }, - composeUserChangeset: (c) => { - if (!tracking) return; - if (applyingNonUserChanges) return; - if (Changeset.isIdentity(c)) return; - userChangeset = Changeset.compose(userChangeset, c, apool); - - setChangeCallbackTimeout(); - }, - applyChangesToBase: (c, optAuthor, apoolJsonObj) => { - if (!tracking) return; - - aceCallbacksProvider.withCallbacks('applyChangesToBase', (callbacks) => { - if (apoolJsonObj) { - const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj); - c = Changeset.moveOpsToNewPool(c, wireApool, apool); - } - - baseAText = Changeset.applyToAText(c, baseAText, apool); - - let c2 = c; - if (submittedChangeset) { - const oldSubmittedChangeset = submittedChangeset; - submittedChangeset = Changeset.follow(c, oldSubmittedChangeset, false, apool); - c2 = Changeset.follow(oldSubmittedChangeset, c, true, apool); - } - - const preferInsertingAfterUserChanges = true; - const oldUserChangeset = userChangeset; - userChangeset = Changeset.follow( - c2, oldUserChangeset, preferInsertingAfterUserChanges, apool); - const postChange = Changeset.follow( - oldUserChangeset, c2, !preferInsertingAfterUserChanges, apool); - - const preferInsertionAfterCaret = true; // (optAuthor && optAuthor > thisAuthor); - applyingNonUserChanges = true; - try { - callbacks.applyChangesetToDocument(postChange, preferInsertionAfterCaret); - } finally { - applyingNonUserChanges = false; - } - }); - }, - prepareUserChangeset: () => { - // If there are user changes to submit, 'changeset' will be the - // changeset, else it will be null. - let toSubmit; - if (submittedChangeset) { - // submission must have been canceled, prepare new changeset - // that includes old submittedChangeset - toSubmit = Changeset.compose(submittedChangeset, userChangeset, apool); - } else { - // Get my authorID - const authorId = parent.parent.pad.myUserInfo.userId; - - // Sanitize authorship: Replace all author attributes with this user's author ID in case the - // text was copied from another author. - const cs = Changeset.unpack(userChangeset); - const assem = Changeset.mergingOpAssembler(); - - for (const op of Changeset.deserializeOps(cs.ops)) { - if (op.opcode === '+') { - const attribs = AttributeMap.fromString(op.attribs, apool); - const oldAuthorId = attribs.get('author'); - if (oldAuthorId != null && oldAuthorId !== authorId) { - attribs.set('author', authorId); - op.attribs = attribs.toString(); - } - } - assem.append(op); - } - assem.endDocument(); - userChangeset = Changeset.pack(cs.oldLen, cs.newLen, assem.toString(), cs.charBank); - Changeset.checkRep(userChangeset); - - if (Changeset.isIdentity(userChangeset)) toSubmit = null; - else toSubmit = userChangeset; - } - - let cs = null; - if (toSubmit) { - submittedChangeset = toSubmit; - userChangeset = Changeset.identity(Changeset.newLen(toSubmit)); - - cs = toSubmit; - } - let wireApool = null; - if (cs) { - const forWire = Changeset.prepareForWire(cs, apool); - wireApool = forWire.pool.toJsonable(); - cs = forWire.translated; - } - - const data = { - changeset: cs, - apool: wireApool, - }; - return data; - }, - applyPreparedChangesetToBase: () => { - if (!submittedChangeset) { - // violation of protocol; use prepareUserChangeset first - throw new Error('applySubmittedChangesToBase: no submitted changes to apply'); - } - // bumpDebug("applying committed changeset: "+submittedChangeset.encodeToString(false)); - baseAText = Changeset.applyToAText(submittedChangeset, baseAText, apool); - submittedChangeset = null; - }, - setUserChangeNotificationCallback: (callback) => { - changeCallback = callback; - }, - hasUncommittedChanges: () => !!(submittedChangeset || (!Changeset.isIdentity(userChangeset))), - }; -}; - -exports.makeChangesetTracker = makeChangesetTracker; diff --git a/src/static/js/changesettracker.ts b/src/static/js/changesettracker.ts new file mode 100644 index 000000000..fec259d7f --- /dev/null +++ b/src/static/js/changesettracker.ts @@ -0,0 +1,216 @@ +'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. + */ + +import AttributeMap from './AttributeMap' +import AttributePool from "./AttributePool"; +import {AText} from "../../node/types/PadType"; +import {Attribute} from "./types/Attribute"; + +const Changeset = require('./Changeset'); + + +class Changesettracker { + private scheduler: WindowProxy + private readonly apool: AttributePool + private baseAText: { + attribs: Attribute[] + } + private submittedChangeset: null + private userChangeset: any + private tracking: boolean + private applyingNonUserChanges: boolean + private aceCallbacksProvider: any + private changeCallback: (() => void) | null = null + private changeCallbackTimeout: number | null = null + + constructor(scheduler: WindowProxy, apool: AttributePool, aceCallbacksProvider: any) { + this.scheduler = scheduler + this.apool = apool + this.aceCallbacksProvider = aceCallbacksProvider + // latest official text from server + this.baseAText = Changeset.makeAText('\n'); + // changes applied to baseText that have been submitted + this.submittedChangeset = null + // changes applied to submittedChangeset since it was prepared + this.userChangeset = Changeset.identity(1) + // is the changesetTracker enabled + this.tracking = false + this.applyingNonUserChanges = false + } + + setChangeCallbackTimeout = () => { + // can call this multiple times per call-stack, because + // we only schedule a call to changeCallback if it exists + // and if there isn't a timeout already scheduled. + if (this.changeCallback && this.changeCallbackTimeout == null) { + this.changeCallbackTimeout = this.scheduler.setTimeout(() => { + try { + this.changeCallback!(); + } catch (pseudoError) { + // as empty as my soul + } finally { + this.changeCallbackTimeout = null; + } + }, 0); + } + } + isTracking = () => this.tracking + setBaseText = (text: string) => { + this.setBaseAttributedText(Changeset.makeAText(text), null); + } + setBaseAttributedText = (atext: AText, apoolJsonObj?: AttributePool | null) => { + this.aceCallbacksProvider.withCallbacks('setBaseText', (callbacks: { setDocumentAttributedText: (arg0: AText) => void; }) => { + this.tracking = true; + this.baseAText = Changeset.cloneAText(atext); + if (apoolJsonObj) { + const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj); + this.baseAText.attribs = Changeset.moveOpsToNewPool(this.baseAText.attribs, wireApool, this.apool); + } + this.submittedChangeset = null; + this.userChangeset = Changeset.identity(atext.text.length); + this.applyingNonUserChanges = true; + try { + callbacks.setDocumentAttributedText(atext); + } finally { + this.applyingNonUserChanges = false; + } + }); + } + composeUserChangeset = (c: number) => { + if (!this.tracking) return; + if (this.applyingNonUserChanges) return; + if (Changeset.isIdentity(c)) return; + this.userChangeset = Changeset.compose(this.userChangeset, c, this.apool); + + this.setChangeCallbackTimeout(); + } + + applyChangesToBase = (c: number, optAuthor: string, apoolJsonObj: AttributePool) => { + if (!this.tracking) return; + + this.aceCallbacksProvider.withCallbacks('applyChangesToBase', (callbacks: { applyChangesetToDocument: (arg0: any, arg1: boolean) => void; }) => { + if (apoolJsonObj) { + const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj); + c = Changeset.moveOpsToNewPool(c, wireApool, this.apool); + } + + this.baseAText = Changeset.applyToAText(c, this.baseAText, this.apool); + + let c2 = c; + if (this.submittedChangeset) { + const oldSubmittedChangeset = this.submittedChangeset; + this.submittedChangeset = Changeset.follow(c, oldSubmittedChangeset, false, this.apool); + c2 = Changeset.follow(oldSubmittedChangeset, c, true, this.apool); + } + + const preferInsertingAfterUserChanges = true; + const oldUserChangeset = this.userChangeset; + this.userChangeset = Changeset.follow( + c2, oldUserChangeset, preferInsertingAfterUserChanges, this.apool); + const postChange = Changeset.follow( + oldUserChangeset, c2, !preferInsertingAfterUserChanges, this.apool); + + const preferInsertionAfterCaret = true; // (optAuthor && optAuthor > thisAuthor); + this.applyingNonUserChanges = true; + try { + callbacks.applyChangesetToDocument(postChange, preferInsertionAfterCaret); + } finally { + this.applyingNonUserChanges = false; + } + }); + } + + prepareUserChangeset = () => { + // If there are user changes to submit, 'changeset' will be the + // changeset, else it will be null. + let toSubmit; + if (this.submittedChangeset) { + // submission must have been canceled, prepare new changeset + // that includes old submittedChangeset + toSubmit = Changeset.compose(this.submittedChangeset, this.userChangeset, this.apool); + } else { + // Get my authorID + // @ts-ignore + const authorId = parent.parent.pad.myUserInfo.userId; + + // Sanitize authorship: Replace all author attributes with this user's author ID in case the + // text was copied from another author. + const cs = Changeset.unpack(this.userChangeset); + const assem = Changeset.mergingOpAssembler(); + + for (const op of Changeset.deserializeOps(cs.ops)) { + if (op.opcode === '+') { + const attribs = AttributeMap.fromString(op.attribs, this.apool); + const oldAuthorId = attribs.get('author'); + if (oldAuthorId != null && oldAuthorId !== authorId) { + attribs.set('author', authorId); + op.attribs = attribs.toString(); + } + } + assem.append(op); + } + assem.endDocument(); + this.userChangeset = Changeset.pack(cs.oldLen, cs.newLen, assem.toString(), cs.charBank); + Changeset.checkRep(this.userChangeset); + + if (Changeset.isIdentity(this.userChangeset)) toSubmit = null; + else toSubmit = this.userChangeset; + } + + let cs = null; + if (toSubmit) { + this.submittedChangeset = toSubmit; + this.userChangeset = Changeset.identity(Changeset.newLen(toSubmit)); + + cs = toSubmit; + } + let wireApool = null; + if (cs) { + const forWire = Changeset.prepareForWire(cs, this.apool); + wireApool = forWire.pool.toJsonable(); + cs = forWire.translated; + } + + const data = { + changeset: cs, + apool: wireApool, + }; + return data; + } + applyPreparedChangesetToBase = () => { + if (!this.submittedChangeset) { + // violation of protocol; use prepareUserChangeset first + throw new Error('applySubmittedChangesToBase: no submitted changes to apply'); + } +// bumpDebug("applying committed changeset: "+submittedChangeset.encodeToString(false)); + this.baseAText = Changeset.applyToAText(this.submittedChangeset, this.baseAText, this.apool); + this.submittedChangeset = null; + } + setUserChangeNotificationCallback = (callback: (() => void) | null) => { + this.changeCallback = callback; + } + hasUncommittedChanges = () => !!(this.submittedChangeset || (!Changeset.isIdentity(this.userChangeset))) +} + +export default Changesettracker diff --git a/src/static/js/chat.js b/src/static/js/chat.js index d32a62c7a..9b2818bcf 100755 --- a/src/static/js/chat.js +++ b/src/static/js/chat.js @@ -16,8 +16,9 @@ */ const ChatMessage = require('./ChatMessage'); -const padutils = require('./pad_utils').padutils; -const padcookie = require('./pad_cookie').padcookie; + +import {padUtils as padutils} from "./pad_utils"; +import padcookie from "./pad_cookie"; const Tinycon = require('tinycon/tinycon'); const hooks = require('./pluginfw/hooks'); const padeditor = require('./pad_editor').padeditor; diff --git a/src/static/js/contentcollector.js b/src/static/js/contentcollector.js index 4735374ee..0bff7da7d 100644 --- a/src/static/js/contentcollector.js +++ b/src/static/js/contentcollector.js @@ -26,7 +26,7 @@ const _MAX_LIST_LEVEL = 16; -const AttributeMap = require('./AttributeMap'); +import AttributeMap from './AttributeMap' const UNorm = require('unorm'); const Changeset = require('./Changeset'); const hooks = require('./pluginfw/hooks'); diff --git a/src/static/js/cssmanager.js b/src/static/js/cssmanager.js deleted file mode 100644 index 5bf2adb30..000000000 --- a/src/static/js/cssmanager.js +++ /dev/null @@ -1,72 +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. - */ - -exports.makeCSSManager = (browserSheet) => { - const browserRules = () => (browserSheet.cssRules || browserSheet.rules); - - const browserDeleteRule = (i) => { - if (browserSheet.deleteRule) browserSheet.deleteRule(i); - else browserSheet.removeRule(i); - }; - - const browserInsertRule = (i, selector) => { - if (browserSheet.insertRule) browserSheet.insertRule(`${selector} {}`, i); - else browserSheet.addRule(selector, null, i); - }; - const selectorList = []; - - const indexOfSelector = (selector) => { - for (let i = 0; i < selectorList.length; i++) { - if (selectorList[i] === selector) { - return i; - } - } - return -1; - }; - - const selectorStyle = (selector) => { - let i = indexOfSelector(selector); - if (i < 0) { - // add selector - browserInsertRule(0, selector); - selectorList.splice(0, 0, selector); - i = 0; - } - return browserRules().item(i).style; - }; - - const removeSelectorStyle = (selector) => { - const i = indexOfSelector(selector); - if (i >= 0) { - browserDeleteRule(i); - selectorList.splice(i, 1); - } - }; - - return { - selectorStyle, - removeSelectorStyle, - info: () => `${selectorList.length}:${browserRules().length}`, - }; -}; diff --git a/src/static/js/cssmanager.ts b/src/static/js/cssmanager.ts new file mode 100644 index 000000000..1e801663f --- /dev/null +++ b/src/static/js/cssmanager.ts @@ -0,0 +1,72 @@ +'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. + */ + +export class Cssmanager { + private browserSheet: CSSStyleSheet + private selectorList:string[] = []; + constructor(browserSheet: CSSStyleSheet) { + this.browserSheet = browserSheet + } + + browserRules = () => (this.browserSheet.cssRules || this.browserSheet.rules); + browserDeleteRule = (i: number) => { + if (this.browserSheet.deleteRule) this.browserSheet.deleteRule(i); + else this.browserSheet.removeRule(i); + } + browserInsertRule = (i: number, selector: string) => { + if (this.browserSheet.insertRule) this.browserSheet.insertRule(`${selector} {}`, i); + else { // @ts-ignore + this.browserSheet.addRule(selector, null, i); + } + } + indexOfSelector = (selector: string) => { + for (let i = 0; i < this.selectorList.length; i++) { + if (this.selectorList[i] === selector) { + return i; + } + } + return -1; + } + + selectorStyle = (selector: string) => { + let i = this.indexOfSelector(selector); + if (i < 0) { + // add selector + this.browserInsertRule(0, selector); + this.selectorList.splice(0, 0, selector); + i = 0; + } + // @ts-ignore + return this.browserRules().item(i)!.style; + } + + removeSelectorStyle = (selector: string) => { + const i = this.indexOfSelector(selector); + if (i >= 0) { + this.browserDeleteRule(i); + this.selectorList.splice(i, 1); + } + } + info= () => `${this.selectorList.length}:${this.browserRules().length}` +} diff --git a/src/static/js/domline.js b/src/static/js/domline.js index af786b2dc..5c3dfcbc4 100644 --- a/src/static/js/domline.js +++ b/src/static/js/domline.js @@ -22,10 +22,11 @@ // requires: plugins // requires: undefined -const Security = require('./security'); +const Security = require('security'); const hooks = require('./pluginfw/hooks'); -const _ = require('./underscore'); -const lineAttributeMarker = require('./linestylefilter').lineAttributeMarker; +const _ = require('underscore'); +import {lineAttributeMarker} from "./linestylefilter"; + const noop = () => {}; diff --git a/src/static/js/index.js b/src/static/js/index.ts similarity index 88% rename from src/static/js/index.js rename to src/static/js/index.ts index d50c14e7d..12d9cb684 100644 --- a/src/static/js/index.js +++ b/src/static/js/index.ts @@ -19,6 +19,8 @@ * limitations under the License. */ +import {getRandomValues} from 'crypto' + const randomPadName = () => { // the number of distinct chars (64) is chosen to ensure that the selection will be uniform when // using the PRNG below @@ -28,8 +30,7 @@ const randomPadName = () => { // make room for 8-bit integer values that span from 0 to 255. const randomarray = new Uint8Array(stringLength); // use browser's PRNG to generate a "unique" sequence - const cryptoObj = window.crypto || window.msCrypto; // for IE 11 - cryptoObj.getRandomValues(randomarray); + getRandomValues(randomarray); let randomstring = ''; for (let i = 0; i < stringLength; i++) { // instead of writing "Math.floor(randomarray[i]/256*64)" @@ -42,9 +43,9 @@ const randomPadName = () => { $(() => { $('#go2Name').on('submit', () => { - const padname = $('#padname').val(); + const padname = $('#padname').val() as string; if (padname.length > 0) { - window.location = `p/${encodeURIComponent(padname.trim())}`; + window.location.href = `p/${encodeURIComponent(padname.trim())}`; } else { alert('Please enter a name'); } @@ -52,10 +53,11 @@ $(() => { }); $('#button').on('click', () => { - window.location = `p/${randomPadName()}`; + window.location.href = `p/${randomPadName()}`; }); // start the custom js + // @ts-ignore if (typeof window.customStart === 'function') window.customStart(); }); diff --git a/src/static/js/linestylefilter.js b/src/static/js/linestylefilter.js deleted file mode 100644 index 632e6b3cc..000000000 --- a/src/static/js/linestylefilter.js +++ /dev/null @@ -1,291 +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 - */ - -// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.linestylefilter -// %APPJET%: import("etherpad.collab.ace.easysync2.Changeset"); -// %APPJET%: import("etherpad.admin.plugins"); -/** - * Copyright 2009 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS-IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// requires: easysync2.Changeset -// requires: top -// requires: plugins -// requires: undefined - -const Changeset = require('./Changeset'); -const attributes = require('./attributes'); -const hooks = require('./pluginfw/hooks'); -const linestylefilter = {}; -const AttributeManager = require('./AttributeManager'); -const padutils = require('./pad_utils').padutils; - -linestylefilter.ATTRIB_CLASSES = { - bold: 'tag:b', - italic: 'tag:i', - underline: 'tag:u', - strikethrough: 'tag:s', -}; - -const lineAttributeMarker = 'lineAttribMarker'; -exports.lineAttributeMarker = lineAttributeMarker; - -linestylefilter.getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => { - if (c === '.') return '-'; - return `z${c.charCodeAt(0)}z`; -})}`; - -// lineLength is without newline; aline includes newline, -// but may be falsy if lineLength == 0 -linestylefilter.getLineStyleFilter = (lineLength, aline, textAndClassFunc, apool) => { - // Plugin Hook to add more Attrib Classes - for (const attribClasses of hooks.callAll('aceAttribClasses', linestylefilter.ATTRIB_CLASSES)) { - Object.assign(linestylefilter.ATTRIB_CLASSES, attribClasses); - } - - if (lineLength === 0) return textAndClassFunc; - - const nextAfterAuthorColors = textAndClassFunc; - - const authorColorFunc = (() => { - const lineEnd = lineLength; - let curIndex = 0; - let extraClasses; - let leftInAuthor; - - const attribsToClasses = (attribs) => { - let classes = ''; - let isLineAttribMarker = false; - - for (const [key, value] of attributes.attribsFromString(attribs, apool)) { - if (!key || !value) continue; - if (!isLineAttribMarker && AttributeManager.lineAttributes.indexOf(key) >= 0) { - isLineAttribMarker = true; - } - if (key === 'author') { - classes += ` ${linestylefilter.getAuthorClassName(value)}`; - } else if (key === 'list') { - classes += ` list:${value}`; - } else if (key === 'start') { - // Needed to introduce the correct Ordered list item start number on import - classes += ` start:${value}`; - } else if (linestylefilter.ATTRIB_CLASSES[key]) { - classes += ` ${linestylefilter.ATTRIB_CLASSES[key]}`; - } else { - const results = hooks.callAll('aceAttribsToClasses', {linestylefilter, key, value}); - classes += ` ${results.join(' ')}`; - } - } - - if (isLineAttribMarker) classes += ` ${lineAttributeMarker}`; - return classes.substring(1); - }; - - const attrOps = Changeset.deserializeOps(aline); - let attrOpsNext = attrOps.next(); - let nextOp, nextOpClasses; - - const goNextOp = () => { - nextOp = attrOpsNext.done ? new Changeset.Op() : attrOpsNext.value; - if (!attrOpsNext.done) attrOpsNext = attrOps.next(); - nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs)); - }; - goNextOp(); - - const nextClasses = () => { - if (curIndex < lineEnd) { - extraClasses = nextOpClasses; - leftInAuthor = nextOp.chars; - goNextOp(); - while (nextOp.opcode && nextOpClasses === extraClasses) { - leftInAuthor += nextOp.chars; - goNextOp(); - } - } - }; - nextClasses(); - - return (txt, cls) => { - const disableAuthColorForThisLine = hooks.callAll('disableAuthorColorsForThisLine', { - linestylefilter, - text: txt, - class: cls, - }); - const disableAuthors = (disableAuthColorForThisLine == null || - disableAuthColorForThisLine.length === 0) ? false : disableAuthColorForThisLine[0]; - while (txt.length > 0) { - if (leftInAuthor <= 0 || disableAuthors) { - // prevent infinite loop if something funny's going on - return nextAfterAuthorColors(txt, cls); - } - let spanSize = txt.length; - if (spanSize > leftInAuthor) { - spanSize = leftInAuthor; - } - const curTxt = txt.substring(0, spanSize); - txt = txt.substring(spanSize); - nextAfterAuthorColors(curTxt, (cls && `${cls} `) + extraClasses); - curIndex += spanSize; - leftInAuthor -= spanSize; - if (leftInAuthor === 0) { - nextClasses(); - } - } - }; - })(); - return authorColorFunc; -}; - -linestylefilter.getAtSignSplitterFilter = (lineText, textAndClassFunc) => { - const at = /@/g; - at.lastIndex = 0; - let splitPoints = null; - let execResult; - while ((execResult = at.exec(lineText))) { - if (!splitPoints) { - splitPoints = []; - } - splitPoints.push(execResult.index); - } - - if (!splitPoints) return textAndClassFunc; - - return linestylefilter.textAndClassFuncSplitter(textAndClassFunc, splitPoints); -}; - -linestylefilter.getRegexpFilter = (regExp, tag) => (lineText, textAndClassFunc) => { - regExp.lastIndex = 0; - let regExpMatchs = null; - let splitPoints = null; - let execResult; - while ((execResult = regExp.exec(lineText))) { - if (!regExpMatchs) { - regExpMatchs = []; - splitPoints = []; - } - const startIndex = execResult.index; - const regExpMatch = execResult[0]; - regExpMatchs.push([startIndex, regExpMatch]); - splitPoints.push(startIndex, startIndex + regExpMatch.length); - } - - if (!regExpMatchs) return textAndClassFunc; - - const regExpMatchForIndex = (idx) => { - for (let k = 0; k < regExpMatchs.length; k++) { - const u = regExpMatchs[k]; - if (idx >= u[0] && idx < u[0] + u[1].length) { - return u[1]; - } - } - return false; - }; - - const handleRegExpMatchsAfterSplit = (() => { - let curIndex = 0; - return (txt, cls) => { - const txtlen = txt.length; - let newCls = cls; - const regExpMatch = regExpMatchForIndex(curIndex); - if (regExpMatch) { - newCls += ` ${tag}:${regExpMatch}`; - } - textAndClassFunc(txt, newCls); - curIndex += txtlen; - }; - })(); - - return linestylefilter.textAndClassFuncSplitter(handleRegExpMatchsAfterSplit, splitPoints); -}; - - -linestylefilter.getURLFilter = linestylefilter.getRegexpFilter(padutils.urlRegex, 'url'); - -linestylefilter.textAndClassFuncSplitter = (func, splitPointsOpt) => { - let nextPointIndex = 0; - let idx = 0; - - // don't split at 0 - while (splitPointsOpt && - nextPointIndex < splitPointsOpt.length && - splitPointsOpt[nextPointIndex] === 0) { - nextPointIndex++; - } - - const spanHandler = (txt, cls) => { - if ((!splitPointsOpt) || nextPointIndex >= splitPointsOpt.length) { - func(txt, cls); - idx += txt.length; - } else { - const splitPoints = splitPointsOpt; - const pointLocInSpan = splitPoints[nextPointIndex] - idx; - const txtlen = txt.length; - if (pointLocInSpan >= txtlen) { - func(txt, cls); - idx += txt.length; - if (pointLocInSpan === txtlen) { - nextPointIndex++; - } - } else { - if (pointLocInSpan > 0) { - func(txt.substring(0, pointLocInSpan), cls); - idx += pointLocInSpan; - } - nextPointIndex++; - // recurse - spanHandler(txt.substring(pointLocInSpan), cls); - } - } - }; - return spanHandler; -}; - -linestylefilter.getFilterStack = (lineText, textAndClassFunc, abrowser) => { - let func = linestylefilter.getURLFilter(lineText, textAndClassFunc); - - const hookFilters = hooks.callAll('aceGetFilterStack', { - linestylefilter, - browser: abrowser, - }); - hookFilters.map((hookFilter) => { - func = hookFilter(lineText, func); - }); - - return func; -}; - -// domLineObj is like that returned by domline.createDomLine -linestylefilter.populateDomLine = (textLine, aline, apool, domLineObj) => { - // remove final newline from text if any - let text = textLine; - if (text.slice(-1) === '\n') { - text = text.substring(0, text.length - 1); - } - - const textAndClassFunc = (tokenText, tokenClass) => { - domLineObj.appendSpan(tokenText, tokenClass); - }; - - let func = linestylefilter.getFilterStack(text, textAndClassFunc); - func = linestylefilter.getLineStyleFilter(text.length, aline, func, apool); - func(text, ''); -}; - -exports.linestylefilter = linestylefilter; diff --git a/src/static/js/linestylefilter.ts b/src/static/js/linestylefilter.ts new file mode 100644 index 000000000..5a71f200e --- /dev/null +++ b/src/static/js/linestylefilter.ts @@ -0,0 +1,298 @@ +'use strict'; + +/** + * This code is mostly from the old Etherpad. Please help us to comment this code. + * This helps other people to understand this code better and helps them to improve it. + * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED + */ + +// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.linestylefilter +// %APPJET%: import("etherpad.collab.ace.easysync2.Changeset"); +// %APPJET%: import("etherpad.admin.plugins"); +import AttributePool from "./AttributePool"; + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// requires: easysync2.Changeset +// requires: top +// requires: plugins +// requires: undefined + +const Changeset = require('./Changeset'); +const attributes = require('./attributes'); +const hooks = require('./pluginfw/hooks'); +const linestylefilter = {}; +import AttributeManager from "./AttributeManager"; +import {padUtils as padutils} from "./pad_utils"; +import {Attribute} from "./types/Attribute"; +import Op from "./Op"; + +type DomLineObject = { + appendSpan(tokenText: string, tokenClass:string):void +} + +class Linestylefilter { + ATTRIB_CLASSES: { + [key: string]: string + } = { + bold: 'tag:b', + italic: 'tag:i', + underline: 'tag:u', + strikethrough: 'tag:s', + } + getAuthorClassName = (author: string) => `author-${author.replace(/[^a-y0-9]/g, (c) => { + if (c === '.') return '-'; + return `z${c.charCodeAt(0)}z`; + })}` + + // lineLength is without newline; aline includes newline, +// but may be falsy if lineLength == 0 + getLineStyleFilter = (lineLength: number, aline: string, textAndClassFunc: Function, apool: AttributePool) => { + // Plugin Hook to add more Attrib Classes + for (const attribClasses of hooks.callAll('aceAttribClasses', this.ATTRIB_CLASSES)) { + Object.assign(this.ATTRIB_CLASSES, attribClasses); + } + + if (lineLength === 0) return textAndClassFunc; + + const nextAfterAuthorColors = textAndClassFunc; + + const authorColorFunc = (() => { + const lineEnd = lineLength; + let curIndex = 0; + let extraClasses: string; + let leftInAuthor: number; + + const attribsToClasses = (attribs: string) => { + let classes = ''; + let isLineAttribMarker = false; + + for (const [key, value] of attributes.attribsFromString(attribs, apool)) { + if (!key || !value) continue; + if (!isLineAttribMarker && AttributeManager.lineAttributes.indexOf(key) >= 0) { + isLineAttribMarker = true; + } + if (key === 'author') { + classes += ` ${this.getAuthorClassName(value)}`; + } else if (key === 'list') { + classes += ` list:${value}`; + } else if (key === 'start') { + // Needed to introduce the correct Ordered list item start number on import + classes += ` start:${value}`; + } else if (this.ATTRIB_CLASSES[key]) { + classes += ` ${this.ATTRIB_CLASSES[key]}`; + } else { + const results = hooks.callAll('aceAttribsToClasses', {linestylefilter, key, value}); + classes += ` ${results.join(' ')}`; + } + } + + if (isLineAttribMarker) classes += ` ${lineAttributeMarker}`; + return classes.substring(1); + }; + + const attrOps = Changeset.deserializeOps(aline); + let attrOpsNext = attrOps.next(); + let nextOp: Op, nextOpClasses: string; + + const goNextOp = () => { + nextOp = attrOpsNext.done ? new Changeset.Op() : attrOpsNext.value; + if (!attrOpsNext.done) attrOpsNext = attrOps.next(); + nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs)); + }; + goNextOp(); + + const nextClasses = () => { + if (curIndex < lineEnd) { + extraClasses = nextOpClasses; + leftInAuthor = nextOp.chars; + goNextOp(); + while (nextOp.opcode && nextOpClasses === extraClasses) { + leftInAuthor += nextOp.chars; + goNextOp(); + } + } + }; + nextClasses(); + + return (txt: string, cls: string) => { + const disableAuthColorForThisLine = hooks.callAll('disableAuthorColorsForThisLine', { + linestylefilter, + text: txt, + class: cls, + }); + const disableAuthors = (disableAuthColorForThisLine == null || + disableAuthColorForThisLine.length === 0) ? false : disableAuthColorForThisLine[0]; + while (txt.length > 0) { + if (leftInAuthor <= 0 || disableAuthors) { + // prevent infinite loop if something funny's going on + return nextAfterAuthorColors(txt, cls); + } + let spanSize = txt.length; + if (spanSize > leftInAuthor) { + spanSize = leftInAuthor; + } + const curTxt = txt.substring(0, spanSize); + txt = txt.substring(spanSize); + nextAfterAuthorColors(curTxt, (cls && `${cls} `) + extraClasses); + curIndex += spanSize; + leftInAuthor -= spanSize; + if (leftInAuthor === 0) { + nextClasses(); + } + } + }; + })(); + return authorColorFunc; + } + +getAtSignSplitterFilter = (lineText: string, textAndClassFunc: Function) => { + const at = /@/g; + at.lastIndex = 0; + let splitPoints = null; + let execResult; + while ((execResult = at.exec(lineText))) { + if (!splitPoints) { + splitPoints = []; + } + splitPoints.push(execResult.index); + } + + if (!splitPoints) return textAndClassFunc; + + return this.textAndClassFuncSplitter(textAndClassFunc, splitPoints); + } + + getRegexpFilter = (regExp: RegExp, tag: string) => (lineText: string, textAndClassFunc: Function) => { + regExp.lastIndex = 0; + let regExpMatchs = null; + let splitPoints: number[]|null = null; + let execResult; + while ((execResult = regExp.exec(lineText))) { + if (!regExpMatchs) { + regExpMatchs = []; + splitPoints = []; + } + const startIndex = execResult.index; + const regExpMatch = execResult[0]; + regExpMatchs.push([startIndex, regExpMatch]); + splitPoints!.push(startIndex, startIndex + regExpMatch.length); + } + + if (!regExpMatchs) return textAndClassFunc; + + const regExpMatchForIndex = (idx: number) => { + for (let k = 0; k < regExpMatchs.length; k++) { + const u = regExpMatchs[k] as number[]; + // @ts-ignore + if (idx >= u[0] && idx < u[0] + u[1].length) { + return u[1]; + } + } + return false; + } + + const handleRegExpMatchsAfterSplit = (() => { + let curIndex = 0; + return (txt: string, cls: string) => { + const txtlen = txt.length; + let newCls = cls; + const regExpMatch = regExpMatchForIndex(curIndex); + if (regExpMatch) { + newCls += ` ${tag}:${regExpMatch}`; + } + textAndClassFunc(txt, newCls); + curIndex += txtlen; + }; + })(); + + return this.textAndClassFuncSplitter(handleRegExpMatchsAfterSplit, splitPoints!); + } + getURLFilter = this.getRegexpFilter(padutils.urlRegex, 'url') + textAndClassFuncSplitter = (func: Function, splitPointsOpt: number[]) => { + let nextPointIndex = 0; + let idx = 0; + + // don't split at 0 + while (splitPointsOpt && + nextPointIndex < splitPointsOpt.length && + splitPointsOpt[nextPointIndex] === 0) { + nextPointIndex++; + } + + const spanHandler = (txt: string, cls: string) => { + if ((!splitPointsOpt) || nextPointIndex >= splitPointsOpt.length) { + func(txt, cls); + idx += txt.length; + } else { + const splitPoints = splitPointsOpt; + const pointLocInSpan = splitPoints[nextPointIndex] - idx; + const txtlen = txt.length; + if (pointLocInSpan >= txtlen) { + func(txt, cls); + idx += txt.length; + if (pointLocInSpan === txtlen) { + nextPointIndex++; + } + } else { + if (pointLocInSpan > 0) { + func(txt.substring(0, pointLocInSpan), cls); + idx += pointLocInSpan; + } + nextPointIndex++; + // recurse + spanHandler(txt.substring(pointLocInSpan), cls); + } + } + }; + return spanHandler; + } + getFilterStack = (lineText: string, textAndClassFunc: Function, abrowser?:(tokenText: string, tokenClass: string)=>void) => { + let func = this.getURLFilter(lineText, textAndClassFunc); + + const hookFilters = hooks.callAll('aceGetFilterStack', { + linestylefilter, + browser: abrowser, + }); + hookFilters.map((hookFilter: (arg0: string, arg1: Function) => Function) => { + func = hookFilter(lineText, func); + }); + + return func; + } + + // domLineObj is like that returned by domline.createDomLine + populateDomLine = (textLine: string, aline: string, apool: AttributePool, domLineObj: DomLineObject) => { + // remove final newline from text if any + let text = textLine; + if (text.slice(-1) === '\n') { + text = text.substring(0, text.length - 1); + } + + const textAndClassFunc = (tokenText: string, tokenClass: string) => { + domLineObj.appendSpan(tokenText, tokenClass); + }; + + let func = this.getFilterStack(text, textAndClassFunc); + func = this.getLineStyleFilter(text.length, aline, func, apool); + func(text, ''); + }; +} + +export default new Linestylefilter() + +export const lineAttributeMarker = 'lineAttribMarker'; diff --git a/src/static/js/pad.js b/src/static/js/pad.js index d6648f031..f6970ebbf 100644 --- a/src/static/js/pad.js +++ b/src/static/js/pad.js @@ -37,17 +37,17 @@ const Cookies = require('./pad_utils').Cookies; const chat = require('./chat').chat; const getCollabClient = require('./collab_client').getCollabClient; const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus; -const padcookie = require('./pad_cookie').padcookie; +import padcookie from "./pad_cookie"; const padeditbar = require('./pad_editbar').padeditbar; const padeditor = require('./pad_editor').padeditor; const padimpexp = require('./pad_impexp').padimpexp; const padmodals = require('./pad_modals').padmodals; const padsavedrevs = require('./pad_savedrevs'); const paduserlist = require('./pad_userlist').paduserlist; -const padutils = require('./pad_utils').padutils; +import {padUtils as padutils} from "./pad_utils"; const colorutils = require('./colorutils').colorutils; const randomString = require('./pad_utils').randomString; -const socketio = require('./socketio'); +import connect from './socketio' const hooks = require('./pluginfw/hooks'); @@ -222,7 +222,7 @@ const handshake = async () => { // padId is used here for sharding / scaling. We prefix the padId with padId: so it's clear // to the proxy/gateway/whatever that this is a pad connection and should be treated as such - socket = pad.socket = socketio.connect(exports.baseURL, '/', { + socket = pad.socket = connect(exports.baseURL, '/', { query: {padId}, reconnectionAttempts: 5, reconnection: true, diff --git a/src/static/js/pad_cookie.js b/src/static/js/pad_cookie.ts similarity index 84% rename from src/static/js/pad_cookie.js rename to src/static/js/pad_cookie.ts index 0e946ea5c..ff8f951e2 100644 --- a/src/static/js/pad_cookie.js +++ b/src/static/js/pad_cookie.ts @@ -16,9 +16,12 @@ * limitations under the License. */ -const Cookies = require('./pad_utils').Cookies; +import {Cookies} from './pad_utils' +import html10n from "./vendors/html10n"; + +class PadCookie { + private readonly cookieName_: string -exports.padcookie = new class { constructor() { this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'; } @@ -31,6 +34,7 @@ exports.padcookie = new class { this.writePrefs_(prefs); // Re-read the saved cookie to test if cookies are enabled. if (this.readPrefs_() == null) { + // @ts-ignore $.gritter.add({ title: 'Error', text: html10n.get('pad.noCookie'), @@ -50,15 +54,15 @@ exports.padcookie = new class { } } - writePrefs_(prefs) { + writePrefs_(prefs: object) { Cookies.set(this.cookieName_, JSON.stringify(prefs), {expires: 365 * 100}); } - getPref(prefName) { + getPref(prefName: string) { return this.readPrefs_()[prefName]; } - setPref(prefName, value) { + setPref(prefName: string, value: string) { const prefs = this.readPrefs_(); prefs[prefName] = value; this.writePrefs_(prefs); @@ -67,4 +71,6 @@ exports.padcookie = new class { clear() { this.writePrefs_({}); } -}(); +} + +export default new PadCookie diff --git a/src/static/js/pad_editbar.js b/src/static/js/pad_editbar.js index af8d59f1f..d392fa7a3 100644 --- a/src/static/js/pad_editbar.js +++ b/src/static/js/pad_editbar.js @@ -24,7 +24,8 @@ const browser = require('./vendors/browser'); const hooks = require('./pluginfw/hooks'); -const padutils = require('./pad_utils').padutils; +import {padUtils as padutils} from "./pad_utils"; + const padeditor = require('./pad_editor').padeditor; const padsavedrevs = require('./pad_savedrevs'); const _ = require('underscore'); diff --git a/src/static/js/pad_editor.js b/src/static/js/pad_editor.js index 47a250734..739b73a6a 100644 --- a/src/static/js/pad_editor.js +++ b/src/static/js/pad_editor.js @@ -22,8 +22,9 @@ */ const Cookies = require('./pad_utils').Cookies; -const padcookie = require('./pad_cookie').padcookie; -const padutils = require('./pad_utils').padutils; + +import padcookie from "./pad_cookie"; +import {padUtils as padutils} from "./pad_utils"; const Ace2Editor = require('./ace').Ace2Editor; import html10n from '../js/vendors/html10n' diff --git a/src/static/js/pad_userlist.js b/src/static/js/pad_userlist.js index a0cbd4b44..14d760c0c 100644 --- a/src/static/js/pad_userlist.js +++ b/src/static/js/pad_userlist.js @@ -16,7 +16,7 @@ * limitations under the License. */ -const padutils = require('./pad_utils').padutils; +import {padUtils as padutils} from "./pad_utils"; const hooks = require('./pluginfw/hooks'); import html10n from './vendors/html10n'; let myUserInfo = {}; diff --git a/src/static/js/pad_utils.js b/src/static/js/pad_utils.ts similarity index 58% rename from src/static/js/pad_utils.js rename to src/static/js/pad_utils.ts index 467a8adc9..ab5dde6b6 100644 --- a/src/static/js/pad_utils.js +++ b/src/static/js/pad_utils.ts @@ -22,13 +22,14 @@ * limitations under the License. */ -const Security = require('./security'); +const Security = require('security'); +import jsCookie, {CookiesStatic} from 'js-cookie' /** * Generates a random String with the given length. Is needed to generate the Author, Group, * readonly, session Ids */ -const randomString = (len) => { +export const randomString = (len?: number) => { const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; let randomstring = ''; len = len || 20; @@ -85,13 +86,41 @@ const urlRegex = (() => { 'tel', ].join('|')}):`; return new RegExp( - `(?:${withAuth}|${withoutAuth}|www\\.)${urlChar}*(?!${postUrlPunct})${urlChar}`, 'g'); + `(?:${withAuth}|${withoutAuth}|www\\.)${urlChar}*(?!${postUrlPunct})${urlChar}`, 'g'); })(); // https://stackoverflow.com/a/68957976 const base64url = /^(?=(?:.{4})*$)[A-Za-z0-9_-]*(?:[AQgw]==|[AEIMQUYcgkosw048]=)?$/; -const padutils = { +type PadEvent = { + which: number +} + +type JQueryNode = JQuery + +class PadUtils { + public urlRegex: RegExp + public wordCharRegex: RegExp + public warnDeprecatedFlags: { + disabledForTestingOnly: boolean, + _rl?: { + prevs: Map, + now: () => number, + period: number + } + logger?: any + } + public globalExceptionHandler: null | any = null; + + + constructor() { + this.warnDeprecatedFlags = { + disabledForTestingOnly: false + } + this.wordCharRegex = wordCharRegex + this.urlRegex = urlRegex + } + /** * Prints a warning message followed by a stack trace (to make it easier to figure out what code * is using the deprecated function). @@ -107,41 +136,41 @@ const padutils = { * @param {...*} args - Passed to `padutils.warnDeprecated.logger.warn` (or `console.warn` if no * logger is set), with a stack trace appended if available. */ - warnDeprecated: (...args) => { - if (padutils.warnDeprecated.disabledForTestingOnly) return; + warnDeprecated = (...args: any[]) => { + if (this.warnDeprecatedFlags.disabledForTestingOnly) return; const err = new Error(); - if (Error.captureStackTrace) Error.captureStackTrace(err, padutils.warnDeprecated); + if (Error.captureStackTrace) Error.captureStackTrace(err, this.warnDeprecated); err.name = ''; // Rate limit identical deprecation warnings (as determined by the stack) to avoid log spam. if (typeof err.stack === 'string') { - if (padutils.warnDeprecated._rl == null) { - padutils.warnDeprecated._rl = - {prevs: new Map(), now: () => Date.now(), period: 10 * 60 * 1000}; + if (this.warnDeprecatedFlags._rl == null) { + this.warnDeprecatedFlags._rl = + {prevs: new Map(), now: () => Date.now(), period: 10 * 60 * 1000}; } - const rl = padutils.warnDeprecated._rl; + const rl = this.warnDeprecatedFlags._rl; const now = rl.now(); const prev = rl.prevs.get(err.stack); if (prev != null && now - prev < rl.period) return; rl.prevs.set(err.stack, now); } if (err.stack) args.push(err.stack); - (padutils.warnDeprecated.logger || console).warn(...args); - }, - - escapeHtml: (x) => Security.escapeHTML(String(x)), - uniqueId: () => { + (this.warnDeprecatedFlags.logger || console).warn(...args); + } + escapeHtml = (x: string) => Security.escapeHTML(String(x)) + uniqueId = () => { const pad = require('./pad').pad; // Sidestep circular dependency // returns string that is exactly 'width' chars, padding with zeros and taking rightmost digits const encodeNum = - (n, width) => (Array(width + 1).join('0') + Number(n).toString(35)).slice(-width); + (n: number, width: number) => (Array(width + 1).join('0') + Number(n).toString(35)).slice(-width); return [ pad.getClientIp(), encodeNum(+new Date(), 7), encodeNum(Math.floor(Math.random() * 1e9), 4), ].join('.'); - }, + } + // e.g. "Thu Jun 18 2009 13:09" - simpleDateTime: (date) => { + simpleDateTime = (date: string) => { const d = new Date(+date); // accept either number or date const dayOfWeek = (['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'])[d.getDay()]; const month = ([ @@ -162,16 +191,14 @@ const padutils = { const year = d.getFullYear(); const hourmin = `${d.getHours()}:${(`0${d.getMinutes()}`).slice(-2)}`; return `${dayOfWeek} ${month} ${dayOfMonth} ${year} ${hourmin}`; - }, - wordCharRegex, - urlRegex, + } // returns null if no URLs, or [[startIndex1, url1], [startIndex2, url2], ...] - findURLs: (text) => { + findURLs = (text: string) => { // Copy padutils.urlRegex so that the use of .exec() below (which mutates the RegExp object) // does not break other concurrent uses of padutils.urlRegex. - const urlRegex = new RegExp(padutils.urlRegex, 'g'); + const urlRegex = new RegExp(this.urlRegex, 'g'); urlRegex.lastIndex = 0; - let urls = null; + let urls: [number, string][] | null = null; let execResult; // TODO: Switch to String.prototype.matchAll() after support for Node.js < 12.0.0 is dropped. while ((execResult = urlRegex.exec(text))) { @@ -181,18 +208,19 @@ const padutils = { urls.push([startIndex, url]); } return urls; - }, - escapeHtmlWithClickableLinks: (text, target) => { + } + escapeHtmlWithClickableLinks = (text: string, target: string) => { let idx = 0; const pieces = []; - const urls = padutils.findURLs(text); + const urls = this.findURLs(text); - const advanceTo = (i) => { - if (i > idx) { - pieces.push(Security.escapeHTML(text.substring(idx, i))); - idx = i; + const advanceTo = (i: number) => { + if (i > idx) { + pieces.push(Security.escapeHTML(text.substring(idx, i))); + idx = i; + } } - }; + ; if (urls) { for (let j = 0; j < urls.length; j++) { const startIndex = urls[j][0]; @@ -206,25 +234,25 @@ const padutils = { // https://mathiasbynens.github.io/rel-noopener/ // https://github.com/ether/etherpad-lite/pull/3636 pieces.push( - ''); + ''); advanceTo(startIndex + href.length); pieces.push(''); } } advanceTo(text.length); return pieces.join(''); - }, - bindEnterAndEscape: (node, onEnter, onEscape) => { + } + bindEnterAndEscape = (node: JQueryNode, onEnter: Function, onEscape: Function) => { // Use keypress instead of keyup in bindEnterAndEscape. Keyup event is fired on enter in IME // (Input Method Editor), But keypress is not. So, I changed to use keypress instead of keyup. // It is work on Windows (IE8, Chrome 6.0.472), CentOs (Firefox 3.0) and Mac OSX (Firefox // 3.6.10, Chrome 6.0.472, Safari 5.0). if (onEnter) { - node.on('keypress', (evt) => { + node.on('keypress', (evt: { which: number; }) => { if (evt.which === 13) { onEnter(evt); } @@ -238,13 +266,15 @@ const padutils = { } }); } - }, - timediff: (d) => { + } + + timediff = (d: number) => { const pad = require('./pad').pad; // Sidestep circular dependency - const format = (n, word) => { - n = Math.round(n); - return (`${n} ${word}${n !== 1 ? 's' : ''} ago`); - }; + const format = (n: number, word: string) => { + n = Math.round(n); + return (`${n} ${word}${n !== 1 ? 's' : ''} ago`); + } + ; d = Math.max(0, (+(new Date()) - (+d) - pad.clientTimeOffset) / 1000); if (d < 60) { return format(d, 'second'); @@ -259,78 +289,89 @@ const padutils = { } d /= 24; return format(d, 'day'); - }, - makeAnimationScheduler: (funcToAnimateOneStep, stepTime, stepsAtOnce) => { - if (stepsAtOnce === undefined) { - stepsAtOnce = 1; + } + makeAnimationScheduler = + (funcToAnimateOneStep: any, stepTime: number, stepsAtOnce?: number) => { + if (stepsAtOnce === undefined) { + stepsAtOnce = 1; + } + + let animationTimer: any = null; + + const scheduleAnimation = () => { + if (!animationTimer) { + animationTimer = window.setTimeout(() => { + animationTimer = null; + let n = stepsAtOnce; + let moreToDo = true; + while (moreToDo && n > 0) { + moreToDo = funcToAnimateOneStep(); + n--; + } + if (moreToDo) { + // more to do + scheduleAnimation(); + } + }, stepTime * stepsAtOnce); + } + }; + return {scheduleAnimation}; } - let animationTimer = null; + makeFieldLabeledWhenEmpty + = + (field: JQueryNode, labelText: string) => { + field = $(field); - const scheduleAnimation = () => { - if (!animationTimer) { - animationTimer = window.setTimeout(() => { - animationTimer = null; - let n = stepsAtOnce; - let moreToDo = true; - while (moreToDo && n > 0) { - moreToDo = funcToAnimateOneStep(); - n--; - } - if (moreToDo) { - // more to do - scheduleAnimation(); - } - }, stepTime * stepsAtOnce); - } - }; - return {scheduleAnimation}; - }, - makeFieldLabeledWhenEmpty: (field, labelText) => { - field = $(field); - - const clear = () => { - field.addClass('editempty'); - field.val(labelText); - }; - field.focus(() => { - if (field.hasClass('editempty')) { - field.val(''); - } - field.removeClass('editempty'); - }); - field.on('blur', () => { - if (!field.val()) { - clear(); - } - }); - return { - clear, - }; - }, - getCheckbox: (node) => $(node).is(':checked'), - setCheckbox: (node, value) => { - if (value) { - $(node).attr('checked', 'checked'); - } else { - $(node).prop('checked', false); + const clear = () => { + field.addClass('editempty'); + field.val(labelText); + } + ; + field.focus(() => { + if (field.hasClass('editempty')) { + field.val(''); + } + field.removeClass('editempty'); + }); + field.on('blur', () => { + if (!field.val()) { + clear(); + } + }); + return { + clear, + }; } - }, - bindCheckboxChange: (node, func) => { - $(node).on('change', func); - }, - encodeUserId: (userId) => userId.replace(/[^a-y0-9]/g, (c) => { - if (c === '.') return '-'; - return `z${c.charCodeAt(0)}z`; - }), - decodeUserId: (encodedUserId) => encodedUserId.replace(/[a-y0-9]+|-|z.+?z/g, (cc) => { - if (cc === '-') { return '.'; } else if (cc.charAt(0) === 'z') { - return String.fromCharCode(Number(cc.slice(1, -1))); - } else { - return cc; + getCheckbox = (node: JQueryNode) => $(node).is(':checked') + setCheckbox = + (node: JQueryNode, value: string) => { + if (value) { + $(node).attr('checked', 'checked'); + } else { + $(node).prop('checked', false); + } } - }), - + bindCheckboxChange = + (node: JQueryNode, func: Function) => { + // @ts-ignore + $(node).on("change", func); + } + encodeUserId = + (userId: string) => userId.replace(/[^a-y0-9]/g, (c) => { + if (c === '.') return '-'; + return `z${c.charCodeAt(0)}z`; + }) + decodeUserId = + (encodedUserId: string) => encodedUserId.replace(/[a-y0-9]+|-|z.+?z/g, (cc) => { + if (cc === '-') { + return '.'; + } else if (cc.charAt(0) === 'z') { + return String.fromCharCode(Number(cc.slice(1, -1))); + } else { + return cc; + } + }) /** * Returns whether a string has the expected format to be used as a secret token identifying an * author. The format is defined as: 't.' followed by a non-empty base64url string (RFC 4648 @@ -340,109 +381,109 @@ const padutils = { * conditional transformation of a token to a database key in a way that does not allow a * malicious user to impersonate another user). */ - isValidAuthorToken: (t) => { + isValidAuthorToken = (t: string | object) => { if (typeof t !== 'string' || !t.startsWith('t.')) return false; const v = t.slice(2); return v.length > 0 && base64url.test(v); - }, + } + /** * Returns a string that can be used in the `token` cookie as a secret that authenticates a * particular author. */ - generateAuthorToken: () => `t.${randomString()}`, -}; - -let globalExceptionHandler = null; -padutils.setupGlobalExceptionHandler = () => { - if (globalExceptionHandler == null) { - globalExceptionHandler = (e) => { - let type; - let err; - let msg, url, linenumber; - if (e instanceof ErrorEvent) { - type = 'Uncaught exception'; - err = e.error || {}; - ({message: msg, filename: url, lineno: linenumber} = e); - } else if (e instanceof PromiseRejectionEvent) { - type = 'Unhandled Promise rejection'; - err = e.reason || {}; - ({message: msg = 'unknown', fileName: url = 'unknown', lineNumber: linenumber = -1} = err); - } else { - throw new Error(`unknown event: ${e.toString()}`); - } - if (err.name != null && msg !== err.name && !msg.startsWith(`${err.name}: `)) { - msg = `${err.name}: ${msg}`; - } - const errorId = randomString(20); - - let msgAlreadyVisible = false; - $('.gritter-item .error-msg').each(function () { - if ($(this).text() === msg) { - msgAlreadyVisible = true; + generateAuthorToken = () => `t.${randomString()}` + setupGlobalExceptionHandler = () => { + if (this.globalExceptionHandler == null) { + this.globalExceptionHandler = (e: any) => { + let type; + let err; + let msg, url, linenumber; + if (e instanceof ErrorEvent) { + type = 'Uncaught exception'; + err = e.error || {}; + ({message: msg, filename: url, lineno: linenumber} = e); + } else if (e instanceof PromiseRejectionEvent) { + type = 'Unhandled Promise rejection'; + err = e.reason || {}; + ({message: msg = 'unknown', fileName: url = 'unknown', lineNumber: linenumber = -1} = err); + } else { + throw new Error(`unknown event: ${e.toString()}`); } - }); + if (err.name != null && msg !== err.name && !msg.startsWith(`${err.name}: `)) { + msg = `${err.name}: ${msg}`; + } + const errorId = randomString(20); - if (!msgAlreadyVisible) { - const txt = document.createTextNode.bind(document); // Convenience shorthand. - const errorMsg = [ - $('

') + let msgAlreadyVisible = false; + $('.gritter-item .error-msg').each(function () { + if ($(this).text() === msg) { + msgAlreadyVisible = true; + } + }); + + if (!msgAlreadyVisible) { + const txt = document.createTextNode.bind(document); // Convenience shorthand. + const errorMsg = [ + $('

') .append($('').text('Please press and hold Ctrl and press F5 to reload this page')), - $('

') + $('

') .text('If the problem persists, please send this error message to your webmaster:'), - $('

').css('text-align', 'left').css('font-size', '.8em').css('margin-top', '1em') + $('
').css('text-align', 'left').css('font-size', '.8em').css('margin-top', '1em') .append($('').addClass('error-msg').text(msg)).append($('
')) .append(txt(`at ${url} at line ${linenumber}`)).append($('
')) .append(txt(`ErrorId: ${errorId}`)).append($('
')) .append(txt(type)).append($('
')) .append(txt(`URL: ${window.location.href}`)).append($('
')) .append(txt(`UserAgent: ${navigator.userAgent}`)).append($('
')), - ]; + ]; - $.gritter.add({ - title: 'An error occurred', - text: errorMsg, - class_name: 'error', - position: 'bottom', - sticky: true, + // @ts-ignore + $.gritter.add({ + title: 'An error occurred', + text: errorMsg, + class_name: 'error', + position: 'bottom', + sticky: true, + }); + } + + // send javascript errors to the server + $.post('../jserror', { + errorInfo: JSON.stringify({ + errorId, + type, + msg, + url: window.location.href, + source: url, + linenumber, + userAgent: navigator.userAgent, + stack: err.stack, + }), }); - } - - // send javascript errors to the server - $.post('../jserror', { - errorInfo: JSON.stringify({ - errorId, - type, - msg, - url: window.location.href, - source: url, - linenumber, - userAgent: navigator.userAgent, - stack: err.stack, - }), - }); - }; - window.onerror = null; // Clear any pre-existing global error handler. - window.addEventListener('error', globalExceptionHandler); - window.addEventListener('unhandledrejection', globalExceptionHandler); + }; + window.onerror = null; // Clear any pre-existing global error handler. + window.addEventListener('error', this.globalExceptionHandler); + window.addEventListener('unhandledrejection', this.globalExceptionHandler); + } } -}; - -padutils.binarySearch = require('./ace2_common').binarySearch; + binarySearch = require('./ace2_common').binarySearch +} // https://stackoverflow.com/a/42660748 const inThirdPartyIframe = () => { try { - return (!window.top.location.hostname); + return (!window.top!.location.hostname); } catch (e) { return true; } }; +export let Cookies: CookiesStatic // This file is included from Node so that it can reuse randomString, but Node doesn't have a global // window object. if (typeof window !== 'undefined') { - exports.Cookies = require('js-cookie').withAttributes({ + Cookies = jsCookie.withAttributes({ // Use `SameSite=Lax`, unless Etherpad is embedded in an iframe from another site in which case // use `SameSite=None`. For iframes from another site, only `None` has a chance of working // because the cookies are third-party (not same-site). Many browsers/users block third-party @@ -455,5 +496,5 @@ if (typeof window !== 'undefined') { secure: window.location.protocol === 'https:', }); } -exports.randomString = randomString; -exports.padutils = padutils; + +export const padUtils = new PadUtils() diff --git a/src/static/js/rjquery.js b/src/static/js/rjquery.js deleted file mode 100644 index a80e1f8d3..000000000 --- a/src/static/js/rjquery.js +++ /dev/null @@ -1,5 +0,0 @@ -'use strict'; -// Provides a require'able version of jQuery without leaking $ and jQuery; -window.$ = require('./vendors/jquery'); -const jq = window.$.noConflict(true); -exports.jQuery = exports.$ = jq; diff --git a/src/static/js/security.js b/src/static/js/security.js deleted file mode 100644 index d92425cb7..000000000 --- a/src/static/js/security.js +++ /dev/null @@ -1,19 +0,0 @@ -'use strict'; - -/** - * Copyright 2009 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS-IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -module.exports = require('security'); diff --git a/src/static/js/skin_variants.js b/src/static/js/skin_variants.ts similarity index 89% rename from src/static/js/skin_variants.js rename to src/static/js/skin_variants.ts index 9a0427ac9..7856b3069 100644 --- a/src/static/js/skin_variants.js +++ b/src/static/js/skin_variants.ts @@ -1,5 +1,3 @@ -'use strict'; - // Specific hash to display the skin variants builder popup if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') { $('#skin-variants').addClass('popup-show'); @@ -22,7 +20,7 @@ if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') { domsToUpdate.forEach((el) => { el.removeClass('full-width-editor'); }); - const newClasses = []; + const newClasses:string[] = []; $('select.skin-variant-color').each(function () { newClasses.push(`${$(this).val()}-${$(this).data('container')}`); }); @@ -35,7 +33,8 @@ if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') { // run on init const updateCheckboxFromSkinClasses = () => { - $('html').attr('class').split(' ').forEach((classItem) => { + const htmlTag = $('html') + htmlTag.attr('class')!.split(' ').forEach((classItem) => { const container = classItem.substring(classItem.lastIndexOf('-') + 1, classItem.length); if (containers.indexOf(container) > -1) { const color = classItem.substring(0, classItem.lastIndexOf('-')); @@ -43,7 +42,7 @@ if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') { } }); - $('#skin-variant-full-width').prop('checked', $('html').hasClass('full-width-editor')); + $('#skin-variant-full-width').prop('checked', htmlTag.hasClass('full-width-editor')); }; $('.skin-variant').on('change', () => { diff --git a/src/static/js/skiplist.js b/src/static/js/skiplist.ts similarity index 61% rename from src/static/js/skiplist.js rename to src/static/js/skiplist.ts index f10a4e7a8..f246884db 100644 --- a/src/static/js/skiplist.js +++ b/src/static/js/skiplist.ts @@ -22,10 +22,24 @@ * limitations under the License. */ -const _entryWidth = (e) => (e && e.width) || 0; +const _entryWidth = (e: Entry) => (e && e.width) || 0; + +type Entry = { + key: string, + value: string + width: number +} class Node { - constructor(entry, levels = 0, downSkips = 1, downSkipWidths = 0) { + public key: string|null + readonly entry: Entry|null + levels: number + upPtrs: Node[] + downPtrs: Node[] + downSkips: number[] + readonly downSkipWidths: number[] + + constructor(entry: Entry|null, levels = 0, downSkips: number|null = 1, downSkipWidths:number|null = 0) { this.key = entry != null ? entry.key : null; this.entry = entry; this.levels = levels; @@ -37,9 +51,9 @@ class Node { propagateWidthChange() { const oldWidth = this.downSkipWidths[0]; - const newWidth = _entryWidth(this.entry); + const newWidth = _entryWidth(this.entry!); const widthChange = newWidth - oldWidth; - let n = this; + let n: Node = this; let lvl = 0; while (lvl < n.levels) { n.downSkipWidths[lvl] += widthChange; @@ -57,17 +71,23 @@ class Node { // is still valid and points to the same index in the skiplist. Other operations with other points // invalidate this point. class Point { - constructor(skipList, loc) { - this._skipList = skipList; + private skipList: SkipList + private readonly loc: number + private readonly idxs: number[] + private readonly nodes: Node[] + private widthSkips: number[] + + constructor(skipList: SkipList, loc: number) { + this.skipList = skipList; this.loc = loc; - const numLevels = this._skipList._start.levels; + const numLevels = this.skipList.start.levels; let lvl = numLevels - 1; let i = -1; let ws = 0; - const nodes = new Array(numLevels); - const idxs = new Array(numLevels); - const widthSkips = new Array(numLevels); - nodes[lvl] = this._skipList._start; + const nodes: Node[] = new Array(numLevels); + const idxs: number[] = new Array(numLevels); + const widthSkips: number[] = new Array(numLevels); + nodes[lvl] = this.skipList.start; idxs[lvl] = -1; widthSkips[lvl] = 0; while (lvl >= 0) { @@ -94,9 +114,9 @@ class Point { return `Point(${this.loc})`; } - insert(entry) { + insert(entry: Entry) { if (entry.key == null) throw new Error('entry.key must not be null'); - if (this._skipList.containsKey(entry.key)) { + if (this.skipList.containsKey(entry.key)) { throw new Error(`an entry with key ${entry.key} already exists`); } @@ -115,14 +135,14 @@ class Point { if (lvl === pNodes.length) { // assume we have just passed the end of this.nodes, and reached one level greater // than the skiplist currently supports - pNodes[lvl] = this._skipList._start; + pNodes[lvl] = this.skipList.start; pIdxs[lvl] = -1; - this._skipList._start.levels++; - this._skipList._end.levels++; - this._skipList._start.downPtrs[lvl] = this._skipList._end; - this._skipList._end.upPtrs[lvl] = this._skipList._start; - this._skipList._start.downSkips[lvl] = this._skipList._keyToNodeMap.size + 1; - this._skipList._start.downSkipWidths[lvl] = this._skipList._totalWidth; + this.skipList.start.levels++; + this.skipList.end.levels++; + this.skipList.start.downPtrs[lvl] = this.skipList.end; + this.skipList.end.upPtrs[lvl] = this.skipList.start; + this.skipList.start.downSkips[lvl] = this.skipList.keyToNodeMap.size + 1; + this.skipList.start.downSkipWidths[lvl] = this.skipList.totalWidth; this.widthSkips[lvl] = 0; } const me = newNode; @@ -146,13 +166,13 @@ class Point { up.downSkips[lvl]++; up.downSkipWidths[lvl] += newWidth; } - this._skipList._keyToNodeMap.set(newNode.key, newNode); - this._skipList._totalWidth += newWidth; + this.skipList.keyToNodeMap.set(newNode.key as string, newNode); + this.skipList.totalWidth += newWidth; } delete() { const elem = this.nodes[0].downPtrs[0]; - const elemWidth = _entryWidth(elem.entry); + const elemWidth = _entryWidth(elem.entry!); for (let i = 0; i < this.nodes.length; i++) { if (i < elem.levels) { const up = elem.upPtrs[i]; @@ -169,8 +189,8 @@ class Point { up.downSkipWidths[i] -= elemWidth; } } - this._skipList._keyToNodeMap.delete(elem.key); - this._skipList._totalWidth -= elemWidth; + this.skipList.keyToNodeMap.delete(elem.key as string); + this.skipList.totalWidth -= elemWidth; } getNode() { @@ -183,20 +203,26 @@ class Point { * property that is a string. */ class SkipList { + start: Node + end: Node + totalWidth: number + keyToNodeMap: Map + + constructor() { // if there are N elements in the skiplist, "start" is element -1 and "end" is element N - this._start = new Node(null, 1); - this._end = new Node(null, 1, null, null); - this._totalWidth = 0; - this._keyToNodeMap = new Map(); - this._start.downPtrs[0] = this._end; - this._end.upPtrs[0] = this._start; + this.start = new Node(null, 1); + this.end = new Node(null, 1, null, null); + this.totalWidth = 0; + this.keyToNodeMap = new Map(); + this.start.downPtrs[0] = this.end; + this.end.upPtrs[0] = this.start; } - _getNodeAtOffset(targetOffset) { + _getNodeAtOffset(targetOffset: number) { let i = 0; - let n = this._start; - let lvl = this._start.levels - 1; + let n = this.start; + let lvl = this.start.levels - 1; while (lvl >= 0 && n.downPtrs[lvl]) { while (n.downPtrs[lvl] && (i + n.downSkipWidths[lvl] <= targetOffset)) { i += n.downSkipWidths[lvl]; @@ -204,17 +230,17 @@ class SkipList { } lvl--; } - if (n === this._start) return (this._start.downPtrs[0] || null); - if (n === this._end) { - return targetOffset === this._totalWidth ? (this._end.upPtrs[0] || null) : null; + if (n === this.start) return (this.start.downPtrs[0] || null); + if (n === this.end) { + return targetOffset === this.totalWidth ? (this.end.upPtrs[0] || null) : null; } return n; } - _getNodeIndex(node, byWidth) { + _getNodeIndex(node: Node, byWidth?: boolean) { let dist = (byWidth ? 0 : -1); let n = node; - while (n !== this._start) { + while (n !== this.start) { const lvl = n.levels - 1; n = n.upPtrs[lvl]; if (byWidth) dist += n.downSkipWidths[lvl]; @@ -226,14 +252,14 @@ class SkipList { // Returns index of first entry such that entryFunc(entry) is truthy, // or length() if no such entry. Assumes all falsy entries come before // all truthy entries. - search(entryFunc) { - let low = this._start; - let lvl = this._start.levels - 1; + search(entryFunc: Function) { + let low = this.start; + let lvl = this.start.levels - 1; let lowIndex = -1; - const f = (node) => { - if (node === this._start) return false; - else if (node === this._end) return true; + const f = (node: Node) => { + if (node === this.start) return false; + else if (node === this.end) return true; else return entryFunc(node.entry); }; @@ -249,20 +275,20 @@ class SkipList { return lowIndex + 1; } - length() { return this._keyToNodeMap.size; } + length() { return this.keyToNodeMap.size; } - atIndex(i) { + atIndex(i: number) { if (i < 0) console.warn(`atIndex(${i})`); - if (i >= this._keyToNodeMap.size) console.warn(`atIndex(${i}>=${this._keyToNodeMap.size})`); + if (i >= this.keyToNodeMap.size) console.warn(`atIndex(${i}>=${this.keyToNodeMap.size})`); return (new Point(this, i)).getNode().entry; } // differs from Array.splice() in that new elements are in an array, not varargs - splice(start, deleteCount, newEntryArray) { + splice(start: number, deleteCount: number, newEntryArray: Entry[]) { if (start < 0) console.warn(`splice(${start}, ...)`); - if (start + deleteCount > this._keyToNodeMap.size) { - console.warn(`splice(${start}, ${deleteCount}, ...), N=${this._keyToNodeMap.size}`); - console.warn('%s %s %s', typeof start, typeof deleteCount, typeof this._keyToNodeMap.size); + if (start + deleteCount > this.keyToNodeMap.size) { + console.warn(`splice(${start}, ${deleteCount}, ...), N=${this.keyToNodeMap.size}`); + console.warn('%s %s %s', typeof start, typeof deleteCount, typeof this.keyToNodeMap.size); console.trace(); } @@ -275,56 +301,55 @@ class SkipList { } } - next(entry) { return this._keyToNodeMap.get(entry.key).downPtrs[0].entry || null; } - prev(entry) { return this._keyToNodeMap.get(entry.key).upPtrs[0].entry || null; } - push(entry) { this.splice(this._keyToNodeMap.size, 0, [entry]); } + next(entry: Entry) { return this.keyToNodeMap.get(entry.key)!.downPtrs[0].entry || null; } + prev(entry: Entry) { return this.keyToNodeMap.get(entry.key)!.upPtrs[0].entry || null; } + push(entry: Entry) { this.splice(this.keyToNodeMap.size, 0, [entry]); } - slice(start, end) { + slice(start: number, end: number) { // act like Array.slice() if (start === undefined) start = 0; - else if (start < 0) start += this._keyToNodeMap.size; - if (end === undefined) end = this._keyToNodeMap.size; - else if (end < 0) end += this._keyToNodeMap.size; + else if (start < 0) start += this.keyToNodeMap.size; + if (end === undefined) end = this.keyToNodeMap.size; + else if (end < 0) end += this.keyToNodeMap.size; if (start < 0) start = 0; - if (start > this._keyToNodeMap.size) start = this._keyToNodeMap.size; + if (start > this.keyToNodeMap.size) start = this.keyToNodeMap.size; if (end < 0) end = 0; - if (end > this._keyToNodeMap.size) end = this._keyToNodeMap.size; + if (end > this.keyToNodeMap.size) end = this.keyToNodeMap.size; if (end <= start) return []; let n = this.atIndex(start); const array = [n]; for (let i = 1; i < (end - start); i++) { - n = this.next(n); + n = this.next(n!); array.push(n); } return array; } - atKey(key) { return this._keyToNodeMap.get(key).entry; } - indexOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key)); } - indexOfEntry(entry) { return this.indexOfKey(entry.key); } - containsKey(key) { return this._keyToNodeMap.has(key); } + atKey(key: string) { return this.keyToNodeMap.get(key)!.entry; } + indexOfKey(key: string) { return this._getNodeIndex(this.keyToNodeMap.get(key)!); } + indexOfEntry(entry: Entry) { return this.indexOfKey(entry.key); } + containsKey(key: string) { return this.keyToNodeMap.has(key); } // gets the last entry starting at or before the offset - atOffset(offset) { return this._getNodeAtOffset(offset).entry; } - keyAtOffset(offset) { return this.atOffset(offset).key; } - offsetOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key), true); } - offsetOfEntry(entry) { return this.offsetOfKey(entry.key); } - setEntryWidth(entry, width) { + atOffset(offset: number) { return this._getNodeAtOffset(offset)!.entry; } + keyAtOffset(offset: number) { return this.atOffset(offset)!.key; } + offsetOfKey(key: string) { return this._getNodeIndex(this.keyToNodeMap.get(key)!, true); } + offsetOfEntry(entry: Entry) { return this.offsetOfKey(entry.key); } + setEntryWidth(entry: Entry, width: number) { entry.width = width; - this._totalWidth += this._keyToNodeMap.get(entry.key).propagateWidthChange(); + this.totalWidth += this.keyToNodeMap.get(entry.key)!.propagateWidthChange(); } - totalWidth() { return this._totalWidth; } - offsetOfIndex(i) { + offsetOfIndex(i: number) { if (i < 0) return 0; - if (i >= this._keyToNodeMap.size) return this._totalWidth; - return this.offsetOfEntry(this.atIndex(i)); + if (i >= this.keyToNodeMap.size) return this.totalWidth; + return this.offsetOfEntry(this.atIndex(i)!); } - indexOfOffset(offset) { + indexOfOffset(offset: number) { if (offset <= 0) return 0; - if (offset >= this._totalWidth) return this._keyToNodeMap.size; - return this.indexOfEntry(this.atOffset(offset)); + if (offset >= this.totalWidth) return this.keyToNodeMap.size; + return this.indexOfEntry(this.atOffset(offset)!); } } -module.exports = SkipList; +export default SkipList diff --git a/src/static/js/socketio.js b/src/static/js/socketio.ts similarity index 82% rename from src/static/js/socketio.js rename to src/static/js/socketio.ts index cdc1c9a23..ca91572c9 100644 --- a/src/static/js/socketio.js +++ b/src/static/js/socketio.ts @@ -1,4 +1,5 @@ import io from 'socket.io-client'; +import {Socket} from "socket.io"; /** * Creates a socket.io connection. @@ -9,14 +10,14 @@ import io from 'socket.io-client'; * https://socket.io/docs/v2/client-api/#new-Manager-url-options * @return socket.io Socket object */ -const connect = (etherpadBaseUrl, namespace = '/', options = {}) => { +const connect = (etherpadBaseUrl: string, namespace = '/', options = {}): Socket => { // The API for socket.io's io() function is awkward. The documentation says that the first // argument is a URL, but it is not the URL of the socket.io endpoint. The URL's path part is used // as the name of the socket.io namespace to join, and the rest of the URL (including query // parameters, if present) is combined with the `path` option (which defaults to '/socket.io', but // is overridden here to allow users to host Etherpad at something like '/etherpad') to get the // URL of the socket.io endpoint. - const baseUrl = new URL(etherpadBaseUrl, window.location); + const baseUrl = new URL(etherpadBaseUrl, window.location.href); const socketioUrl = new URL('socket.io', baseUrl); const namespaceUrl = new URL(namespace, new URL('/', baseUrl)); @@ -27,7 +28,7 @@ const connect = (etherpadBaseUrl, namespace = '/', options = {}) => { }; socketOptions = Object.assign(options, socketOptions); - const socket = io(namespaceUrl.href, socketOptions); + const socket = io(namespaceUrl.href, socketOptions) as unknown as Socket; socket.on('connect_error', (error) => { console.log('Error connecting to pad', error); @@ -41,8 +42,8 @@ const connect = (etherpadBaseUrl, namespace = '/', options = {}) => { return socket; }; -if (typeof exports === 'object') { - exports.connect = connect; -} else { - window.socketio = {connect}; -} + +export default connect + + // @ts-ignore +window.socketio = {connect}; diff --git a/src/static/js/timeslider.js b/src/static/js/timeslider.ts similarity index 82% rename from src/static/js/timeslider.js rename to src/static/js/timeslider.ts index 8d8604b91..f146b3fc3 100644 --- a/src/static/js/timeslider.js +++ b/src/static/js/timeslider.ts @@ -24,20 +24,28 @@ // These jQuery things should create local references, but for now `require()` // assigns to the global `$` and augments it with plugins. -require('./vendors/jquery'); -const Cookies = require('./pad_utils').Cookies; -const randomString = require('./pad_utils').randomString; +import {Cookies} from "./pad_utils"; +import {randomString, padUtils as padutils} from "./pad_utils"; const hooks = require('./pluginfw/hooks'); -const padutils = require('./pad_utils').padutils; -const socketio = require('./socketio'); +import connect from './socketio' import html10n from '../js/vendors/html10n' -let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider; +import {Socket} from "socket.io"; +import {ClientVarMessage, SocketIOMessage} from "./types/SocketIOMessage"; +import {Func} from "mocha"; -const init = () => { +type ChangeSetLoader = { + handleMessageFromServer(msg: ClientVarMessage): void +} + + +export let token: string, padId: string, exportLinks: JQuery, socket: Socket, changesetLoader: ChangeSetLoader, BroadcastSlider: any; + +export const init = () => { padutils.setupGlobalExceptionHandler(); $(document).ready(() => { // start the custom js + // @ts-ignore if (typeof customStart === 'function') customStart(); // eslint-disable-line no-undef // get the padId out of the url @@ -48,13 +56,13 @@ const init = () => { document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`; // ensure we have a token - token = Cookies.get('token'); + token = Cookies.get('token')!; if (token == null) { token = `t.${randomString()}`; Cookies.set('token', token, {expires: 60}); } - socket = socketio.connect(exports.baseURL, '/', {query: {padId}}); + socket = connect(baseURL, '/', {query: {padId}}); // send the ready message once we're connected socket.on('connect', () => { @@ -65,11 +73,11 @@ const init = () => { BroadcastSlider.showReconnectUI(); // The socket.io client will automatically try to reconnect for all reasons other than "io // server disconnect". - if (reason === 'io server disconnect') socket.connect(); + console.log("Disconnected") }); // route the incoming messages - socket.on('message', (message) => { + socket.on('message', (message: ClientVarMessage) => { if (message.type === 'CLIENT_VARS') { handleClientVars(message); } else if (message.accessStatus) { @@ -85,16 +93,12 @@ const init = () => { $('button#forcereconnect').on('click', () => { window.location.reload(); }); - - exports.socket = socket; // make the socket available - exports.BroadcastSlider = BroadcastSlider; // Make the slider available - hooks.aCallAll('postTimesliderInit'); }); }; // sends a message over the socket -const sendSocketMsg = (type, data) => { +const sendSocketMsg = (type: string, data: Object) => { socket.emit("message", { component: 'pad', // FIXME: Remove this stupidity! type, @@ -105,9 +109,9 @@ const sendSocketMsg = (type, data) => { }); }; -const fireWhenAllScriptsAreLoaded = []; +const fireWhenAllScriptsAreLoaded: Function[] = []; -const handleClientVars = (message) => { +const handleClientVars = (message: ClientVarMessage) => { // save the client Vars window.clientVars = message.data; @@ -140,13 +144,15 @@ const handleClientVars = (message) => { const baseURI = document.location.pathname; // change export urls when the slider moves - BroadcastSlider.onSlider((revno) => { + BroadcastSlider.onSlider((revno: number) => { // exportLinks is a jQuery Array, so .each is allowed. exportLinks.each(function () { // Modified from regular expression to fix: // https://github.com/ether/etherpad-lite/issues/4071 // Where a padId that was numeric would create the wrong export link + // @ts-ignore if (this.href) { + // @ts-ignore const type = this.href.split('export/')[1]; let href = baseURI.split('timeslider')[0]; href += `${revno}/export/${type}`; @@ -159,7 +165,7 @@ const handleClientVars = (message) => { for (let i = 0; i < fireWhenAllScriptsAreLoaded.length; i++) { fireWhenAllScriptsAreLoaded[i](); } - $('#ui-slider-handle').css('left', $('#ui-slider-bar').width() - 2); + $('#ui-slider-handle').css('left', $('#ui-slider-bar').width()! - 2); // Translate some strings where we only want to set the title not the actual values $('#playpause_button_icon').attr('title', html10n.get('timeslider.playPause')); @@ -168,9 +174,13 @@ const handleClientVars = (message) => { // font family change $('#viewfontmenu').on('change', function () { + // @ts-ignore $('#innerdocbody').css('font-family', $(this).val() || ''); }); }; -exports.baseURL = ''; -exports.init = init; +export let baseURL = '' + +export const setBaseURl = (url: string)=>{ + baseURL = url +} diff --git a/src/static/js/types/Attribute.ts b/src/static/js/types/Attribute.ts new file mode 100644 index 000000000..f1c06b3cb --- /dev/null +++ b/src/static/js/types/Attribute.ts @@ -0,0 +1 @@ +export type Attribute = [string, string] diff --git a/src/static/js/types/ChangeSetBuilder.ts b/src/static/js/types/ChangeSetBuilder.ts new file mode 100644 index 000000000..6f3919352 --- /dev/null +++ b/src/static/js/types/ChangeSetBuilder.ts @@ -0,0 +1,7 @@ +import {Attribute} from "./Attribute"; +import AttributePool from "../AttributePool"; + +export type ChangeSetBuilder = { + remove: (start: number, end?: number)=>void, + keep: (start: number, end?: number, attribs?: Attribute[], pool?: AttributePool)=>void +} diff --git a/src/static/js/types/RangePos.ts b/src/static/js/types/RangePos.ts new file mode 100644 index 000000000..be611a1ff --- /dev/null +++ b/src/static/js/types/RangePos.ts @@ -0,0 +1 @@ +export type RangePos = [number, number] diff --git a/src/static/js/types/RepModel.ts b/src/static/js/types/RepModel.ts index 821549e1d..f6a02ad20 100644 --- a/src/static/js/types/RepModel.ts +++ b/src/static/js/types/RepModel.ts @@ -1,13 +1,21 @@ +import AttributePool from "../AttributePool"; +import {RangePos} from "./RangePos"; + export type RepModel = { lines: { atIndex: (num: number)=>RepNode, offsetOfIndex: (range: number)=>number, search: (filter: (e: RepNode)=>boolean)=>number, - length: ()=>number + length: ()=>number, + totalWidth: ()=>number + } + selStart: RangePos, + selEnd: RangePos, + selFocusAtStart: boolean, + apool: AttributePool, + alines: { + [key:string]: any } - selStart: number[], - selEnd: number[], - selFocusAtStart: boolean } export type Position = { @@ -22,7 +30,8 @@ export type RepNode = { length: number, lastChild: RepNode, offsetHeight: number, - offsetTop: number + offsetTop: number, + text: string } export type WindowElementWithScrolling = HTMLIFrameElement & { diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts new file mode 100644 index 000000000..ca5c629e9 --- /dev/null +++ b/src/static/js/types/SocketIOMessage.ts @@ -0,0 +1,13 @@ +export type SocketIOMessage = { + type: string + accessStatus: string +} + + +export type ClientVarMessage = { + data: { + sessionRefreshInterval: number + } + type: string + accessStatus: string +} diff --git a/src/static/js/types/Window.ts b/src/static/js/types/Window.ts new file mode 100644 index 000000000..df19bc8cf --- /dev/null +++ b/src/static/js/types/Window.ts @@ -0,0 +1,6 @@ +declare global { + interface Window { + clientVars: any; + $: any + } +} diff --git a/src/static/js/underscore.js b/src/static/js/underscore.js deleted file mode 100644 index d30543cab..000000000 --- a/src/static/js/underscore.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict'; - -module.exports = require('underscore'); diff --git a/src/static/js/undomodule.js b/src/static/js/undomodule.js deleted file mode 100644 index d0b83419d..000000000 --- a/src/static/js/undomodule.js +++ /dev/null @@ -1,285 +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 Changeset = require('./Changeset'); -const _ = require('./underscore'); - -const undoModule = (() => { - const stack = (() => { - const stackElements = []; - // two types of stackElements: - // 1) { elementType: UNDOABLE_EVENT, eventType: "anything", [backset: ,] - // [selStart: , selEnd: , selFocusAtStart: ] } - // 2) { elementType: EXTERNAL_CHANGE, changeset: } - // invariant: no two consecutive EXTERNAL_CHANGEs - let numUndoableEvents = 0; - - const UNDOABLE_EVENT = 'undoableEvent'; - const EXTERNAL_CHANGE = 'externalChange'; - - const clearStack = () => { - stackElements.length = 0; - stackElements.push( - { - elementType: UNDOABLE_EVENT, - eventType: 'bottom', - }); - numUndoableEvents = 1; - }; - clearStack(); - - const pushEvent = (event) => { - const e = _.extend( - {}, event); - e.elementType = UNDOABLE_EVENT; - stackElements.push(e); - numUndoableEvents++; - }; - - const pushExternalChange = (cs) => { - const idx = stackElements.length - 1; - if (stackElements[idx].elementType === EXTERNAL_CHANGE) { - stackElements[idx].changeset = - Changeset.compose(stackElements[idx].changeset, cs, getAPool()); - } else { - stackElements.push( - { - elementType: EXTERNAL_CHANGE, - changeset: cs, - }); - } - }; - - const _exposeEvent = (nthFromTop) => { - // precond: 0 <= nthFromTop < numUndoableEvents - const targetIndex = stackElements.length - 1 - nthFromTop; - let idx = stackElements.length - 1; - while (idx > targetIndex || stackElements[idx].elementType === EXTERNAL_CHANGE) { - if (stackElements[idx].elementType === EXTERNAL_CHANGE) { - const ex = stackElements[idx]; - const un = stackElements[idx - 1]; - if (un.backset) { - const excs = ex.changeset; - const unbs = un.backset; - un.backset = Changeset.follow(excs, un.backset, false, getAPool()); - ex.changeset = Changeset.follow(unbs, ex.changeset, true, getAPool()); - if ((typeof un.selStart) === 'number') { - const newSel = Changeset.characterRangeFollow(excs, un.selStart, un.selEnd); - un.selStart = newSel[0]; - un.selEnd = newSel[1]; - if (un.selStart === un.selEnd) { - un.selFocusAtStart = false; - } - } - } - stackElements[idx - 1] = ex; - stackElements[idx] = un; - if (idx >= 2 && stackElements[idx - 2].elementType === EXTERNAL_CHANGE) { - ex.changeset = - Changeset.compose(stackElements[idx - 2].changeset, ex.changeset, getAPool()); - stackElements.splice(idx - 2, 1); - idx--; - } - } else { - idx--; - } - } - }; - - const getNthFromTop = (n) => { - // precond: 0 <= n < numEvents() - _exposeEvent(n); - return stackElements[stackElements.length - 1 - n]; - }; - - const numEvents = () => numUndoableEvents; - - const popEvent = () => { - // precond: numEvents() > 0 - _exposeEvent(0); - numUndoableEvents--; - return stackElements.pop(); - }; - - return { - numEvents, - popEvent, - pushEvent, - pushExternalChange, - clearStack, - getNthFromTop, - }; - })(); - - // invariant: stack always has at least one undoable event - let undoPtr = 0; // zero-index from top of stack, 0 == top - - const clearHistory = () => { - stack.clearStack(); - undoPtr = 0; - }; - - const _charOccurrences = (str, c) => { - let i = 0; - let count = 0; - while (i >= 0 && i < str.length) { - i = str.indexOf(c, i); - if (i >= 0) { - count++; - i++; - } - } - return count; - }; - - const _opcodeOccurrences = (cs, opcode) => _charOccurrences(Changeset.unpack(cs).ops, opcode); - - const _mergeChangesets = (cs1, cs2) => { - if (!cs1) return cs2; - if (!cs2) return cs1; - - // Rough heuristic for whether changesets should be considered one action: - // each does exactly one insertion, no dels, and the composition does also; or - // each does exactly one deletion, no ins, and the composition does also. - // A little weird in that it won't merge "make bold" with "insert char" - // but will merge "make bold and insert char" with "insert char", - // though that isn't expected to come up. - const plusCount1 = _opcodeOccurrences(cs1, '+'); - const plusCount2 = _opcodeOccurrences(cs2, '+'); - const minusCount1 = _opcodeOccurrences(cs1, '-'); - const minusCount2 = _opcodeOccurrences(cs2, '-'); - if (plusCount1 === 1 && plusCount2 === 1 && minusCount1 === 0 && minusCount2 === 0) { - const merge = Changeset.compose(cs1, cs2, getAPool()); - const plusCount3 = _opcodeOccurrences(merge, '+'); - const minusCount3 = _opcodeOccurrences(merge, '-'); - if (plusCount3 === 1 && minusCount3 === 0) { - return merge; - } - } else if (plusCount1 === 0 && plusCount2 === 0 && minusCount1 === 1 && minusCount2 === 1) { - const merge = Changeset.compose(cs1, cs2, getAPool()); - const plusCount3 = _opcodeOccurrences(merge, '+'); - const minusCount3 = _opcodeOccurrences(merge, '-'); - if (plusCount3 === 0 && minusCount3 === 1) { - return merge; - } - } - return null; - }; - - const reportEvent = (event) => { - const topEvent = stack.getNthFromTop(0); - - const applySelectionToTop = () => { - if ((typeof event.selStart) === 'number') { - topEvent.selStart = event.selStart; - topEvent.selEnd = event.selEnd; - topEvent.selFocusAtStart = event.selFocusAtStart; - } - }; - - if ((!event.backset) || Changeset.isIdentity(event.backset)) { - applySelectionToTop(); - } else { - let merged = false; - if (topEvent.eventType === event.eventType) { - const merge = _mergeChangesets(event.backset, topEvent.backset); - if (merge) { - topEvent.backset = merge; - applySelectionToTop(); - merged = true; - } - } - if (!merged) { - /* - * Push the event on the undo stack only if it exists, and if it's - * not a "clearauthorship". This disallows undoing the removal of the - * authorship colors, but is a necessary stopgap measure against - * https://github.com/ether/etherpad-lite/issues/2802 - */ - if (event && (event.eventType !== 'clearauthorship')) { - stack.pushEvent(event); - } - } - undoPtr = 0; - } - }; - - const reportExternalChange = (changeset) => { - if (changeset && !Changeset.isIdentity(changeset)) { - stack.pushExternalChange(changeset); - } - }; - - const _getSelectionInfo = (event) => { - if ((typeof event.selStart) !== 'number') { - return null; - } else { - return { - selStart: event.selStart, - selEnd: event.selEnd, - selFocusAtStart: event.selFocusAtStart, - }; - } - }; - - // For "undo" and "redo", the change event must be returned - // by eventFunc and NOT reported through the normal mechanism. - // "eventFunc" should take a changeset and an optional selection info object, - // or can be called with no arguments to mean that no undo is possible. - // "eventFunc" will be called exactly once. - - const performUndo = (eventFunc) => { - if (undoPtr < stack.numEvents() - 1) { - const backsetEvent = stack.getNthFromTop(undoPtr); - const selectionEvent = stack.getNthFromTop(undoPtr + 1); - const undoEvent = eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent)); - stack.pushEvent(undoEvent); - undoPtr += 2; - } else { eventFunc(); } - }; - - const performRedo = (eventFunc) => { - if (undoPtr >= 2) { - const backsetEvent = stack.getNthFromTop(0); - const selectionEvent = stack.getNthFromTop(1); - eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent)); - stack.popEvent(); - undoPtr -= 2; - } else { eventFunc(); } - }; - - const getAPool = () => undoModule.apool; - - return { - clearHistory, - reportEvent, - reportExternalChange, - performUndo, - performRedo, - enabled: true, - apool: null, - }; // apool is filled in by caller -})(); - -exports.undoModule = undoModule; diff --git a/src/static/js/undomodule.ts b/src/static/js/undomodule.ts new file mode 100644 index 000000000..4330b1ad9 --- /dev/null +++ b/src/static/js/undomodule.ts @@ -0,0 +1,275 @@ +'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 {RepModel} from "./types/RepModel"; + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const Changeset = require('./Changeset'); +import {extend} from 'underscore' +import AttributePool from "./AttributePool"; + +export let pool: AttributePool|null = null + + +export const setPool = (poolAssigned: AttributePool)=> { + pool = poolAssigned +} +class Stack { + private numUndoableEvents = 0 + private UNDOABLE_EVENT = 'undoableEvent'; + private EXTERNAL_CHANGE = 'externalChange'; + private stackElements: any[] = [] + + constructor() { + // two types of stackElements: + // 1) { elementType: UNDOABLE_EVENT, eventType: "anything", [backset: ,] + // [selStart: , selEnd: , selFocusAtStart: ] } + // 2) { elementType: EXTERNAL_CHANGE, changeset: } + // invariant: no two consecutive EXTERNAL_CHANGEs + this.clearStack(); + } + clearStack = () => { + this.stackElements.length = 0; + this.stackElements.push( + { + elementType: this.UNDOABLE_EVENT, + eventType: 'bottom', + }); + this.numUndoableEvents = 1; + }; + pushEvent = (event: string) => { + const e = extend( + {}, event); + e.elementType = this.UNDOABLE_EVENT; + this.stackElements.push(e); + this.numUndoableEvents++; + } + pushExternalChange = (cs: string) => { + const idx = this.stackElements.length - 1; + if (this.stackElements[idx].elementType === this.EXTERNAL_CHANGE) { + this.stackElements[idx].changeset = + Changeset.compose(this.stackElements[idx].changeset, cs, pool); + } else { + this.stackElements.push( + { + elementType: this.EXTERNAL_CHANGE, + changeset: cs, + }); + } + } + + private exposeEvent = (nthFromTop: number) => { + // precond: 0 <= nthFromTop < numUndoableEvents + const targetIndex = this.stackElements.length - 1 - nthFromTop; + let idx = this.stackElements.length - 1; + while (idx > targetIndex || this.stackElements[idx].elementType === this.EXTERNAL_CHANGE) { + if (this.stackElements[idx].elementType === this.EXTERNAL_CHANGE) { + const ex = this.stackElements[idx]; + const un = this.stackElements[idx - 1]; + if (un.backset) { + const excs = ex.changeset; + const unbs = un.backset; + un.backset = Changeset.follow(excs, un.backset, false, pool); + ex.changeset = Changeset.follow(unbs, ex.changeset, true, pool); + if ((typeof un.selStart) === 'number') { + const newSel = Changeset.characterRangeFollow(excs, un.selStart, un.selEnd); + un.selStart = newSel[0]; + un.selEnd = newSel[1]; + if (un.selStart === un.selEnd) { + un.selFocusAtStart = false; + } + } + } + this.stackElements[idx - 1] = ex; + this.stackElements[idx] = un; + if (idx >= 2 && this.stackElements[idx - 2].elementType === this.EXTERNAL_CHANGE) { + ex.changeset = + Changeset.compose(this.stackElements[idx - 2].changeset, ex.changeset, pool); + this.stackElements.splice(idx - 2, 1); + idx--; + } + } else { + idx--; + } + } + } + + getNthFromTop = (n: number) => { + // precond: 0 <= n < numEvents() + this.exposeEvent(n); + return this.stackElements[this.stackElements.length - 1 - n]; + } + numEvents = () => this.numUndoableEvents; + popEvent = () => { + // precond: numEvents() > 0 + this.exposeEvent(0); + this.numUndoableEvents--; + return this.stackElements.pop(); + } +} + +class UndoModule { + // invariant: stack always has at least one undoable event + private undoPtr = 0 + private stack: Stack + public enabled: boolean + private readonly apool: AttributePool|null + constructor() { + this.stack = new Stack() + this.enabled = true + this.apool = null + } + + clearHistory = () => { + this.stack.clearStack(); + this.undoPtr = 0; + } + + private charOccurrences = (str: string, c: string) => { + let i = 0; + let count = 0; + while (i >= 0 && i < str.length) { + i = str.indexOf(c, i); + if (i >= 0) { + count++; + i++; + } + } + return count; + } + private opcodeOccurrences = (cs: string, opcode: string) => this.charOccurrences(Changeset.unpack(cs).ops, opcode) + private mergeChangesets = (cs1: string, cs2:string) => { + if (!cs1) return cs2; + if (!cs2) return cs1; + + // Rough heuristic for whether changesets should be considered one action: + // each does exactly one insertion, no dels, and the composition does also; or + // each does exactly one deletion, no ins, and the composition does also. + // A little weird in that it won't merge "make bold" with "insert char" + // but will merge "make bold and insert char" with "insert char", + // though that isn't expected to come up. + const plusCount1 = this.opcodeOccurrences(cs1, '+'); + const plusCount2 = this.opcodeOccurrences(cs2, '+'); + const minusCount1 = this.opcodeOccurrences(cs1, '-'); + const minusCount2 = this.opcodeOccurrences(cs2, '-'); + if (plusCount1 === 1 && plusCount2 === 1 && minusCount1 === 0 && minusCount2 === 0) { + const merge = Changeset.compose(cs1, cs2, this.getAPool()); + const plusCount3 = this.opcodeOccurrences(merge, '+'); + const minusCount3 = this.opcodeOccurrences(merge, '-'); + if (plusCount3 === 1 && minusCount3 === 0) { + return merge; + } + } else if (plusCount1 === 0 && plusCount2 === 0 && minusCount1 === 1 && minusCount2 === 1) { + const merge = Changeset.compose(cs1, cs2, this.getAPool()); + const plusCount3 = this.opcodeOccurrences(merge, '+'); + const minusCount3 = this.opcodeOccurrences(merge, '-'); + if (plusCount3 === 0 && minusCount3 === 1) { + return merge; + } + } + return null; + } + + reportEvent = (event: any) => { + const topEvent = this.stack.getNthFromTop(0); + + const applySelectionToTop = () => { + if ((typeof event.selStart) === 'number') { + topEvent.selStart = event.selStart; + topEvent.selEnd = event.selEnd; + topEvent.selFocusAtStart = event.selFocusAtStart; + } + }; + + if ((!event.backset) || Changeset.isIdentity(event.backset)) { + applySelectionToTop(); + } else { + let merged = false; + if (topEvent.eventType === event.eventType) { + const merge = this.mergeChangesets(event.backset, topEvent.backset); + if (merge) { + topEvent.backset = merge; + applySelectionToTop(); + merged = true; + } + } + if (!merged) { + /* + * Push the event on the undo stack only if it exists, and if it's + * not a "clearauthorship". This disallows undoing the removal of the + * authorship colors, but is a necessary stopgap measure against + * https://github.com/ether/etherpad-lite/issues/2802 + */ + if (event && (event.eventType !== 'clearauthorship')) { + this.stack.pushEvent(event); + } + } + this.undoPtr = 0; + } + } + reportExternalChange = (changeset: string) => { + if (changeset && !Changeset.isIdentity(changeset)) { + this.stack.pushExternalChange(changeset); + } + } + getSelectionInfo = (event: any) => { + if ((typeof event.selStart) !== 'number') { + return null; + } else { + return { + selStart: event.selStart, + selEnd: event.selEnd, + selFocusAtStart: event.selFocusAtStart, + }; + } + } + // For "undo" and "redo", the change event must be returned + // by eventFunc and NOT reported through the normal mechanism. + // "eventFunc" should take a changeset and an optional selection info object, + // or can be called with no arguments to mean that no undo is possible. + // "eventFunc" will be called exactly once. + + performUndo = (eventFunc: Function) => { + if (this.undoPtr < this.stack.numEvents() - 1) { + const backsetEvent = this.stack.getNthFromTop(this.undoPtr); + const selectionEvent = this.stack.getNthFromTop(this.undoPtr + 1); + const undoEvent = eventFunc(backsetEvent.backset, this.getSelectionInfo(selectionEvent)); + this.stack.pushEvent(undoEvent); + this.undoPtr += 2; + } else { eventFunc(); } + } + performRedo = (eventFunc: Function) => { + if (this.undoPtr >= 2) { + const backsetEvent = this.stack.getNthFromTop(0); + const selectionEvent = this.stack.getNthFromTop(1); + eventFunc(backsetEvent.backset, this.getSelectionInfo(selectionEvent)); + this.stack.popEvent(); + this.undoPtr -= 2; + } else { eventFunc(); } + } + getAPool = () => this.apool; + +} + +export const undoModule = new UndoModule() + diff --git a/src/templates/padBootstrap.js b/src/templates/padBootstrap.js index c86d170c1..f2e176fa6 100644 --- a/src/templates/padBootstrap.js +++ b/src/templates/padBootstrap.js @@ -7,13 +7,13 @@ // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the server // sends the CLIENT_VARS message. randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>, - }; + } // Allow other frames to access this frame's modules. //window.require.resolveTmp = require.resolve('ep_etherpad-lite/static/js/pad_cookie'); const basePath = new URL('..', window.location.href).pathname; - window.$ = window.jQuery = require('../../src/static/js/rjquery').jQuery; + window.$ = window.jQuery = require('../../src/static/js/vendors/jquery'); window.browser = require('../../src/static/js/vendors/browser'); const pad = require('../../src/static/js/pad'); pad.baseURL = basePath; @@ -25,8 +25,8 @@ window.chat = require('../../src/static/js/chat').chat; window.padeditbar = require('../../src/static/js/pad_editbar').padeditbar; window.padimpexp = require('../../src/static/js/pad_impexp').padimpexp; - require('../../src/static/js/skin_variants'); - require('../../src/static/js/basic_error_handler') + await import('../../src/static/js/skin_variants') + await import('../../src/static/js/basic_error_handler') window.plugins.baseURL = basePath; await window.plugins.update(new Map([ diff --git a/src/templates/padViteBootstrap.js b/src/templates/padViteBootstrap.js deleted file mode 100644 index 05f759077..000000000 --- a/src/templates/padViteBootstrap.js +++ /dev/null @@ -1,41 +0,0 @@ -window.$ = window.jQuery = await import('../../src/static/js/rjquery').jQuery; -await import('../../src/static/js/l10n') - -window.clientVars = { - // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the server - // sends the CLIENT_VARS message. - randomVersionString: "7a7bdbad", -}; - -(async () => { - // Allow other frames to access this frame's modules. - //window.require.resolveTmp = require.resolve('ep_etherpad-lite/static/js/pad_cookie'); - - const basePath = new URL('..', window.location.href).pathname; - window.browser = require('../../src/static/js/vendors/browser'); - const pad = require('../../src/static/js/pad'); - pad.baseURL = basePath; - window.plugins = require('../../src/static/js/pluginfw/client_plugins'); - const hooks = require('../../src/static/js/pluginfw/hooks'); - - // TODO: These globals shouldn't exist. - window.pad = pad.pad; - window.chat = require('../../src/static/js/chat').chat; - window.padeditbar = require('../../src/static/js/pad_editbar').padeditbar; - window.padimpexp = require('../../src/static/js/pad_impexp').padimpexp; - require('../../src/static/js/skin_variants'); - require('../../src/static/js/basic_error_handler') - - window.plugins.baseURL = basePath; - await window.plugins.update(new Map([ - - ])); - // Mechanism for tests to register hook functions (install fake plugins). - window._postPluginUpdateForTestingDone = false; - if (window._postPluginUpdateForTesting != null) window._postPluginUpdateForTesting(); - window._postPluginUpdateForTestingDone = true; - window.pluginDefs = require('../../src/static/js/pluginfw/plugin_defs'); - pad.init(); - await new Promise((resolve) => $(resolve)); - await hooks.aCallAll('documentReady'); -})(); diff --git a/src/templates/timeSliderBootstrap.js b/src/templates/timeSliderBootstrap.js index e3138cfbd..b71b8dc79 100644 --- a/src/templates/timeSliderBootstrap.js +++ b/src/templates/timeSliderBootstrap.js @@ -1,4 +1,7 @@ // @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt + +import {setBaseURl} from "ep_etherpad-lite/static/js/timeslider"; + window.clientVars = { // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the // server sends the CLIENT_VARS message. @@ -6,15 +9,14 @@ window.clientVars = { }; let BroadcastSlider; - +import * as timeSlider from 'ep_etherpad-lite/static/js/timeslider' (function () { - const timeSlider = require('ep_etherpad-lite/static/js/timeslider') const pathComponents = location.pathname.split('/'); // Strip 'p', the padname and 'timeslider' from the pathname and set as baseURL const baseURL = pathComponents.slice(0,pathComponents.length-3).join('/') + '/'; require('ep_etherpad-lite/static/js/l10n') - window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; // Expose jQuery #HACK + window.$ = window.jQuery = require('ep_etherpad-lite/static/js/vendors/jquery'); // Expose jQuery #HACK require('ep_etherpad-lite/static/js/vendors/gritter') window.browser = require('ep_etherpad-lite/static/js/vendors/browser'); @@ -31,7 +33,7 @@ let BroadcastSlider; }); const padeditbar = require('ep_etherpad-lite/static/js/pad_editbar').padeditbar; const padimpexp = require('ep_etherpad-lite/static/js/pad_impexp').padimpexp; - timeSlider.baseURL = baseURL; + setBaseURl(baseURL) timeSlider.init(); padeditbar.init() })(); diff --git a/src/tests/backend/common.ts b/src/tests/backend/common.ts index 21fb01e2f..1a280c133 100644 --- a/src/tests/backend/common.ts +++ b/src/tests/backend/common.ts @@ -2,7 +2,7 @@ import {MapArrayType} from "../../node/types/MapType"; -const AttributePool = require('../../static/js/AttributePool'); +import AttributePool from '../../static/js/AttributePool'; const apiHandler = require('../../node/handler/APIHandler'); const assert = require('assert').strict; const io = require('socket.io-client'); diff --git a/src/tests/backend/specs/contentcollector.ts b/src/tests/backend/specs/contentcollector.ts index 51ae0002f..5a73997d7 100644 --- a/src/tests/backend/specs/contentcollector.ts +++ b/src/tests/backend/specs/contentcollector.ts @@ -11,7 +11,8 @@ import {APool} from "../../../node/types/PadType"; -const AttributePool = require('../../../static/js/AttributePool'); +import AttributePool from '../../../static/js/AttributePool' +import {Attribute} from "../../../static/js/types/Attribute"; const Changeset = require('../../../static/js/Changeset'); const assert = require('assert').strict; const attributes = require('../../../static/js/attributes'); @@ -20,7 +21,7 @@ const jsdom = require('jsdom'); // All test case `wantAlines` values must only refer to attributes in this list so that the // attribute numbers do not change due to changes in pool insertion order. -const knownAttribs = [ +const knownAttribs: Attribute[] = [ ['insertorder', 'first'], ['italic', 'true'], ['list', 'bullet1'], @@ -336,7 +337,7 @@ pre describe(__filename, function () { for (const tc of testCases) { describe(tc.description, function () { - let apool: APool; + let apool: AttributePool; let result: { lines: string[], lineAttribs: string[], diff --git a/src/tests/frontend/easysync-helper.js b/src/tests/frontend/easysync-helper.js index b4f770963..cb725092d 100644 --- a/src/tests/frontend/easysync-helper.js +++ b/src/tests/frontend/easysync-helper.js @@ -1,7 +1,7 @@ 'use strict'; const Changeset = require('../../static/js/Changeset'); -const AttributePool = require('../../static/js/AttributePool'); +import AttributePool from "../../static/js/AttributePool"; const randInt = (maxValue) => Math.floor(Math.random() * maxValue); diff --git a/src/tests/frontend/specs/AttributeMap.js b/src/tests/frontend/specs/AttributeMap.js index 92ca68334..a007b7229 100644 --- a/src/tests/frontend/specs/AttributeMap.js +++ b/src/tests/frontend/specs/AttributeMap.js @@ -1,7 +1,7 @@ 'use strict'; -const AttributeMap = require('../../../static/js/AttributeMap'); -const AttributePool = require('../../../static/js/AttributePool'); +import AttributeMap from "../../../static/js/AttributeMap"; +import AttributePool from '../../../static/js/AttributePool'; const attributes = require('../../../static/js/attributes'); describe('AttributeMap', function () { diff --git a/src/tests/frontend/specs/attributes.js b/src/tests/frontend/specs/attributes.js index 13058dbe3..7af867e9e 100644 --- a/src/tests/frontend/specs/attributes.js +++ b/src/tests/frontend/specs/attributes.js @@ -1,6 +1,6 @@ 'use strict'; -const AttributePool = require('../../../static/js/AttributePool'); +import AttributePool from '../../../static/js/AttributePool' const attributes = require('../../../static/js/attributes'); describe('attributes', function () { diff --git a/src/tests/frontend/specs/easysync-compose.js b/src/tests/frontend/specs/easysync-compose.js index 69757763c..103d1ca04 100644 --- a/src/tests/frontend/specs/easysync-compose.js +++ b/src/tests/frontend/specs/easysync-compose.js @@ -1,7 +1,7 @@ 'use strict'; const Changeset = require('../../../static/js/Changeset'); -const AttributePool = require('../../../static/js/AttributePool'); +import AttributePool from "../../../static/js/AttributePool"; const {randomMultiline, randomTestChangeset} = require('../easysync-helper.js'); describe('easysync-compose', function () { diff --git a/src/tests/frontend/specs/easysync-follow.js b/src/tests/frontend/specs/easysync-follow.js index 9ec5a7e83..569eea9e9 100644 --- a/src/tests/frontend/specs/easysync-follow.js +++ b/src/tests/frontend/specs/easysync-follow.js @@ -1,7 +1,7 @@ 'use strict'; const Changeset = require('../../../static/js/Changeset'); -const AttributePool = require('../../../static/js/AttributePool'); +import AttributePool from "../../../static/js/AttributePool"; const {randomMultiline, randomTestChangeset} = require('../easysync-helper.js'); describe('easysync-follow', function () { diff --git a/src/tests/frontend/specs/easysync-mutations.js b/src/tests/frontend/specs/easysync-mutations.js index c10d34519..a3106c424 100644 --- a/src/tests/frontend/specs/easysync-mutations.js +++ b/src/tests/frontend/specs/easysync-mutations.js @@ -1,7 +1,7 @@ 'use strict'; const Changeset = require('../../../static/js/Changeset'); -const AttributePool = require('../../../static/js/AttributePool'); +import AttributePool from '../../../static/js/AttributePool' const {poolOrArray} = require('../easysync-helper.js'); describe('easysync-mutations', function () { diff --git a/src/tests/frontend/specs/easysync-other.js b/src/tests/frontend/specs/easysync-other.js index af4580835..3059e4e1f 100644 --- a/src/tests/frontend/specs/easysync-other.js +++ b/src/tests/frontend/specs/easysync-other.js @@ -1,7 +1,7 @@ 'use strict'; const Changeset = require('../../../static/js/Changeset'); -const AttributePool = require('../../../static/js/AttributePool'); +import AttributePool from '../../../static/js/AttributePool' const {randomMultiline, poolOrArray} = require('../easysync-helper.js'); const {padutils} = require('../../../static/js/pad_utils'); diff --git a/src/tests/frontend/specs/skiplist.js b/src/tests/frontend/specs/skiplist.js index 16b985615..f28a1cdab 100644 --- a/src/tests/frontend/specs/skiplist.js +++ b/src/tests/frontend/specs/skiplist.js @@ -1,6 +1,6 @@ 'use strict'; -const SkipList = require('ep_etherpad-lite/static/js/skiplist'); +import SkipList from "../../../static/js/skiplist"; describe('skiplist.js', function () { it('rejects null keys', async function () {