From d1ffd5d02f404ac0dd71629db1bb1a34a81f9e88 Mon Sep 17 00:00:00 2001 From: SamTv12345 Date: Thu, 18 Jul 2024 19:20:35 +0200 Subject: [PATCH 1/8] Moved more files to typescript --- doc/api/changeset_library.md | 4 +- doc/api/hooks_server-side.md | 16 +- pnpm-lock.yaml | 8 + src/node/db/AuthorManager.ts | 4 +- src/node/db/Pad.ts | 22 +- src/node/db/SecurityManager.ts | 2 +- src/node/handler/PadMessageHandler.ts | 8 +- src/node/utils/ExportHelper.ts | 5 +- src/node/utils/ExportHtml.ts | 2 +- src/node/utils/ImportEtherpad.ts | 4 +- src/node/utils/padDiff.ts | 2 +- src/package.json | 1 + ...ttributeManager.js => AttributeManager.ts} | 194 +++---- .../js/{AttributeMap.js => AttributeMap.ts} | 17 +- .../js/{AttributePool.js => AttributePool.ts} | 36 +- src/static/js/{Changeset.js => Changeset.ts} | 515 ++---------------- .../{ChangesetUtils.js => ChangesetUtils.ts} | 27 +- .../js/{ChatMessage.js => ChatMessage.ts} | 31 +- src/static/js/MergingOpAssembler.ts | 73 +++ src/static/js/Op.ts | 73 +++ src/static/js/OpAssembler.ts | 21 + src/static/js/OpIter.ts | 45 ++ src/static/js/SmartOpAssembler.ts | 115 ++++ src/static/js/StringAssembler.ts | 18 + src/static/js/StringIterator.ts | 54 ++ src/static/js/ace.js | 12 +- src/static/js/ace2_inner.js | 37 +- .../js/{attributes.js => attributes.ts} | 24 +- ...rror_handler.js => basic_error_handler.ts} | 4 +- src/static/js/broadcast.js | 20 +- src/static/js/broadcast_slider.js | 2 +- src/static/js/changesettracker.js | 203 ------- src/static/js/changesettracker.ts | 216 ++++++++ src/static/js/chat.js | 5 +- src/static/js/contentcollector.js | 2 +- src/static/js/cssmanager.js | 72 --- src/static/js/cssmanager.ts | 72 +++ src/static/js/domline.js | 7 +- src/static/js/{index.js => index.ts} | 12 +- src/static/js/linestylefilter.js | 291 ---------- src/static/js/linestylefilter.ts | 298 ++++++++++ src/static/js/pad.js | 8 +- .../js/{pad_cookie.js => pad_cookie.ts} | 18 +- src/static/js/pad_editbar.js | 3 +- src/static/js/pad_editor.js | 5 +- src/static/js/pad_userlist.js | 2 +- src/static/js/{pad_utils.js => pad_utils.ts} | 411 +++++++------- src/static/js/rjquery.js | 5 - src/static/js/security.js | 19 - .../js/{skin_variants.js => skin_variants.ts} | 9 +- src/static/js/{skiplist.js => skiplist.ts} | 185 ++++--- src/static/js/{socketio.js => socketio.ts} | 17 +- .../js/{timeslider.js => timeslider.ts} | 54 +- src/static/js/types/Attribute.ts | 1 + src/static/js/types/ChangeSetBuilder.ts | 7 + src/static/js/types/RangePos.ts | 1 + src/static/js/types/RepModel.ts | 19 +- src/static/js/types/SocketIOMessage.ts | 13 + src/static/js/types/Window.ts | 6 + src/static/js/underscore.js | 3 - src/static/js/undomodule.js | 285 ---------- src/static/js/undomodule.ts | 275 ++++++++++ src/templates/padBootstrap.js | 8 +- src/templates/padViteBootstrap.js | 41 -- src/templates/timeSliderBootstrap.js | 10 +- src/tests/backend/common.ts | 2 +- src/tests/backend/specs/contentcollector.ts | 7 +- src/tests/frontend/easysync-helper.js | 2 +- src/tests/frontend/specs/AttributeMap.js | 4 +- src/tests/frontend/specs/attributes.js | 2 +- src/tests/frontend/specs/easysync-compose.js | 2 +- src/tests/frontend/specs/easysync-follow.js | 2 +- .../frontend/specs/easysync-mutations.js | 2 +- src/tests/frontend/specs/easysync-other.js | 2 +- src/tests/frontend/specs/skiplist.js | 2 +- 75 files changed, 2078 insertions(+), 1928 deletions(-) rename src/static/js/{AttributeManager.js => AttributeManager.ts} (71%) rename src/static/js/{AttributeMap.js => AttributeMap.ts} (82%) rename src/static/js/{AttributePool.js => AttributePool.ts} (91%) rename src/static/js/{Changeset.js => Changeset.ts} (82%) rename src/static/js/{ChangesetUtils.js => ChangesetUtils.ts} (60%) rename src/static/js/{ChatMessage.js => ChatMessage.ts} (68%) create mode 100644 src/static/js/MergingOpAssembler.ts create mode 100644 src/static/js/Op.ts create mode 100644 src/static/js/OpAssembler.ts create mode 100644 src/static/js/OpIter.ts create mode 100644 src/static/js/SmartOpAssembler.ts create mode 100644 src/static/js/StringAssembler.ts create mode 100644 src/static/js/StringIterator.ts rename src/static/js/{attributes.js => attributes.ts} (80%) rename src/static/js/{basic_error_handler.js => basic_error_handler.ts} (92%) delete mode 100644 src/static/js/changesettracker.js create mode 100644 src/static/js/changesettracker.ts delete mode 100644 src/static/js/cssmanager.js create mode 100644 src/static/js/cssmanager.ts rename src/static/js/{index.js => index.ts} (88%) delete mode 100644 src/static/js/linestylefilter.js create mode 100644 src/static/js/linestylefilter.ts rename src/static/js/{pad_cookie.js => pad_cookie.ts} (84%) rename src/static/js/{pad_utils.js => pad_utils.ts} (58%) delete mode 100644 src/static/js/rjquery.js delete mode 100644 src/static/js/security.js rename src/static/js/{skin_variants.js => skin_variants.ts} (89%) rename src/static/js/{skiplist.js => skiplist.ts} (61%) rename src/static/js/{socketio.js => socketio.ts} (82%) rename src/static/js/{timeslider.js => timeslider.ts} (82%) create mode 100644 src/static/js/types/Attribute.ts create mode 100644 src/static/js/types/ChangeSetBuilder.ts create mode 100644 src/static/js/types/RangePos.ts create mode 100644 src/static/js/types/SocketIOMessage.ts create mode 100644 src/static/js/types/Window.ts delete mode 100644 src/static/js/underscore.js delete mode 100644 src/static/js/undomodule.js create mode 100644 src/static/js/undomodule.ts delete mode 100644 src/templates/padViteBootstrap.js diff --git a/doc/api/changeset_library.md b/doc/api/changeset_library.md index 7929aa48b92..89846a55b7b 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 05a66209f10..663715373b7 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 eca4dceb393..459df8954f1 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 2f4e7d751af..c09907d454f 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 fa4af994d57..aca8c334285 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 326bf36595d..8b6c9fc4573 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 3909496075d..2909c7458f7 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 f3a438e86e6..e12332b0672 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 3b84c4380ec..c75cdc1a7d4 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 50b9a43d580..9f2467c0dd0 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 d731ebbe40a..6074f8bbc6c 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 10806bae798..1f896cfc6a2 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 63af431d908..1b4664b0aa3 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 55640eb8bc6..4bdbfd9b02f 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 ccdd2eb35ca..5bbe52122a4 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 53b3f2c8f09..e4b5871f290 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 ef2be2ebe07..ad8b13c3cae 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 a627f88f9f3..294057becc6 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 00000000000..4d0b8414616 --- /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 00000000000..73233027e5e --- /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 00000000000..2c354965587 --- /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 00000000000..18a63a6861a --- /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 00000000000..02a4aee7caa --- /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 00000000000..a316d5a5274 --- /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 00000000000..1543e8784bd --- /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 a1b5d99c8a5..d756d40be96 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 641c5ecdbe6..139e3bc1867 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 4ab34701957..eb9516a574d 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 ab400aa8a83..a7e6ef0e47e 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 2163fd78ee3..52c0a490e6a 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 848ba06cf05..0dcd59f3c13 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 30c70aa748f..00000000000 --- 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 00000000000..fec259d7f18 --- /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 d32a62c7ab0..9b2818bcfae 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 4735374eeac..0bff7da7d93 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 5bf2adb303b..00000000000 --- 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 00000000000..1e801663f90 --- /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 af786b2dc40..5c3dfcbc478 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 d50c14e7d8e..12d9cb6846e 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 632e6b3ccb5..00000000000 --- 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 00000000000..5a71f200ebb --- /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 d6648f031db..f6970ebbf15 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 0e946ea5c89..ff8f951e22c 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 af8d59f1fc5..d392fa7a3a8 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 47a25073458..739b73a6a44 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 a0cbd4b44aa..14d760c0c29 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 467a8adc90d..ab5dde6b64c 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 = null; + 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}; - }, - makeFieldLabeledWhenEmpty: (field, labelText) => { - 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}; + } - 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(); + makeFieldLabeledWhenEmpty + = + (field: JQueryNode, labelText: string) => { + 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: JQueryNode) => $(node).is(':checked') + setCheckbox = + (node: JQueryNode, value: string) => { + if (value) { + $(node).attr('checked', 'checked'); + } else { + $(node).prop('checked', false); } - }); - return { - clear, - }; - }, - getCheckbox: (node) => $(node).is(':checked'), - setCheckbox: (node, value) => { - if (value) { - $(node).attr('checked', 'checked'); - } else { - $(node).prop('checked', false); } - }, - 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; + 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); + + 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 = [ - $('

') + 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, - }), - }); - }; - window.onerror = null; // Clear any pre-existing global error handler. - window.addEventListener('error', globalExceptionHandler); - window.addEventListener('unhandledrejection', globalExceptionHandler); + // 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', 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 a80e1f8d30a..00000000000 --- 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 d92425cb789..00000000000 --- 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 9a0427ac908..7856b306900 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 f10a4e7a8ce..f246884db42 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 cdc1c9a23e9..ca91572c9cd 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 8d8604b91ac..f146b3fc361 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 00000000000..f1c06b3cb48 --- /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 00000000000..6f39193520b --- /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 00000000000..be611a1ff19 --- /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 821549e1d05..f6a02ad205c 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 00000000000..ca5c629e986 --- /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 00000000000..df19bc8cf14 --- /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 d30543cabc4..00000000000 --- 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 d0b83419dd9..00000000000 --- 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 00000000000..4330b1ad9c2 --- /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 c86d170c1bc..f2e176fa641 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 05f759077de..00000000000 --- 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 e3138cfbdd6..b71b8dc79e5 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 21fb01e2f48..1a280c13320 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 51ae0002f2b..5a73997d7fd 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 b4f77096301..cb725092d57 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 92ca6833463..a007b72299c 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 13058dbe38c..7af867e9e59 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 69757763c6c..103d1ca0489 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 9ec5a7e8301..569eea9e913 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 c10d34519f2..a3106c42436 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 af4580835c8..3059e4e1f84 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 16b98561515..f28a1cdab25 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 () { From cef2af15b989d22b16af41586cec1fd6eb949dda Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Thu, 18 Jul 2024 22:13:33 +0200 Subject: [PATCH 2/8] Continued writing --- src/node/types/PadType.ts | 1 + src/static/js/AttributeMap.ts | 2 +- src/static/js/AttributionLinesMutator.ts | 132 ++++ src/static/js/Builder.ts | 109 +++ src/static/js/Changeset.ts | 814 +++++------------------ src/static/js/Op.ts | 3 + src/static/js/OpIter.ts | 16 +- src/static/js/TextLinesMutator.ts | 335 ++++++++++ src/static/js/{ace.js => ace.ts} | 191 +++--- src/static/js/{pad.js => pad.ts} | 564 +++++++++------- src/static/js/pad_editor.js | 211 ------ src/static/js/pad_editor.ts | 229 +++++++ src/static/js/pad_utils.ts | 4 +- src/static/js/types/AText.ts | 4 + src/static/js/types/ChangeSet.ts | 6 + src/static/js/types/InnerWindow.ts | 5 + src/static/js/types/SocketIOMessage.ts | 48 +- src/static/js/types/Window.ts | 7 +- 18 files changed, 1453 insertions(+), 1228 deletions(-) create mode 100644 src/static/js/AttributionLinesMutator.ts create mode 100644 src/static/js/Builder.ts create mode 100644 src/static/js/TextLinesMutator.ts rename src/static/js/{ace.js => ace.ts} (65%) rename src/static/js/{pad.js => pad.ts} (61%) delete mode 100644 src/static/js/pad_editor.js create mode 100644 src/static/js/pad_editor.ts create mode 100644 src/static/js/types/AText.ts create mode 100644 src/static/js/types/ChangeSet.ts create mode 100644 src/static/js/types/InnerWindow.ts diff --git a/src/node/types/PadType.ts b/src/node/types/PadType.ts index b344ed8c555..43d6e31c0d4 100644 --- a/src/node/types/PadType.ts +++ b/src/node/types/PadType.ts @@ -19,6 +19,7 @@ export type PadType = { getRevisionDate: (rev: number)=>Promise, getRevisionChangeset: (rev: number)=>Promise, appendRevision: (changeset: AChangeSet, author: string)=>Promise, + settings:any } diff --git a/src/static/js/AttributeMap.ts b/src/static/js/AttributeMap.ts index 4bdbfd9b02f..4e3fa636caf 100644 --- a/src/static/js/AttributeMap.ts +++ b/src/static/js/AttributeMap.ts @@ -31,7 +31,7 @@ class AttributeMap extends Map { * @param {AttributePool} pool - Attribute pool. * @returns {AttributeMap} */ - public static fromString(str: string, pool: AttributePool): AttributeMap { + public static fromString(str: string, pool?: AttributePool|null): AttributeMap { return new AttributeMap(pool).updateFromString(str); } diff --git a/src/static/js/AttributionLinesMutator.ts b/src/static/js/AttributionLinesMutator.ts new file mode 100644 index 00000000000..6eb36913cba --- /dev/null +++ b/src/static/js/AttributionLinesMutator.ts @@ -0,0 +1,132 @@ +import {TextLinesMutator} from "./TextLinesMutator"; +import AttributePool from "./AttributePool"; +import {assert, copyOp, deserializeOps, slicerZipperFunc, unpack} from "./Changeset"; +import Op from "./Op"; +import {MergingOpAssembler} from "./MergingOpAssembler"; + +/** + * Applies a changeset to an array of attribute lines. + * + * @param {string} cs - The encoded changeset. + * @param {Array} lines - Attribute lines. Modified in place. + * @param {AttributePool.ts} pool - Attribute pool. + */ +export class AttributionLinesMutator { + private unpacked + private csOps: Generator|Op + private csOpsNext: IteratorResult + private csBank: string + private csBankIndex: number + private mut: TextLinesMutator + private lineOps: Generator|null + private lineOpsNext: IteratorResult|null + private lineAssem: null|MergingOpAssembler + private attOp: Op + private csOp: Op + constructor(cs: string, lines:string[], pool: AttributePool) { + this.unpacked = unpack(cs); + this.csOps = deserializeOps(this.unpacked.ops); + this.csOpsNext = this.csOps.next(); + this.csBank = this.unpacked.charBank; + this.csBankIndex = 0; + // treat the attribution lines as text lines, mutating a line at a time + this.mut = new TextLinesMutator(lines); + /** + * The Ops in the current line from `lines`. + * + * @type {?Generator} + */ + this.lineOps = null; + this.lineOpsNext = null; + this.lineAssem = null + this.csOp = new Op() + this.attOp = new Op() + while (this.csOp.opcode || !this.csOpsNext.done || this.attOp.opcode || this.isNextMutOp()) { + if (!this.csOp.opcode && !this.csOpsNext.done) { + // coOp done, but more ops in cs. + this.csOp = this.csOpsNext.value; + this.csOpsNext = this.csOps.next(); + } + if (!this.csOp.opcode && !this.attOp.opcode && !this.lineAssem && !this.lineOpsHasNext()) { + break; // done + } else if (this.csOp.opcode === '=' && this.csOp.lines > 0 && !this.csOp.attribs && !this.attOp.opcode && + !this.lineAssem && !this.lineOpsHasNext()) { + // Skip multiple lines without attributes; this is what makes small changes not order of the + // document size. + this.mut.skipLines(this.csOp.lines); + this.csOp.opcode = ''; + } else if (this.csOp.opcode === '+') { + const opOut = copyOp(this.csOp); + if (this.csOp.lines > 1) { + // Copy the first line from `csOp` to `opOut`. + const firstLineLen = this.csBank.indexOf('\n', this.csBankIndex) + 1 - this.csBankIndex; + this.csOp.chars -= firstLineLen; + this.csOp.lines--; + opOut.lines = 1; + opOut.chars = firstLineLen; + } else { + // Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`. + this.csOp.opcode = ''; + } + this.outputMutOp(opOut); + this.csBankIndex += opOut.chars; + } else { + if (!this.attOp.opcode && this.isNextMutOp()) { + this.attOp = this.nextMutOp(); + } + const opOut = slicerZipperFunc(this.attOp, this.csOp, pool); + if (opOut.opcode) { + this.outputMutOp(opOut); + } + } + } + + assert(!this.lineAssem, `line assembler not finished:${cs}`); + this.mut.close(); + } + + lineOpsHasNext = () => this.lineOpsNext && !this.lineOpsNext.done; + /** + * Returns false if we are on the last attribute line in `lines` and there is no additional op in + * that line. + * + * @returns {boolean} True if there are more ops to go through. + */ + isNextMutOp = () => this.lineOpsHasNext() || this.mut.hasMore(); + + + /** + * @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to + * iterate over the next line, which is consumed from `mut`. If there are no more lines, + * returns a null Op. + */ + nextMutOp = () => { + if (!this.lineOpsHasNext() && this.mut.hasMore()) { + // There are more attribute lines in `lines` to do AND either we just started so `lineIter` is + // still null or there are no more ops in current `lineIter`. + const line = this.mut.removeLines(1); + this.lineOps = deserializeOps(line); + this.lineOpsNext = this.lineOps.next(); + } + if (!this.lineOpsHasNext()) return new Op(); // No more ops and no more lines. + const op = this.lineOpsNext!.value; + this.lineOpsNext = this.lineOps!.next(); + return op; + } + + /** + * Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the + * `lines` mutator. + */ + outputMutOp = (op: Op) => { + if (!this.lineAssem) { + this.lineAssem = new MergingOpAssembler() + } + this.lineAssem.append(op); + if (op.lines <= 0) return; + assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`); + // ship it to the mut + this.mut.insert(this.lineAssem.toString(), 1); + this.lineAssem = null; + }; +} diff --git a/src/static/js/Builder.ts b/src/static/js/Builder.ts new file mode 100644 index 00000000000..496d6299138 --- /dev/null +++ b/src/static/js/Builder.ts @@ -0,0 +1,109 @@ +/** + * Incrementally builds a Changeset. + * + * @typedef {object} Builder + * @property {Function} insert - + * @property {Function} keep - + * @property {Function} keepText - + * @property {Function} remove - + * @property {Function} toString - + */ +import {SmartOpAssembler} from "./SmartOpAssembler"; +import Op from "./Op"; +import {StringAssembler} from "./StringAssembler"; +import AttributeMap from "./AttributeMap"; +import {Attribute} from "./types/Attribute"; +import AttributePool from "./AttributePool"; +import {opsFromText} from "./Changeset"; + +/** + * @param {number} oldLen - Old length + * @returns {Builder} + */ +export class Builder { + private readonly oldLen: number; + private assem: SmartOpAssembler; + private readonly o: Op; + private charBank: StringAssembler; + + constructor(oldLen: number) { + this.oldLen = oldLen + this.assem = new SmartOpAssembler() + this.o = new Op() + this.charBank = new StringAssembler() + } + + /** + * @param {number} N - Number of characters to keep. + * @param {number} L - Number of newlines among the `N` characters. If positive, the last + * character must be a newline. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + keep = (N: number, L: number, attribs?: string|Attribute[], pool?: AttributePool): Builder => { + this.o.opcode = '='; + this.o.attribs = typeof attribs === 'string' + ? attribs : new AttributeMap(pool).update(attribs || []).toString(); + this.o.chars = N; + this.o.lines = (L || 0); + this.assem.append(this.o); + return this; + } + + + /** + * @param {string} text - Text to keep. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + keepText= (text: string, attribs: string|Attribute[], pool?: AttributePool): Builder=> { + for (const op of opsFromText('=', text, attribs, pool)) this.assem.append(op); + return this; + } + + + /** + * @param {string} text - Text to insert. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + insert= (text: string, attribs: string | Attribute[] | undefined, pool?: AttributePool | null | undefined): Builder => { + for (const op of opsFromText('+', text, attribs, pool)) this.assem.append(op); + this.charBank.append(text); + return this; + } + + + /** + * @param {number} N - Number of characters to remove. + * @param {number} L - Number of newlines among the `N` characters. If positive, the last + * character must be a newline. + * @returns {Builder} this + */ + remove= (N: number, L: number): Builder => { + this.o.opcode = '-'; + this.o.attribs = ''; + this.o.chars = N; + this.o.lines = (L || 0); + this.assem.append(this.o); + return this; + } + + toString= () => { + this.assem.endDocument(); + const newLen = this.oldLen + this.assem.getLengthChange(); + return exports.pack(this.oldLen, newLen, this.assem.toString(), this.charBank.toString()); + } +} + + + diff --git a/src/static/js/Changeset.ts b/src/static/js/Changeset.ts index e4b5871f290..9da2e710e58 100644 --- a/src/static/js/Changeset.ts +++ b/src/static/js/Changeset.ts @@ -24,13 +24,21 @@ import AttributeMap from './AttributeMap' import AttributePool from "./AttributePool"; -import {} from './attributes'; +import {attribsFromString} from './attributes'; import {padUtils as padutils} from "./pad_utils"; -import Op from './Op' +import Op, {OpCode} from './Op' import {numToString, parseNum} from './ChangesetUtils' import {StringAssembler} from "./StringAssembler"; import {OpIter} from "./OpIter"; import {Attribute} from "./types/Attribute"; +import {SmartOpAssembler} from "./SmartOpAssembler"; +import {TextLinesMutator} from "./TextLinesMutator"; +import {ChangeSet} from "./types/ChangeSet"; +import {AText} from "./types/AText"; +import {ChangeSetBuilder} from "./types/ChangeSetBuilder"; +import {Builder} from "./Builder"; +import {StringIterator} from "./StringIterator"; +import {MergingOpAssembler} from "./MergingOpAssembler"; /** * A `[key, value]` pair of strings describing a text attribute. @@ -235,16 +243,16 @@ export const opsFromText = function* (opcode: "" | "=" | "+" | "-" | undefined, * @returns {string} the checked Changeset */ export const checkRep = (cs: string) => { - const unpacked = exports.unpack(cs); + const unpacked = unpack(cs); const oldLen = unpacked.oldLen; const newLen = unpacked.newLen; const ops = unpacked.ops; let charBank = unpacked.charBank; - const assem = exports.smartOpAssembler(); + const assem = new SmartOpAssembler(); let oldPos = 0; let calcNewLen = 0; - for (const o of exports.deserializeOps(ops)) { + for (const o of deserializeOps(ops)) { switch (o.opcode) { case '=': oldPos += o.chars; @@ -277,7 +285,7 @@ export const checkRep = (cs: string) => { assert(calcNewLen === newLen, 'Invalid changeset: claimed length does not match actual length'); assert(charBank === '', 'Invalid changeset: excess characters in the charBank'); assem.endDocument(); - const normalized = exports.pack(oldLen, calcNewLen, assem.toString(), unpacked.charBank); + const normalized = pack(oldLen, calcNewLen, assem.toString(), unpacked.charBank); assert(normalized === cs, 'Invalid changeset: not in canonical form'); return cs; }; @@ -302,323 +310,6 @@ export const checkRep = (cs: string) => { * `Array.prototype.splice()`. */ -/** - * Class to iterate and modify texts which have several lines. It is used for applying Changesets on - * arrays of lines. - * - * Mutation operations have the same constraints as exports operations with respect to newlines, but - * not the other additional constraints (i.e. ins/del ordering, forbidden no-ops, non-mergeability, - * final newline). Can be used to mutate lists of strings where the last char of each string is not - * actually a newline, but for the purposes of N and L values, the caller should pretend it is, and - * for things to work right in that case, the input to the `insert` method should be a single line - * with no newlines. - */ -class TextLinesMutator { - /** - * @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place). - */ - constructor(lines) { - this._lines = lines; - /** - * this._curSplice holds values that will be passed as arguments to this._lines.splice() to - * insert, delete, or change lines: - * - this._curSplice[0] is an index into the this._lines array. - * - this._curSplice[1] is the number of lines that will be removed from the this._lines array - * starting at the index. - * - The other elements represent mutated (changed by ops) lines or new lines (added by ops) - * to insert at the index. - * - * @type {[number, number?, ...string[]?]} - */ - this._curSplice = [0, 0]; - this._inSplice = false; - // position in lines after curSplice is applied: - this._curLine = 0; - this._curCol = 0; - // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) && - // curLine >= curSplice[0] - // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then - // curCol == 0 - } - - /** - * Get a line from `lines` at given index. - * - * @param {number} idx - an index - * @returns {string} - */ - _linesGet(idx) { - if ('get' in this._lines) { - return this._lines.get(idx); - } else { - return this._lines[idx]; - } - } - - /** - * Return a slice from `lines`. - * - * @param {number} start - the start index - * @param {number} end - the end index - * @returns {string[]} - */ - _linesSlice(start, end) { - // can be unimplemented if removeLines's return value not needed - if (this._lines.slice) { - return this._lines.slice(start, end); - } else { - return []; - } - } - - /** - * Return the length of `lines`. - * - * @returns {number} - */ - _linesLength() { - if (typeof this._lines.length === 'number') { - return this._lines.length; - } else { - return this._lines.length(); - } - } - - /** - * Starts a new splice. - */ - _enterSplice() { - this._curSplice[0] = this._curLine; - this._curSplice[1] = 0; - // TODO(doc) when is this the case? - // check all enterSplice calls and changes to curCol - if (this._curCol > 0) this._putCurLineInSplice(); - this._inSplice = true; - } - - /** - * Changes the lines array according to the values in curSplice and resets curSplice. Called via - * close or TODO(doc). - */ - _leaveSplice() { - this._lines.splice(...this._curSplice); - this._curSplice.length = 2; - this._curSplice[0] = this._curSplice[1] = 0; - this._inSplice = false; - } - - /** - * Indicates if curLine is already in the splice. This is necessary because the last element in - * curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting). - * - * @returns {boolean} true if curLine is in splice - */ - _isCurLineInSplice() { - // The value of `this._curSplice[1]` does not matter when determining the return value because - // `this._curLine` refers to the line number *after* the splice is applied (so after those lines - // are deleted). - return this._curLine - this._curSplice[0] < this._curSplice.length - 2; - } - - /** - * Incorporates current line into the splice and marks its old position to be deleted. - * - * @returns {number} the index of the added line in curSplice - */ - _putCurLineInSplice() { - if (!this._isCurLineInSplice()) { - this._curSplice.push(this._linesGet(this._curSplice[0] + this._curSplice[1])); - this._curSplice[1]++; - } - // TODO should be the same as this._curSplice.length - 1 - return 2 + this._curLine - this._curSplice[0]; - } - - /** - * It will skip some newlines by putting them into the splice. - * - * @param {number} L - - * @param {boolean} includeInSplice - Indicates that attributes are present. - */ - skipLines(L, includeInSplice) { - if (!L) return; - if (includeInSplice) { - if (!this._inSplice) this._enterSplice(); - // TODO(doc) should this count the number of characters that are skipped to check? - for (let i = 0; i < L; i++) { - this._curCol = 0; - this._putCurLineInSplice(); - this._curLine++; - } - } else { - if (this._inSplice) { - if (L > 1) { - // TODO(doc) figure out why single lines are incorporated into splice instead of ignored - this._leaveSplice(); - } else { - this._putCurLineInSplice(); - } - } - this._curLine += L; - this._curCol = 0; - } - // tests case foo in remove(), which isn't otherwise covered in current impl - } - - /** - * Skip some characters. Can contain newlines. - * - * @param {number} N - number of characters to skip - * @param {number} L - number of newlines to skip - * @param {boolean} includeInSplice - indicates if attributes are present - */ - skip(N, L, includeInSplice) { - if (!N) return; - if (L) { - this.skipLines(L, includeInSplice); - } else { - if (includeInSplice && !this._inSplice) this._enterSplice(); - if (this._inSplice) { - // although the line is put into splice curLine is not increased, because - // only some chars are skipped, not the whole line - this._putCurLineInSplice(); - } - this._curCol += N; - } - } - - /** - * Remove whole lines from lines array. - * - * @param {number} L - number of lines to remove - * @returns {string} - */ - removeLines(L) { - if (!L) return ''; - if (!this._inSplice) this._enterSplice(); - - /** - * Gets a string of joined lines after the end of the splice. - * - * @param {number} k - number of lines - * @returns {string} joined lines - */ - const nextKLinesText = (k) => { - const m = this._curSplice[0] + this._curSplice[1]; - return this._linesSlice(m, m + k).join(''); - }; - - let removed = ''; - if (this._isCurLineInSplice()) { - if (this._curCol === 0) { - removed = this._curSplice[this._curSplice.length - 1]; - this._curSplice.length--; - removed += nextKLinesText(L - 1); - this._curSplice[1] += L - 1; - } else { - removed = nextKLinesText(L - 1); - this._curSplice[1] += L - 1; - const sline = this._curSplice.length - 1; - removed = this._curSplice[sline].substring(this._curCol) + removed; - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + - this._linesGet(this._curSplice[0] + this._curSplice[1]); - this._curSplice[1] += 1; - } - } else { - removed = nextKLinesText(L); - this._curSplice[1] += L; - } - return removed; - } - - /** - * Remove text from lines array. - * - * @param {number} N - characters to delete - * @param {number} L - lines to delete - * @returns {string} - */ - remove(N, L) { - if (!N) return ''; - if (L) return this.removeLines(L); - if (!this._inSplice) this._enterSplice(); - // although the line is put into splice, curLine is not increased, because - // only some chars are removed not the whole line - const sline = this._putCurLineInSplice(); - const removed = this._curSplice[sline].substring(this._curCol, this._curCol + N); - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + - this._curSplice[sline].substring(this._curCol + N); - return removed; - } - - /** - * Inserts text into lines array. - * - * @param {string} text - the text to insert - * @param {number} L - number of newlines in text - */ - insert(text, L) { - if (!text) return; - if (!this._inSplice) this._enterSplice(); - if (L) { - const newLines = exports.splitTextLines(text); - if (this._isCurLineInSplice()) { - const sline = this._curSplice.length - 1; - /** @type {string} */ - const theLine = this._curSplice[sline]; - const lineCol = this._curCol; - // Insert the chars up to `curCol` and the first new line. - this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; - this._curLine++; - newLines.splice(0, 1); - // insert the remaining new lines - this._curSplice.push(...newLines); - this._curLine += newLines.length; - // insert the remaining chars from the "old" line (e.g. the line we were in - // when we started to insert new lines) - this._curSplice.push(theLine.substring(lineCol)); - this._curCol = 0; // TODO(doc) why is this not set to the length of last line? - } else { - this._curSplice.push(...newLines); - this._curLine += newLines.length; - } - } else { - // There are no additional lines. Although the line is put into splice, curLine is not - // increased because there may be more chars in the line (newline is not reached). - const sline = this._putCurLineInSplice(); - if (!this._curSplice[sline]) { - const err = new Error( - 'curSplice[sline] not populated, actual curSplice contents is ' + - `${JSON.stringify(this._curSplice)}. Possibly related to ` + - 'https://github.com/ether/etherpad-lite/issues/2802'); - console.error(err.stack || err.toString()); - } - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + text + - this._curSplice[sline].substring(this._curCol); - this._curCol += text.length; - } - } - - /** - * Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`. - * - * @returns {boolean} indicates if there are lines left - */ - hasMore() { - let docLines = this._linesLength(); - if (this._inSplice) { - docLines += this._curSplice.length - 2 - this._curSplice[1]; - } - return this._curLine < docLines; - } - - /** - * Closes the splice - */ - close() { - if (this._inSplice) this._leaveSplice(); - } -} /** * Apply operations to other operations. @@ -641,12 +332,12 @@ class TextLinesMutator { * either `opOut` must be nullish or `opOut.opcode` must be the empty string. * @returns {string} the integrated changeset */ -const applyZip = (in1, in2, func) => { - const ops1 = exports.deserializeOps(in1); - const ops2 = exports.deserializeOps(in2); +const applyZip = (in1: string, in2: string, func: Function): string => { + const ops1 = deserializeOps(in1); + const ops2 = deserializeOps(in2); let next1 = ops1.next(); let next2 = ops2.next(); - const assem = exports.smartOpAssembler(); + const assem = new SmartOpAssembler(); while (!next1.done || !next2.done) { if (!next1.done && !next1.value.opcode) next1 = ops1.next(); if (!next2.done && !next2.value.opcode) next2 = ops2.next(); @@ -666,15 +357,15 @@ const applyZip = (in1, in2, func) => { * @param {string} cs - The encoded changeset. * @returns {Changeset} */ -exports.unpack = (cs) => { +export const unpack = (cs: string): ChangeSet => { const headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/; const headerMatch = headerRegex.exec(cs); if ((!headerMatch) || (!headerMatch[0])) error(`Not a changeset: ${cs}`); - const oldLen = parseNum(headerMatch[1]); - const changeSign = (headerMatch[2] === '>') ? 1 : -1; - const changeMag = parseNum(headerMatch[3]); + const oldLen = parseNum(headerMatch![1]); + const changeSign = (headerMatch![2] === '>') ? 1 : -1; + const changeMag = parseNum(headerMatch![3]); const newLen = oldLen + changeSign * changeMag; - const opsStart = headerMatch[0].length; + const opsStart = headerMatch![0].length; let opsEnd = cs.indexOf('$'); if (opsEnd < 0) opsEnd = cs.length; return { @@ -694,12 +385,12 @@ exports.unpack = (cs) => { * @param {string} bank - Characters for insert operations. * @returns {string} The encoded changeset. */ -exports.pack = (oldLen, newLen, opsStr, bank) => { +export const pack = (oldLen: number, newLen: number, opsStr: string, bank: string): string => { const lenDiff = newLen - oldLen; - const lenDiffStr = (lenDiff >= 0 ? `>${exports.numToString(lenDiff)}` - : `<${exports.numToString(-lenDiff)}`); + const lenDiffStr = (lenDiff >= 0 ? `>${numToString(lenDiff)}` + : `<${numToString(-lenDiff)}`); const a = []; - a.push('Z:', exports.numToString(oldLen), lenDiffStr, opsStr, '$', bank); + a.push('Z:', numToString(oldLen), lenDiffStr, opsStr, '$', bank); return a.join(''); }; @@ -710,13 +401,13 @@ exports.pack = (oldLen, newLen, opsStr, bank) => { * @param {string} str - String to which a Changeset should be applied * @returns {string} */ -exports.applyToText = (cs, str) => { - const unpacked = exports.unpack(cs); +export const applyToText = (cs: string, str: string): string => { + const unpacked = unpack(cs); assert(str.length === unpacked.oldLen, `mismatched apply: ${str.length} / ${unpacked.oldLen}`); - const bankIter = exports.stringIterator(unpacked.charBank); - const strIter = exports.stringIterator(str); + const bankIter = new StringIterator(unpacked.charBank); + const strIter = new StringIterator(str); const assem = new StringAssembler(); - for (const op of exports.deserializeOps(unpacked.ops)) { + for (const op of deserializeOps(unpacked.ops)) { switch (op.opcode) { case '+': // op is + and op.lines 0: no newlines must be in op.chars @@ -754,11 +445,11 @@ exports.applyToText = (cs, str) => { * @param {string} cs - the changeset to apply * @param {string[]} lines - The lines to which the changeset needs to be applied */ -exports.mutateTextLines = (cs, lines) => { - const unpacked = exports.unpack(cs); - const bankIter = exports.stringIterator(unpacked.charBank); +const mutateTextLines = (cs: string, lines:string[]) => { + const unpacked = unpack(cs); + const bankIter = new StringIterator(unpacked.charBank); const mut = new TextLinesMutator(lines); - for (const op of exports.deserializeOps(unpacked.ops)) { + for (const op of deserializeOps(unpacked.ops)) { switch (op.opcode) { case '+': mut.insert(bankIter.take(op.chars), op.lines); @@ -783,7 +474,7 @@ exports.mutateTextLines = (cs, lines) => { * @param {AttributePool.ts} pool - attribute pool * @returns {string} */ -exports.composeAttributes = (att1, att2, resultIsMutation, pool) => { +export const composeAttributes = (att1: string, att2: string, resultIsMutation: boolean, pool?: AttributePool|null): string => { // att1 and att2 are strings like "*3*f*1c", asMutation is a boolean. // Sometimes attribute (key,value) pairs are treated as attribute presence // information, while other times they are treated as operations that @@ -817,7 +508,7 @@ exports.composeAttributes = (att1, att2, resultIsMutation, pool) => { * @param {AttributePool.ts} pool - Can be null if definitely not needed. * @returns {Op} The result of applying `csOp` to `attOp`. */ -const slicerZipperFunc = (attOp, csOp, pool) => { +export const slicerZipperFunc = (attOp: Op, csOp: Op, pool: AttributePool|null):Op => { const opOut = new Op(); if (!attOp.opcode) { copyOp(csOp, opOut); @@ -852,7 +543,7 @@ const slicerZipperFunc = (attOp, csOp, pool) => { '-': '-', '=': '=', }, - }[attOp.opcode][csOp.opcode]; + }[attOp.opcode][csOp.opcode] as OpCode; const [fullyConsumedOp, partiallyConsumedOp] = [attOp, csOp].sort((a, b) => a.chars - b.chars); opOut.chars = fullyConsumedOp.chars; opOut.lines = fullyConsumedOp.lines; @@ -861,7 +552,7 @@ const slicerZipperFunc = (attOp, csOp, pool) => { // normally be the empty string. However, padDiff.js adds attributes to remove ops and needs // them preserved so they are copied here. ? csOp.attribs - : exports.composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode === '=', pool); + : composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode === '=', pool); partiallyConsumedOp.chars -= fullyConsumedOp.chars; partiallyConsumedOp.lines -= fullyConsumedOp.lines; if (!partiallyConsumedOp.chars) partiallyConsumedOp.opcode = ''; @@ -878,120 +569,9 @@ const slicerZipperFunc = (attOp, csOp, pool) => { * @param {AttributePool.ts} pool - the attibutes pool * @returns {string} */ -exports.applyToAttribution = (cs, astr, pool) => { - const unpacked = exports.unpack(cs); - return applyZip(astr, unpacked.ops, (op1, op2) => slicerZipperFunc(op1, op2, pool)); -}; - -/** - * Applies a changeset to an array of attribute lines. - * - * @param {string} cs - The encoded changeset. - * @param {Array} lines - Attribute lines. Modified in place. - * @param {AttributePool.ts} pool - Attribute pool. - */ -exports.mutateAttributionLines = (cs, lines, pool) => { - const unpacked = exports.unpack(cs); - const csOps = exports.deserializeOps(unpacked.ops); - let csOpsNext = csOps.next(); - const csBank = unpacked.charBank; - let csBankIndex = 0; - // treat the attribution lines as text lines, mutating a line at a time - const mut = new TextLinesMutator(lines); - - /** - * The Ops in the current line from `lines`. - * - * @type {?Generator} - */ - let lineOps = null; - let lineOpsNext = null; - - const lineOpsHasNext = () => lineOpsNext && !lineOpsNext.done; - /** - * Returns false if we are on the last attribute line in `lines` and there is no additional op in - * that line. - * - * @returns {boolean} True if there are more ops to go through. - */ - const isNextMutOp = () => lineOpsHasNext() || mut.hasMore(); - - /** - * @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to - * iterate over the next line, which is consumed from `mut`. If there are no more lines, - * returns a null Op. - */ - const nextMutOp = () => { - if (!lineOpsHasNext() && mut.hasMore()) { - // There are more attribute lines in `lines` to do AND either we just started so `lineIter` is - // still null or there are no more ops in current `lineIter`. - const line = mut.removeLines(1); - lineOps = exports.deserializeOps(line); - lineOpsNext = lineOps.next(); - } - if (!lineOpsHasNext()) return new Op(); // No more ops and no more lines. - const op = lineOpsNext.value; - lineOpsNext = lineOps.next(); - return op; - }; - let lineAssem = null; - - /** - * Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the - * `lines` mutator. - */ - const outputMutOp = (op) => { - if (!lineAssem) { - lineAssem = exports.mergingOpAssembler(); - } - lineAssem.append(op); - if (op.lines <= 0) return; - assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`); - // ship it to the mut - mut.insert(lineAssem.toString(), 1); - lineAssem = null; - }; - - let csOp = new Op(); - let attOp = new Op(); - while (csOp.opcode || !csOpsNext.done || attOp.opcode || isNextMutOp()) { - if (!csOp.opcode && !csOpsNext.done) { - // coOp done, but more ops in cs. - csOp = csOpsNext.value; - csOpsNext = csOps.next(); - } - if (!csOp.opcode && !attOp.opcode && !lineAssem && !lineOpsHasNext()) { - break; // done - } else if (csOp.opcode === '=' && csOp.lines > 0 && !csOp.attribs && !attOp.opcode && - !lineAssem && !lineOpsHasNext()) { - // Skip multiple lines without attributes; this is what makes small changes not order of the - // document size. - mut.skipLines(csOp.lines); - csOp.opcode = ''; - } else if (csOp.opcode === '+') { - const opOut = copyOp(csOp); - if (csOp.lines > 1) { - // Copy the first line from `csOp` to `opOut`. - const firstLineLen = csBank.indexOf('\n', csBankIndex) + 1 - csBankIndex; - csOp.chars -= firstLineLen; - csOp.lines--; - opOut.lines = 1; - opOut.chars = firstLineLen; - } else { - // Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`. - csOp.opcode = ''; - } - outputMutOp(opOut); - csBankIndex += opOut.chars; - } else { - if (!attOp.opcode && isNextMutOp()) attOp = nextMutOp(); - const opOut = slicerZipperFunc(attOp, csOp, pool); - if (opOut.opcode) outputMutOp(opOut); - } - } - - assert(!lineAssem, `line assembler not finished:${cs}`); - mut.close(); +export const applyToAttribution = (cs: string, astr: string, pool: AttributePool): string => { + const unpacked = unpack(cs); + return applyZip(astr, unpacked.ops, (op1: Op, op2:Op) => slicerZipperFunc(op1, op2, pool)); }; /** @@ -1000,20 +580,20 @@ exports.mutateAttributionLines = (cs, lines, pool) => { * @param {string[]} theAlines - collection of Attribution lines * @returns {string} joined Attribution lines */ -exports.joinAttributionLines = (theAlines) => { - const assem = exports.mergingOpAssembler(); +export const joinAttributionLines = (theAlines: string[]): string => { + const assem = new MergingOpAssembler(); for (const aline of theAlines) { - for (const op of exports.deserializeOps(aline)) assem.append(op); + for (const op of deserializeOps(aline)) assem.append(op); } return assem.toString(); }; -exports.splitAttributionLines = (attrOps, text) => { - const assem = exports.mergingOpAssembler(); - const lines = []; +export const splitAttributionLines = (attrOps: string, text: string) => { + const assem = new MergingOpAssembler(); + const lines: string[] = []; let pos = 0; - const appendOp = (op) => { + const appendOp = (op:Op) => { assem.append(op); if (op.lines > 0) { lines.push(assem.toString()); @@ -1022,7 +602,7 @@ exports.splitAttributionLines = (attrOps, text) => { pos += op.chars; }; - for (const op of exports.deserializeOps(attrOps)) { + for (const op of deserializeOps(attrOps)) { let numChars = op.chars; let numLines = op.lines; while (numLines > 1) { @@ -1050,7 +630,7 @@ exports.splitAttributionLines = (attrOps, text) => { * @param {string} text - text to split * @returns {string[]} */ -exports.splitTextLines = (text) => text.match(/[^\n]*(?:\n|[^\n]$)/g); +export const splitTextLines = (text:string) => text.match(/[^\n]*(?:\n|[^\n]$)/g); /** * Compose two Changesets. @@ -1060,18 +640,18 @@ exports.splitTextLines = (text) => text.match(/[^\n]*(?:\n|[^\n]$)/g); * @param {AttributePool.ts} pool - Attribs pool * @returns {string} */ -exports.compose = (cs1, cs2, pool) => { - const unpacked1 = exports.unpack(cs1); - const unpacked2 = exports.unpack(cs2); +export const compose = (cs1: string, cs2:string, pool: AttributePool): string => { + const unpacked1 = unpack(cs1); + const unpacked2 = unpack(cs2); const len1 = unpacked1.oldLen; const len2 = unpacked1.newLen; assert(len2 === unpacked2.oldLen, 'mismatched composition of two changesets'); const len3 = unpacked2.newLen; - const bankIter1 = exports.stringIterator(unpacked1.charBank); - const bankIter2 = exports.stringIterator(unpacked2.charBank); + const bankIter1 = new StringIterator(unpacked1.charBank); + const bankIter2 = new StringIterator(unpacked2.charBank); const bankAssem = new StringAssembler(); - const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { + const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1: Op, op2: Op) => { const op1code = op1.opcode; const op2code = op2.opcode; if (op1code === '+' && op2code === '-') { @@ -1088,7 +668,7 @@ exports.compose = (cs1, cs2, pool) => { return opOut; }); - return exports.pack(len1, len3, newOps, bankAssem.toString()); + return pack(len1, len3, newOps, bankAssem.toString()); }; /** @@ -1099,13 +679,13 @@ exports.compose = (cs1, cs2, pool) => { * @param {AttributePool.ts} pool - Attribute pool * @returns {Function} */ -exports.attributeTester = (attribPair, pool) => { - const never = (attribs) => false; +export const attributeTester = (attribPair: Attribute, pool: AttributePool): Function => { + const never = (attribs: Attribute[]) => false; if (!pool) return never; const attribNum = pool.putAttrib(attribPair, true); if (attribNum < 0) return never; - const re = new RegExp(`\\*${exports.numToString(attribNum)}(?!\\w)`); - return (attribs) => re.test(attribs); + const re = new RegExp(`\\*${numToString(attribNum)}(?!\\w)`); + return (attribs: string) => re.test(attribs); }; /** @@ -1114,7 +694,7 @@ exports.attributeTester = (attribPair, pool) => { * @param {number} N - length of the identity changeset * @returns {string} */ -exports.identity = (N) => exports.pack(N, N, '', ''); +export const identity = (N: number): string => pack(N, N, '', ''); /** * Creates a Changeset which works on oldFullText and removes text from spliceStart to @@ -1129,21 +709,21 @@ exports.identity = (N) => exports.pack(N, N, '', ''); * @param {AttributePool.ts} [pool] - Attribute pool. * @returns {string} */ -exports.makeSplice = (orig, start, ndel, ins, attribs, pool) => { +export const makeSplice = (orig: string, start: number, ndel: number, ins: string, attribs: string | Attribute[] | undefined, pool: AttributePool | null | undefined): string => { if (start < 0) throw new RangeError(`start index must be non-negative (is ${start})`); if (ndel < 0) throw new RangeError(`characters to delete must be non-negative (is ${ndel})`); if (start > orig.length) start = orig.length; if (ndel > orig.length - start) ndel = orig.length - start; const deleted = orig.substring(start, start + ndel); - const assem = exports.smartOpAssembler(); + const assem = new SmartOpAssembler(); const ops = (function* () { yield* opsFromText('=', orig.substring(0, start)); yield* opsFromText('-', deleted); - yield* opsFromText('+', ins, attribs, pool); + yield* opsFromText('+', ins as string, attribs, pool); })(); for (const op of ops) assem.append(op); assem.endDocument(); - return exports.pack(orig.length, orig.length + ins.length - ndel, assem.toString(), ins); + return pack(orig.length, orig.length + ins.length - ndel, assem.toString(), ins); }; /** @@ -1153,15 +733,15 @@ exports.makeSplice = (orig, start, ndel, ins, attribs, pool) => { * @param {string} cs - Changeset * @returns {[number, number, string][]} */ -const toSplices = (cs) => { - const unpacked = exports.unpack(cs); +const toSplices = (cs: string): [number, number, string][] => { + const unpacked = unpack(cs); /** @type {[number, number, string][]} */ - const splices = []; + const splices: [number, number, string][] = []; let oldPos = 0; - const charIter = exports.stringIterator(unpacked.charBank); + const charIter = new StringIterator(unpacked.charBank); let inSplice = false; - for (const op of exports.deserializeOps(unpacked.ops)) { + for (const op of deserializeOps(unpacked.ops)) { if (op.opcode === '=') { oldPos += op.chars; inSplice = false; @@ -1189,7 +769,7 @@ const toSplices = (cs) => { * @param {number} insertionsAfter - * @returns {[number, number]} */ -exports.characterRangeFollow = (cs, startChar, endChar, insertionsAfter) => { +export const characterRangeFollow = (cs: string, startChar: number, endChar: number, insertionsAfter: number):[number, number] => { let newStartChar = startChar; let newEndChar = endChar; let lengthChangeSoFar = 0; @@ -1239,7 +819,7 @@ exports.characterRangeFollow = (cs, startChar, endChar, insertionsAfter) => { * @param {AttributePool} newPool - new attributes pool * @returns {string} the new Changeset */ -exports.moveOpsToNewPool = (cs, oldPool, newPool) => { +export const moveOpsToNewPool = (cs: string, oldPool: AttributePool, newPool: AttributePool): string => { // works on exports or attribution string let dollarPos = cs.indexOf('$'); if (dollarPos < 0) { @@ -1265,8 +845,8 @@ exports.moveOpsToNewPool = (cs, oldPool, newPool) => { * @param {string} text - text to insert * @returns {string} */ -exports.makeAttribution = (text) => { - const assem = exports.smartOpAssembler(); +export const makeAttribution = (text: string) => { + const assem = new SmartOpAssembler(); for (const op of opsFromText('+', text)) assem.append(op); return assem.toString(); }; @@ -1279,7 +859,7 @@ exports.makeAttribution = (text) => { * @param {string} cs - changeset * @param {Function} func - function to call */ -exports.eachAttribNumber = (cs, func) => { +export const eachAttribNumber = (cs: string, func: Function) => { padutils.warnDeprecated( 'Changeset.eachAttribNumber() is deprecated; use attributes.decodeAttribString() instead'); let dollarPos = cs.indexOf('$'); @@ -1305,16 +885,16 @@ exports.eachAttribNumber = (cs, func) => { * Changeset * @returns {string} */ -exports.filterAttribNumbers = (cs, filter) => exports.mapAttribNumbers(cs, filter); +export const filterAttribNumbers = (cs: string, filter: Function) => mapAttribNumbers(cs, filter); /** - * Does exactly the same as exports.filterAttribNumbers. + * Does exactly the same as filterAttribNumbers. * * @param {string} cs - * @param {Function} func - * @returns {string} */ -exports.mapAttribNumbers = (cs, func) => { +export const mapAttribNumbers = (cs: string, func: Function): string => { let dollarPos = cs.indexOf('$'); if (dollarPos < 0) { dollarPos = cs.length; @@ -1352,9 +932,9 @@ exports.mapAttribNumbers = (cs, func) => { * attributes * @returns {AText} */ -exports.makeAText = (text, attribs) => ({ +export const makeAText = (text: string, attribs: string): AText => ({ text, - attribs: (attribs || exports.makeAttribution(text)), + attribs: (attribs || makeAttribution(text)), }); /** @@ -1365,9 +945,9 @@ exports.makeAText = (text, attribs) => ({ * @param {AttributePool.ts} pool - Attribute Pool to add to * @returns {AText} */ -exports.applyToAText = (cs, atext, pool) => ({ - text: exports.applyToText(cs, atext.text), - attribs: exports.applyToAttribution(cs, atext.attribs, pool), +export const applyToAText = (cs: string, atext: AText, pool: AttributePool): AText => ({ + text: applyToText(cs, atext.text), + attribs: applyToAttribution(cs, atext.attribs, pool), }); /** @@ -1376,7 +956,7 @@ exports.applyToAText = (cs, atext, pool) => ({ * @param {AText} atext - * @returns {AText} */ -exports.cloneAText = (atext) => { +export const cloneAText = (atext: AText): AText => { if (!atext) error('atext is null'); return { text: atext.text, @@ -1390,7 +970,7 @@ exports.cloneAText = (atext) => { * @param {AText} atext1 - * @param {AText} atext2 - */ -exports.copyAText = (atext1, atext2) => { +export const copyAText = (atext1: AText, atext2: AText) => { atext2.text = atext1.text; atext2.attribs = atext1.attribs; }; @@ -1402,10 +982,10 @@ exports.copyAText = (atext1, atext2) => { * @yields {Op} * @returns {Generator} */ -exports.opsFromAText = function* (atext) { +export const opsFromAText = function* (atext: AText): Generator { // intentionally skips last newline char of atext let lastOp = null; - for (const op of exports.deserializeOps(atext.attribs)) { + for (const op of deserializeOps(atext.attribs)) { if (lastOp != null) yield lastOp; lastOp = op; } @@ -1433,12 +1013,17 @@ exports.opsFromAText = function* (atext) { * @param {AText} atext - * @param assem - Assembler like SmartOpAssembler TODO add desc */ -exports.appendATextToAssembler = (atext, assem) => { +export const appendATextToAssembler = (atext: AText, assem: SmartOpAssembler) => { padutils.warnDeprecated( 'Changeset.appendATextToAssembler() is deprecated; use Changeset.opsFromAText() instead'); - for (const op of exports.opsFromAText(atext)) assem.append(op); + for (const op of opsFromAText(atext)) assem.append(op); }; +type WirePrep = { + translated: string, + pool: AttributePool +} + /** * Creates a clone of a Changeset and it's APool. * @@ -1446,9 +1031,9 @@ exports.appendATextToAssembler = (atext, assem) => { * @param {AttributePool.ts} pool - * @returns {{translated: string, pool: AttributePool.ts}} */ -exports.prepareForWire = (cs, pool) => { +export const prepareForWire = (cs: string, pool: AttributePool): WirePrep => { const newPool = new AttributePool(); - const newCs = exports.moveOpsToNewPool(cs, pool, newPool); + const newCs = moveOpsToNewPool(cs, pool, newPool); return { translated: newCs, pool: newPool, @@ -1461,17 +1046,17 @@ exports.prepareForWire = (cs, pool) => { * @param {string} cs - * @returns {boolean} */ -exports.isIdentity = (cs) => { - const unpacked = exports.unpack(cs); +export const isIdentity = (cs: string): boolean => { + const unpacked = unpack(cs); return unpacked.ops === '' && unpacked.oldLen === unpacked.newLen; }; /** * @deprecated Use an AttributeMap instead. */ -const attribsAttributeValue = (attribs, key, pool) => { +const _attribsAttributeValue = (attribs: string, key: string, pool: AttributePool) => { if (!attribs) return ''; - for (const [k, v] of attributes.attribsFromString(attribs, pool)) { + for (const [k, v] of attribsFromString(attribs, pool)) { if (k === key) return v; } return ''; @@ -1486,10 +1071,10 @@ const attribsAttributeValue = (attribs, key, pool) => { * @param {AttributePool.ts} pool - attribute pool * @returns {string} */ -exports.opAttributeValue = (op, key, pool) => { +export const opAttributeValue = (op: Op, key: string, pool: AttributePool):string => { padutils.warnDeprecated( 'Changeset.opAttributeValue() is deprecated; use an AttributeMap instead'); - return attribsAttributeValue(op.attribs, key, pool); + return _attribsAttributeValue(op.attribs, key, pool); }; /** @@ -1501,104 +1086,13 @@ exports.opAttributeValue = (op, key, pool) => { * @param {AttributePool.ts} pool - attribute pool * @returns {string} */ -exports.attribsAttributeValue = (attribs, key, pool) => { +export const attribsAttributeValue = (attribs: string, key: string, pool: AttributePool) => { padutils.warnDeprecated( 'Changeset.attribsAttributeValue() is deprecated; use an AttributeMap instead'); - return attribsAttributeValue(attribs, key, pool); + return _attribsAttributeValue(attribs, key, pool); }; -/** - * Incrementally builds a Changeset. - * - * @typedef {object} Builder - * @property {Function} insert - - * @property {Function} keep - - * @property {Function} keepText - - * @property {Function} remove - - * @property {Function} toString - - */ - -/** - * @param {number} oldLen - Old length - * @returns {Builder} - */ -exports.builder = (oldLen) => { - const assem = exports.smartOpAssembler(); - const o = new Op(); - const charBank = new StringAssembler(); - - const self = { - /** - * @param {number} N - Number of characters to keep. - * @param {number} L - Number of newlines among the `N` characters. If positive, the last - * character must be a newline. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - keep: (N, L, attribs, pool) => { - o.opcode = '='; - o.attribs = typeof attribs === 'string' - ? attribs : new AttributeMap(pool).update(attribs || []).toString(); - o.chars = N; - o.lines = (L || 0); - assem.append(o); - return self; - }, - - /** - * @param {string} text - Text to keep. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - keepText: (text, attribs, pool) => { - for (const op of opsFromText('=', text, attribs, pool)) assem.append(op); - return self; - }, - - /** - * @param {string} text - Text to insert. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - insert: (text, attribs, pool) => { - for (const op of opsFromText('+', text, attribs, pool)) assem.append(op); - charBank.append(text); - return self; - }, - - /** - * @param {number} N - Number of characters to remove. - * @param {number} L - Number of newlines among the `N` characters. If positive, the last - * character must be a newline. - * @returns {Builder} this - */ - remove: (N, L) => { - o.opcode = '-'; - o.attribs = ''; - o.chars = N; - o.lines = (L || 0); - assem.append(o); - return self; - }, - - toString: () => { - assem.endDocument(); - const newLen = oldLen + assem.getLengthChange(); - return exports.pack(oldLen, newLen, assem.toString(), charBank.toString()); - }, - }; - return self; -}; /** * Constructs an attribute string from a sequence of attributes. @@ -1613,7 +1107,7 @@ exports.builder = (oldLen) => { * ignored if `attribs` is an attribute string. * @returns {AttributeString} */ -exports.makeAttribsString = (opcode, attribs, pool) => { +export const makeAttribsString = (opcode: string, attribs: Iterable<[string, string]>|string, pool: AttributePool | null | undefined): string => { padutils.warnDeprecated( 'Changeset.makeAttribsString() is deprecated; ' + 'use AttributeMap.prototype.toString() or attributes.attribsToString() instead'); @@ -1625,10 +1119,10 @@ exports.makeAttribsString = (opcode, attribs, pool) => { /** * Like "substring" but on a single-line attribution string. */ -exports.subattribution = (astr, start, optEnd) => { - const attOps = exports.deserializeOps(astr); +export const subattribution = (astr: string, start: number, optEnd: number) => { + const attOps = deserializeOps(astr); let attOpsNext = attOps.next(); - const assem = exports.smartOpAssembler(); + const assem = new SmartOpAssembler(); let attOp = new Op(); const csOp = new Op(); @@ -1636,7 +1130,7 @@ exports.subattribution = (astr, start, optEnd) => { if (!csOp.chars) return; while (csOp.opcode && (attOp.opcode || !attOpsNext.done)) { if (!attOp.opcode) { - attOp = attOpsNext.value; + attOp = attOpsNext.value as Op; attOpsNext = attOps.next(); } if (csOp.opcode && attOp.opcode && csOp.chars >= attOp.chars && @@ -1670,13 +1164,18 @@ exports.subattribution = (astr, start, optEnd) => { return assem.toString(); }; -exports.inverse = (cs, lines, alines, pool) => { +export const inverse = (cs: string, lines: string|{ + get: (idx: number) => string, +}, alines: string|{ + get: (idx: number) => string, +}, pool: AttributePool) => { // lines and alines are what the exports is meant to apply to. // They may be arrays or objects with .get(i) and .length methods. // They include final newlines on lines. - const linesGet = (idx) => { - if (lines.get) { + const linesGet = (idx: number) => { + // @ts-ignore + if ("get" in lines) { return lines.get(idx); } else { return lines[idx]; @@ -1687,8 +1186,9 @@ exports.inverse = (cs, lines, alines, pool) => { * @param {number} idx - * @returns {string} */ - const alinesGet = (idx) => { - if (alines.get) { + const alinesGet = (idx: number): string => { + // @ts-ignore + if ("get" in alines) { return alines.get(idx); } else { return alines[idx]; @@ -1697,17 +1197,17 @@ exports.inverse = (cs, lines, alines, pool) => { let curLine = 0; let curChar = 0; - let curLineOps = null; - let curLineOpsNext = null; - let curLineOpsLine; + let curLineOps: null|Generator = null; + let curLineOpsNext:IteratorResult|null = null; + let curLineOpsLine: number; let curLineNextOp = new Op('+'); - const unpacked = exports.unpack(cs); - const builder = exports.builder(unpacked.newLen); + const unpacked = unpack(cs); + const builder = new Builder(unpacked.newLen); - const consumeAttribRuns = (numChars, func /* (len, attribs, endsLine)*/) => { + const consumeAttribRuns = (numChars: number, func: Function /* (len, attribs, endsLine)*/) => { if (!curLineOps || curLineOpsLine !== curLine) { - curLineOps = exports.deserializeOps(alinesGet(curLine)); + curLineOps = deserializeOps(alinesGet(curLine)); curLineOpsNext = curLineOps.next(); curLineOpsLine = curLine; let indexIntoLine = 0; @@ -1723,19 +1223,19 @@ exports.inverse = (cs, lines, alines, pool) => { } while (numChars > 0) { - if (!curLineNextOp.chars && curLineOpsNext.done) { + if (!curLineNextOp.chars && curLineOpsNext!.done) { curLine++; curChar = 0; curLineOpsLine = curLine; curLineNextOp.chars = 0; - curLineOps = exports.deserializeOps(alinesGet(curLine)); - curLineOpsNext = curLineOps.next(); + curLineOps = deserializeOps(alinesGet(curLine)); + curLineOpsNext = curLineOps!.next(); } if (!curLineNextOp.chars) { - if (curLineOpsNext.done) { + if (curLineOpsNext!.done) { curLineNextOp = new Op(); } else { - curLineNextOp = curLineOpsNext.value; + curLineNextOp = curLineOpsNext!.value; curLineOpsNext = curLineOps.next(); } } @@ -1747,13 +1247,13 @@ exports.inverse = (cs, lines, alines, pool) => { curChar += charsToUse; } - if (!curLineNextOp.chars && curLineOpsNext.done) { + if (!curLineNextOp.chars && curLineOpsNext!.done) { curLine++; curChar = 0; } }; - const skip = (N, L) => { + const skip = (N: number, L: number) => { if (L) { curLine += L; curChar = 0; @@ -1764,7 +1264,7 @@ exports.inverse = (cs, lines, alines, pool) => { } }; - const nextText = (numChars) => { + const nextText = (numChars: number) => { let len = 0; const assem = new StringAssembler(); const firstString = linesGet(curLine).substring(curChar); @@ -1782,9 +1282,11 @@ exports.inverse = (cs, lines, alines, pool) => { return assem.toString().substring(0, numChars); }; - const cachedStrFunc = (func) => { - const cache = {}; - return (s) => { + const cachedStrFunc = (func: Function) => { + const cache:{ + [key: string]: string + } = {}; + return (s: string | number) => { if (!cache[s]) { cache[s] = func(s); } @@ -1792,11 +1294,11 @@ exports.inverse = (cs, lines, alines, pool) => { }; }; - for (const csOp of exports.deserializeOps(unpacked.ops)) { + for (const csOp of deserializeOps(unpacked.ops)) { if (csOp.opcode === '=') { if (csOp.attribs) { const attribs = AttributeMap.fromString(csOp.attribs, pool); - const undoBackToAttribs = cachedStrFunc((oldAttribsStr) => { + const undoBackToAttribs = cachedStrFunc((oldAttribsStr: string) => { const oldAttribs = AttributeMap.fromString(oldAttribsStr, pool); const backAttribs = new AttributeMap(pool); for (const [key, value] of attribs) { @@ -1807,7 +1309,7 @@ exports.inverse = (cs, lines, alines, pool) => { // are in oldAttribs but not in attribs). I don't know if that is intentional. return backAttribs.toString(); }); - consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { + consumeAttribRuns(csOp.chars, (len: number, attribs: string, endsLine: number) => { builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs)); }); } else { @@ -1819,33 +1321,33 @@ exports.inverse = (cs, lines, alines, pool) => { } else if (csOp.opcode === '-') { const textBank = nextText(csOp.chars); let textBankIndex = 0; - consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { + consumeAttribRuns(csOp.chars, (len: number, attribs: string) => { builder.insert(textBank.substr(textBankIndex, len), attribs); textBankIndex += len; }); } } - return exports.checkRep(builder.toString()); + return checkRep(builder.toString()); }; // %CLIENT FILE ENDS HERE% -exports.follow = (cs1, cs2, reverseInsertOrder, pool) => { - const unpacked1 = exports.unpack(cs1); - const unpacked2 = exports.unpack(cs2); +export const follow = (cs1: string, cs2:string, reverseInsertOrder: boolean, pool: AttributePool) => { + const unpacked1 = unpack(cs1); + const unpacked2 = unpack(cs2); const len1 = unpacked1.oldLen; const len2 = unpacked2.oldLen; assert(len1 === len2, 'mismatched follow - cannot transform cs1 on top of cs2'); - const chars1 = exports.stringIterator(unpacked1.charBank); - const chars2 = exports.stringIterator(unpacked2.charBank); + const chars1 = new StringIterator(unpacked1.charBank); + const chars2 = new StringIterator(unpacked2.charBank); const oldLen = unpacked1.newLen; let oldPos = 0; let newLen = 0; - const hasInsertFirst = exports.attributeTester(['insertorder', 'first'], pool); + const hasInsertFirst = attributeTester(['insertorder', 'first'], pool); - const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { + const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1: Op, op2: Op) => { const opOut = new Op(); if (op1.opcode === '+' || op2.opcode === '+') { let whichToDo; @@ -1968,10 +1470,10 @@ exports.follow = (cs1, cs2, reverseInsertOrder, pool) => { }); newLen += oldLen - oldPos; - return exports.pack(oldLen, newLen, newOps, unpacked2.charBank); + return pack(oldLen, newLen, newOps, unpacked2.charBank); }; -const followAttributes = (att1, att2, pool) => { +const followAttributes = (att1: string, att2: string, pool: AttributePool) => { // The merge of two sets of attribute changes to the same text // takes the lexically-earlier value if there are two values // for the same key. Otherwise, all key/value changes from @@ -2000,7 +1502,7 @@ const followAttributes = (att1, att2, pool) => { return buf.toString(); }; -exports.exportedForTestingOnly = { +export const exportedForTestingOnly = { TextLinesMutator, followAttributes, toSplices, diff --git a/src/static/js/Op.ts b/src/static/js/Op.ts index 73233027e5e..fa33da56044 100644 --- a/src/static/js/Op.ts +++ b/src/static/js/Op.ts @@ -1,3 +1,6 @@ +export type OpCode = ''|'='|'+'|'-'; + + /** * An operation to apply to a shared document. */ diff --git a/src/static/js/OpIter.ts b/src/static/js/OpIter.ts index 18a63a6861a..40b0abaf487 100644 --- a/src/static/js/OpIter.ts +++ b/src/static/js/OpIter.ts @@ -1,4 +1,5 @@ import Op from "./Op"; +import {clearOp, copyOp, deserializeOps} from "./Changeset"; /** * Iterator over a changeset's operations. @@ -9,19 +10,20 @@ import Op from "./Op"; */ export class OpIter { private gen + private _next: IteratorResult /** * @param {string} ops - String encoding the change operations to iterate over. */ constructor(ops: string) { - this.gen = exports.deserializeOps(ops); - this.next = this.gen.next(); + this.gen = deserializeOps(ops); + this._next = this.gen.next(); } /** * @returns {boolean} Whether there are any remaining operations. */ - hasNext() { - return !this.next.done; + hasNext(): boolean { + return !this._next.done; } /** @@ -33,10 +35,10 @@ export class OpIter { * @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are * no more operations. */ - next(opOut = new Op()) { + next(opOut: Op = new Op()): Op { if (this.hasNext()) { - copyOp(this._next.value, opOut); - this._next = this._gen.next(); + copyOp(this._next.value!, opOut); + this._next = this.gen.next(); } else { clearOp(opOut); } diff --git a/src/static/js/TextLinesMutator.ts b/src/static/js/TextLinesMutator.ts new file mode 100644 index 00000000000..d0b822cff61 --- /dev/null +++ b/src/static/js/TextLinesMutator.ts @@ -0,0 +1,335 @@ + +/** + * Class to iterate and modify texts which have several lines. It is used for applying Changesets on + * arrays of lines. + * + * Mutation operations have the same constraints as exports operations with respect to newlines, but + * not the other additional constraints (i.e. ins/del ordering, forbidden no-ops, non-mergeability, + * final newline). Can be used to mutate lists of strings where the last char of each string is not + * actually a newline, but for the purposes of N and L values, the caller should pretend it is, and + * for things to work right in that case, the input to the `insert` method should be a single line + * with no newlines. + */ +export class TextLinesMutator { + private readonly lines: string[] + private readonly curSplice: [number, number] + private inSplice: boolean + private curLine: number + private curCol: number + /** + * @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place). + */ + constructor(lines: string[]) { + this.lines = lines; + /** + * this._curSplice holds values that will be passed as arguments to this._lines.splice() to + * insert, delete, or change lines: + * - this._curSplice[0] is an index into the this._lines array. + * - this._curSplice[1] is the number of lines that will be removed from the this._lines array + * starting at the index. + * - The other elements represent mutated (changed by ops) lines or new lines (added by ops) + * to insert at the index. + * + * @type {[number, number?, ...string[]?]} + */ + this.curSplice = [0, 0]; + this.inSplice = false; + // position in lines after curSplice is applied: + this.curLine = 0; + this.curCol = 0; + // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) && + // curLine >= curSplice[0] + // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then + // curCol == 0 + } + + /** + * Get a line from `lines` at given index. + * + * @param {number} idx - an index + * @returns {string} + */ + private linesGet(idx: number) { + if ('get' in this.lines) { + // @ts-ignore + return this.lines.get(idx) as string; + } else { + return this.lines[idx]; + } + } + + /** + * Return a slice from `lines`. + * + * @param {number} start - the start index + * @param {number} end - the end index + * @returns {string[]} + */ + private linesSlice(start: number, end: number): string[] { + // can be unimplemented if removeLines's return value not needed + if (this.lines.slice) { + return this.lines.slice(start, end); + } else { + return []; + } + } + + /** + * Return the length of `lines`. + * + * @returns {number} + */ + private linesLength() { + if (typeof this.lines.length === 'number') { + return this.lines.length; + } else { + // @ts-ignore + return this.lines.length(); + } + } + + /** + * Starts a new splice. + */ + enterSplice() { + this.curSplice[0] = this.curLine; + this.curSplice[1] = 0; + // TODO(doc) when is this the case? + // check all enterSplice calls and changes to curCol + if (this.curCol > 0) this.putCurLineInSplice(); + this.inSplice = true; + } + + /** + * Changes the lines array according to the values in curSplice and resets curSplice. Called via + * close or TODO(doc). + */ + private leaveSplice() { + this.lines.splice(...this.curSplice); + this.curSplice.length = 2; + this.curSplice[0] = this.curSplice[1] = 0; + this.inSplice = false; + } + + /** + * Indicates if curLine is already in the splice. This is necessary because the last element in + * curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting). + * + * @returns {boolean} true if curLine is in splice + */ + private isCurLineInSplice() { + // The value of `this._curSplice[1]` does not matter when determining the return value because + // `this._curLine` refers to the line number *after* the splice is applied (so after those lines + // are deleted). + return this.curLine - this.curSplice[0] < this.curSplice.length - 2; + } + + /** + * Incorporates current line into the splice and marks its old position to be deleted. + * + * @returns {number} the index of the added line in curSplice + */ + private putCurLineInSplice() { + if (!this.isCurLineInSplice()) { + this.curSplice.push(Number(this.linesGet(this.curSplice[0] + this.curSplice[1]!))); + this.curSplice[1]!++; + } + // TODO should be the same as this._curSplice.length - 1 + return 2 + this.curLine - this.curSplice[0]; + } + + /** + * It will skip some newlines by putting them into the splice. + * + * @param {number} L - + * @param {boolean} includeInSplice - Indicates that attributes are present. + */ + public skipLines(L: number, includeInSplice?: boolean) { + if (!L) return; + if (includeInSplice) { + if (!this.inSplice) this.enterSplice(); + // TODO(doc) should this count the number of characters that are skipped to check? + for (let i = 0; i < L; i++) { + this.curCol = 0; + this.putCurLineInSplice(); + this.curLine++; + } + } else { + if (this.inSplice) { + if (L > 1) { + // TODO(doc) figure out why single lines are incorporated into splice instead of ignored + this.leaveSplice(); + } else { + this.putCurLineInSplice(); + } + } + this.curLine += L; + this.curCol = 0; + } + // tests case foo in remove(), which isn't otherwise covered in current impl + } + + /** + * Skip some characters. Can contain newlines. + * + * @param {number} N - number of characters to skip + * @param {number} L - number of newlines to skip + * @param {boolean} includeInSplice - indicates if attributes are present + */ + skip(N: number, L: number, includeInSplice: boolean) { + if (!N) return; + if (L) { + this.skipLines(L, includeInSplice); + } else { + if (includeInSplice && !this.inSplice) this.enterSplice(); + if (this.inSplice) { + // although the line is put into splice curLine is not increased, because + // only some chars are skipped, not the whole line + this.putCurLineInSplice(); + } + this.curCol += N; + } + } + + /** + * Remove whole lines from lines array. + * + * @param {number} L - number of lines to remove + * @returns {string} + */ + removeLines(L: number):string { + if (!L) return ''; + if (!this.inSplice) this.enterSplice(); + + /** + * Gets a string of joined lines after the end of the splice. + * + * @param {number} k - number of lines + * @returns {string} joined lines + */ + const nextKLinesText = (k: number): string => { + const m = this.curSplice[0] + this.curSplice[1]!; + return this.linesSlice(m, m + k).join(''); + }; + + let removed: any = ''; + if (this.isCurLineInSplice()) { + if (this.curCol === 0) { + removed = this.curSplice[this.curSplice.length - 1]; + this.curSplice.length--; + removed += nextKLinesText(L - 1); + this.curSplice[1]! += L - 1; + } else { + removed = nextKLinesText(L - 1); + this.curSplice[1]! += L - 1; + const sline = this.curSplice.length - 1; + // @ts-ignore + removed = this.curSplice[sline]!.substring(this.curCol) + removed; + // @ts-ignore + this.curSplice[sline] = this.curSplice[sline]!.substring(0, this.curCol) + + this.linesGet(this.curSplice[0] + this.curSplice[1]!); + // @ts-ignore + this.curSplice[1] += 1; + } + } else { + removed = nextKLinesText(L); + this.curSplice[1]! += L; + } + return removed; + } + + /** + * Remove text from lines array. + * + * @param {number} N - characters to delete + * @param {number} L - lines to delete + * @returns {string} + */ + remove(N: number, L: number) { + if (!N) return ''; + if (L) return this.removeLines(L); + if (!this.inSplice) this.enterSplice(); + // although the line is put into splice, curLine is not increased, because + // only some chars are removed not the whole line + const sline = this.putCurLineInSplice(); + // @ts-ignore + const removed = this.curSplice[sline].substring(this.curCol, this.curCol + N); + // @ts-ignore + this.curSplice[sline] = this.curSplice[sline]!.substring(0, this.curCol) + + // @ts-ignore + this.curSplice[sline].substring(this.curCol + N); + return removed; + } + + /** + * Inserts text into lines array. + * + * @param {string} text - the text to insert + * @param {number} L - number of newlines in text + */ + insert(text: string, L: number) { + if (!text) return; + if (!this.inSplice) this.enterSplice(); + if (L) { + const newLines = exports.splitTextLines(text); + if (this.isCurLineInSplice()) { + const sline = this.curSplice.length - 1; + /** @type {string} */ + const theLine = this.curSplice[sline]; + const lineCol = this.curCol; + // Insert the chars up to `curCol` and the first new line. + // @ts-ignore + this.curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; + this.curLine++; + newLines.splice(0, 1); + // insert the remaining new lines + this.curSplice.push(...newLines); + this.curLine += newLines.length; + // insert the remaining chars from the "old" line (e.g. the line we were in + // when we started to insert new lines) + // @ts-ignore + this.curSplice.push(theLine.substring(lineCol)); + this.curCol = 0; // TODO(doc) why is this not set to the length of last line? + } else { + this.curSplice.push(...newLines); + this.curLine += newLines.length; + } + } else { + // There are no additional lines. Although the line is put into splice, curLine is not + // increased because there may be more chars in the line (newline is not reached). + const sline = this.putCurLineInSplice(); + if (!this.curSplice[sline]) { + const err = new Error( + 'curSplice[sline] not populated, actual curSplice contents is ' + + `${JSON.stringify(this.curSplice)}. Possibly related to ` + + 'https://github.com/ether/etherpad-lite/issues/2802'); + console.error(err.stack || err.toString()); + } + // @ts-ignore + this.curSplice[sline] = this.curSplice[sline].substring(0, this.curCol) + text + + // @ts-ignore + this.curSplice[sline].substring(this.curCol); + this.curCol += text.length; + } + } + + /** + * Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`. + * + * @returns {boolean} indicates if there are lines left + */ + hasMore(): boolean { + let docLines = this.linesLength(); + if (this.inSplice) { + docLines += this.curSplice.length - 2 - this.curSplice[1]; + } + return this.curLine < docLines; + } + + /** + * Closes the splice + */ + close() { + if (this.inSplice) this.leaveSplice(); + } +} diff --git a/src/static/js/ace.js b/src/static/js/ace.ts similarity index 65% rename from src/static/js/ace.js rename to src/static/js/ace.ts index d756d40be96..091ab12f109 100644 --- a/src/static/js/ace.js +++ b/src/static/js/ace.ts @@ -5,6 +5,8 @@ * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ +import {InnerWindow} from "./types/InnerWindow"; + /** * Copyright 2009 Google Inc. * @@ -28,21 +30,21 @@ const hooks = require('./pluginfw/hooks'); const pluginUtils = require('./pluginfw/shared'); const ace2_inner = require('ep_etherpad-lite/static/js/ace2_inner') -const debugLog = (...args) => {}; +const debugLog = (...args: string[]|Object[]|null[]) => {}; const cl_plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins') const {Cssmanager} = require("./cssmanager"); // The inner and outer iframe's locations are about:blank, so relative URLs are relative to that. // Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari // errors out unless given an absolute URL for a JavaScript-created element. -const absUrl = (url) => new URL(url, window.location.href).href; +const absUrl = (url: string) => new URL(url, window.location.href).href; -const eventFired = async (obj, event, cleanups = [], predicate = () => true) => { +const eventFired = async (obj: any, event: string, cleanups: Function[] = [], predicate = () => true) => { if (typeof cleanups === 'function') { predicate = cleanups; cleanups = []; } - await new Promise((resolve, reject) => { - let cleanup; + await new Promise((resolve: Function, reject: Function) => { + let cleanup: Function; const successCb = () => { if (!predicate()) return; debugLog(`Ace2Editor.init() ${event} event on`, obj); @@ -57,23 +59,23 @@ const eventFired = async (obj, event, cleanups = [], predicate = () => true) => }; cleanup = () => { cleanup = () => {}; - obj.removeEventListener(event, successCb); - obj.removeEventListener('error', errorCb); + obj!.removeEventListener(event, successCb); + obj!.removeEventListener('error', errorCb); }; cleanups.push(cleanup); - obj.addEventListener(event, successCb); - obj.addEventListener('error', errorCb); + obj!.addEventListener(event, successCb); + obj!.addEventListener('error', errorCb); }); }; // Resolves when the frame's document is ready to be mutated. Browsers seem to be quirky about // iframe ready events so this function throws the kitchen sink at the problem. Maybe one day we'll // find a concise general solution. -const frameReady = async (frame) => { +const frameReady = async (frame: HTMLIFrameElement) => { // Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace // the document object after the frame is first created for some reason. ¯\_(ツ)_/¯ - const doc = () => frame.contentDocument; - const cleanups = []; + const doc: any = () => frame.contentDocument; + const cleanups: Function[] = []; try { await Promise.race([ eventFired(frame, 'load', cleanups), @@ -87,26 +89,39 @@ const frameReady = async (frame) => { } }; -const Ace2Editor = function () { - let info = {editor: this}; - let loaded = false; - - let actionsPendingInit = []; +export class Ace2Editor { + info = {editor: this}; + loaded = false; + actionsPendingInit: Function[] = []; + + + constructor() { + for (const fnName of this.aceFunctionsPendingInit) { + // Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to + // pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until + // method invocation. + // @ts-ignore + this[fnName] = this.pendingInit(function (...args) { + // @ts-ignore + this.info[`ace_${fnName}`].apply(this, args); + }); + } + } - const pendingInit = (func) => function (...args) { + pendingInit = (func: Function) => (...args: any[])=> { const action = () => func.apply(this, args); - if (loaded) return action(); - actionsPendingInit.push(action); - }; + if (this.loaded) return action(); + this.actionsPendingInit.push(action); + } - const doActionsPendingInit = () => { - for (const fn of actionsPendingInit) fn(); - actionsPendingInit = []; - }; + doActionsPendingInit = () => { + for (const fn of this.actionsPendingInit) fn(); + this.actionsPendingInit = []; + } // The following functions (prefixed by 'ace_') are exposed by editor, but // execution is delayed until init is complete - const aceFunctionsPendingInit = [ + aceFunctionsPendingInit = [ 'importText', 'importAText', 'focus', @@ -124,21 +139,13 @@ const Ace2Editor = function () { 'callWithAce', 'execCommand', 'replaceRange', - ]; - - for (const fnName of aceFunctionsPendingInit) { - // Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to - // pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until - // method invocation. - this[fnName] = pendingInit(function (...args) { - info[`ace_${fnName}`].apply(this, args); - }); - } + ] - this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\n'; + // @ts-ignore + exportText = () => this.loaded ? this.info.ace_exportText() : '(awaiting init)\n'; + // @ts-ignore + getInInternationalComposition = () => this.loaded ? this.info.ace_getInInternationalComposition() : null; - this.getInInternationalComposition = - () => loaded ? info.ace_getInInternationalComposition() : null; // prepareUserChangeset: // Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes @@ -148,9 +155,9 @@ const Ace2Editor = function () { // be called again before applyPreparedChangesetToBase. Multiple consecutive calls to // prepareUserChangeset will return an updated changeset that takes into account the latest user // changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly. - this.prepareUserChangeset = () => loaded ? info.ace_prepareUserChangeset() : null; - - const addStyleTagsFor = (doc, files) => { + // @ts-ignore + prepareUserChangeset = () => this.loaded ? this.info.ace_prepareUserChangeset() : null; + addStyleTagsFor = (doc: Document, files: string[]) => { for (const file of files) { const link = doc.createElement('link'); link.rel = 'stylesheet'; @@ -158,32 +165,36 @@ const Ace2Editor = function () { link.href = absUrl(encodeURI(file)); doc.head.appendChild(link); } - }; + } - this.destroy = pendingInit(() => { - info.ace_dispose(); - info.frame.parentNode.removeChild(info.frame); - info = null; // prevent IE 6 closure memory leaks + destroy = this.pendingInit(() => { + // @ts-ignore + this.info.ace_dispose(); + // @ts-ignore + this.info.frame.parentNode.removeChild(this.info.frame); + // @ts-ignore + this.info = null; // prevent IE 6 closure memory leaks }); - this.init = async function (containerId, initialCode) { + init = async (containerId: string, initialCode: string)=> { debugLog('Ace2Editor.init()'); + // @ts-ignore this.importText(initialCode); const includedCSS = [ - `../static/css/iframe_editor.css?v=${clientVars.randomVersionString}`, - `../static/css/pad.css?v=${clientVars.randomVersionString}`, + `../static/css/iframe_editor.css?v=${window.clientVars.randomVersionString}`, + `../static/css/pad.css?v=${window.clientVars.randomVersionString}`, ...hooks.callAll('aceEditorCSS').map( - // Allow urls to external CSS - http(s):// and //some/path.css - (p) => /\/\//.test(p) ? p : `../static/plugins/${p}`), - `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`, + // Allow urls to external CSS - http(s):// and //some/path.css + (p: string) => /\/\//.test(p) ? p : `../static/plugins/${p}`), + `../static/skins/${window.clientVars.skinName}/pad.css?v=${window.clientVars.randomVersionString}`, ]; - const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== ''); + const skinVariants = window.clientVars.skinVariants.split(' ').filter((x: string) => x !== ''); const outerFrame = document.createElement('iframe'); outerFrame.name = 'ace_outer'; - outerFrame.frameBorder = 0; // for IE + outerFrame.frameBorder = String(0); // for IE outerFrame.title = 'Ether'; // Some browsers do strange things unless the iframe has a src or srcdoc property: // - Firefox replaces the frame's contentWindow.document object with a different object after @@ -195,8 +206,9 @@ const Ace2Editor = function () { // srcdoc is avoided because Firefox's Content Security Policy engine does not properly handle // 'self' with nested srcdoc iframes: https://bugzilla.mozilla.org/show_bug.cgi?id=1721296 outerFrame.src = '../static/empty.html'; - info.frame = outerFrame; - document.getElementById(containerId).appendChild(outerFrame); + // @ts-ignore + this.info.frame = outerFrame; + document.getElementById(containerId)!.appendChild(outerFrame); const outerWindow = outerFrame.contentWindow; debugLog('Ace2Editor.init() waiting for outer frame'); @@ -205,13 +217,13 @@ const Ace2Editor = function () { // Firefox might replace the outerWindow.document object after iframe creation so this variable // is assigned after the Window's load event. - const outerDocument = outerWindow.document; + const outerDocument = outerWindow!.document; // tag outerDocument.documentElement.classList.add('outer-editor', 'outerdoc', ...skinVariants); // tag - addStyleTagsFor(outerDocument, includedCSS); + this.addStyleTagsFor(outerDocument, includedCSS); const outerStyle = outerDocument.createElement('style'); outerStyle.type = 'text/css'; outerStyle.title = 'dynamicsyntax'; @@ -236,14 +248,11 @@ const Ace2Editor = function () { const innerFrame = outerDocument.createElement('iframe'); innerFrame.name = 'ace_inner'; innerFrame.title = 'pad'; - innerFrame.scrolling = 'no'; - innerFrame.frameBorder = 0; - innerFrame.allowTransparency = true; // for IE // The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above // outerFrame.srcdoc. innerFrame.src = 'empty.html'; outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild); - const innerWindow = innerFrame.contentWindow; + const innerWindow: InnerWindow = innerFrame.contentWindow!; debugLog('Ace2Editor.init() waiting for inner frame'); await frameReady(innerFrame); @@ -251,69 +260,47 @@ const Ace2Editor = function () { // Firefox might replace the innerWindow.document object after iframe creation so this variable // is assigned after the Window's load event. - const innerDocument = innerWindow.document; + const innerDocument = innerWindow!.document; // tag innerDocument.documentElement.classList.add('inner-editor', ...skinVariants); // tag - addStyleTagsFor(innerDocument, includedCSS); - //const requireKernel = innerDocument.createElement('script'); - //requireKernel.type = 'text/javascript'; - //requireKernel.src = - // absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`); - //innerDocument.head.appendChild(requireKernel); - // Pre-fetch modules to improve load performance. - /*for (const module of ['ace2_inner', 'ace2_common']) { - const script = innerDocument.createElement('script'); - script.type = 'text/javascript'; - script.src = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` + - `?callback=require.define&v=${clientVars.randomVersionString}`); - innerDocument.head.appendChild(script); - }*/ + this.addStyleTagsFor(innerDocument, includedCSS); + const innerStyle = innerDocument.createElement('style'); innerStyle.type = 'text/css'; innerStyle.title = 'dynamicsyntax'; innerDocument.head.appendChild(innerStyle); - const headLines = []; + const headLines: string[] = []; hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines}); innerDocument.head.appendChild( - innerDocument.createRange().createContextualFragment(headLines.join('\n'))); + innerDocument.createRange().createContextualFragment(headLines.join('\n'))); // tag innerDocument.body.id = 'innerdocbody'; innerDocument.body.classList.add('innerdocbody'); innerDocument.body.setAttribute('spellcheck', 'false'); innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); //   -/* - debugLog('Ace2Editor.init() waiting for require kernel load'); - await eventFired(requireKernel, 'load'); - debugLog('Ace2Editor.init() require kernel loaded'); - const require = innerWindow.require; - require.setRootURI(absUrl('../javascripts/src')); - require.setLibraryURI(absUrl('../javascripts/lib')); - require.setGlobalKeyPath('require'); -*/ + // intentially moved before requiring client_plugins to save a 307 - innerWindow.Ace2Inner = ace2_inner; - innerWindow.plugins = cl_plugins; + innerWindow!.Ace2Inner = ace2_inner; + innerWindow!.plugins = cl_plugins; - innerWindow.$ = innerWindow.jQuery = window.$; + innerWindow!.$ = innerWindow.jQuery = window.$; debugLog('Ace2Editor.init() waiting for plugins'); - /*await new Promise((resolve, reject) => innerWindow.plugins.ensure( - (err) => err != null ? reject(err) : resolve()));*/ + + debugLog('Ace2Editor.init() waiting for Ace2Inner.init()'); - await innerWindow.Ace2Inner.init(info, { + await innerWindow.Ace2Inner.init(this.info, { inner: new Cssmanager(innerStyle.sheet), outer: new Cssmanager(outerStyle.sheet), - parent: new Cssmanager(document.querySelector('style[title="dynamicsyntax"]').sheet), + parent: new Cssmanager((document.querySelector('style[title="dynamicsyntax"]') as HTMLStyleElement)!.sheet), }); debugLog('Ace2Editor.init() Ace2Inner.init() returned'); - loaded = true; - doActionsPendingInit(); + this.loaded = true; + this.doActionsPendingInit(); debugLog('Ace2Editor.init() done'); - }; -}; - -exports.Ace2Editor = Ace2Editor; + } +} diff --git a/src/static/js/pad.js b/src/static/js/pad.ts similarity index 61% rename from src/static/js/pad.js rename to src/static/js/pad.ts index f6970ebbf15..8f1c7af7b1b 100644 --- a/src/static/js/pad.js +++ b/src/static/js/pad.ts @@ -6,6 +6,8 @@ * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ +import {Socket} from "socket.io"; + /** * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd) * @@ -22,7 +24,7 @@ * limitations under the License. */ -let socket; +let socket: null | Socket; // These jQuery things should create local references, but for now `require()` @@ -38,6 +40,7 @@ const chat = require('./chat').chat; const getCollabClient = require('./collab_client').getCollabClient; const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus; import padcookie from "./pad_cookie"; + const padeditbar = require('./pad_editbar').padeditbar; const padeditor = require('./pad_editor').padeditor; const padimpexp = require('./pad_impexp').padimpexp; @@ -45,9 +48,12 @@ const padmodals = require('./pad_modals').padmodals; const padsavedrevs = require('./pad_savedrevs'); const paduserlist = require('./pad_userlist').paduserlist; import {padUtils as padutils} from "./pad_utils"; + const colorutils = require('./colorutils').colorutils; const randomString = require('./pad_utils').randomString; import connect from './socketio' +import {SocketClientReadyMessage} from "./types/SocketIOMessage"; +import {MapArrayType} from "../../node/types/MapType"; const hooks = require('./pluginfw/hooks'); @@ -61,22 +67,22 @@ const getParameters = [ { name: 'noColors', checkVal: 'true', - callback: (val) => { - settings.noColors = true; + callback: (val: any) => { + pad.settings.noColors = true; $('#clearAuthorship').hide(); }, }, { name: 'showControls', checkVal: 'true', - callback: (val) => { + callback: (val: any) => { $('#editbar').css('display', 'flex'); }, }, { name: 'showChat', checkVal: null, - callback: (val) => { + callback: (val: any) => { if (val === 'false') { settings.hideChat = true; chat.hide(); @@ -103,7 +109,7 @@ const getParameters = [ checkVal: null, callback: (val) => { settings.globalUserName = val; - clientVars.userName = val; + window.clientVars.userName = val; }, }, { @@ -111,7 +117,7 @@ const getParameters = [ checkVal: null, callback: (val) => { settings.globalUserColor = val; - clientVars.userColor = val; + window.clientVars.userColor = val; }, }, { @@ -149,7 +155,7 @@ const getParameters = [ const getParams = () => { // Tries server enforced options first.. for (const setting of getParameters) { - let value = clientVars.padOptions[setting.name]; + let value = window.clientVars.padOptions[setting.name]; if (value == null) continue; value = value.toString(); if (value === setting.checkVal || setting.checkVal == null) { @@ -169,7 +175,7 @@ const getParams = () => { const getUrlVars = () => new URL(window.location.href).searchParams; -const sendClientReady = (isReconnect) => { +const sendClientReady = (isReconnect: boolean) => { let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1); // unescape necessary due to Safari and Opera interpretation of spaces padId = decodeURIComponent(padId); @@ -196,7 +202,7 @@ const sendClientReady = (isReconnect) => { name: params.get('userName'), }; - const msg = { + const msg: SocketClientReadyMessage = { component: 'pad', type: 'CLIENT_READY', padId, @@ -207,11 +213,11 @@ const sendClientReady = (isReconnect) => { // this is a reconnect, lets tell the server our revisionnumber if (isReconnect) { - msg.client_rev = pad.collabClient.getCurrentRevisionNumber(); + msg.client_rev = this.collabClient!.getCurrentRevisionNumber(); msg.reconnect = true; } - socket.emit("message", msg); + socket!.emit("message", msg); }; const handshake = async () => { @@ -261,7 +267,7 @@ const handshake = async () => { socket.on('shout', (obj) => { - if(obj.type === "COLLABROOM") { + if (obj.type === "COLLABROOM") { let date = new Date(obj.data.payload.timestamp); $.gritter.add({ // (string | mandatory) the heading of the notification @@ -315,12 +321,13 @@ const handshake = async () => { window.clientVars = obj.data; if (window.clientVars.sessionRefreshInterval) { const ping = - () => $.ajax('../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {}); + () => $.ajax('../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => { + }); setInterval(ping, window.clientVars.sessionRefreshInterval); } - if(window.clientVars.mode === "development") { + if (window.clientVars.mode === "development") { console.warn('Enabling development mode with live update') - socket.on('liveupdate', ()=>{ + socket.on('liveupdate', () => { console.log('Live reload update received') location.reload() @@ -381,30 +388,52 @@ class MessageQueue { } } -const pad = { - // don't access these directly from outside this file, except - // for debugging - collabClient: null, - myUserInfo: null, - diagnosticInfo: {}, - initTime: 0, - clientTimeOffset: null, - padOptions: {}, - _messageQ: new MessageQueue(), +export class Pad { + private collabClient: null; + private myUserInfo: null | { + userId: string, + name: string, + ip: string, + colorId: string, + }; + private diagnosticInfo: {}; + private initTime: number; + private clientTimeOffset: null | number; + private _messageQ: MessageQueue; + private padOptions: MapArrayType>; + settings: PadSettings = { + LineNumbersDisabled: false, + noColors: false, + useMonospaceFontGlobal: false, + globalUserName: false, + globalUserColor: false, + rtlIsTrue: false, + } - // these don't require init; clientVars should all go through here - getPadId: () => clientVars.padId, - getClientIp: () => clientVars.clientIp, - getColorPalette: () => clientVars.colorPalette, - getPrivilege: (name) => clientVars.accountPrivs[name], - getUserId: () => pad.myUserInfo.userId, - getUserName: () => pad.myUserInfo.name, - userList: () => paduserlist.users(), - sendClientMessage: (msg) => { - pad.collabClient.sendClientMessage(msg); - }, + constructor() { + // don't access these directly from outside this file, except + // for debugging + this.collabClient = null + this.myUserInfo = null + this.diagnosticInfo = {} + this.initTime = 0 + this.clientTimeOffset = null + this.padOptions = {} + this._messageQ = new MessageQueue() + } - init() { + // these don't require init; clientVars should all go through here + getPadId = () => window.clientVars.padId + getClientIp = () => window.clientVars.clientIp + getColorPalette = () => window.clientVars.colorPalette + getPrivilege = (name: string) => window.clientVars.accountPrivs[name] + getUserId = () => this.myUserInfo!.userId + getUserName = () => this.myUserInfo!.name + userList = () => paduserlist.users() + sendClientMessage = (msg: string) => { + this.collabClient.sendClientMessage(msg); + } + init = () => { padutils.setupGlobalExceptionHandler(); // $(handler), $().ready(handler), $.wait($.ready).then(handler), etc. don't work if handler is @@ -412,28 +441,32 @@ const pad = { // function. $(() => (async () => { if (window.customStart != null) window.customStart(); + // @ts-ignore $('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220}); - $('#readonlyinput').on('click', () => { padeditbar.setEmbedLinks(); }); + $('#readonlyinput').on('click', () => { + padeditbar.setEmbedLinks(); + }); padcookie.init(); await handshake(); this._afterHandshake(); })()); - }, + } + _afterHandshake() { - pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp; + this.clientTimeOffset = Date.now() - window.clientVars.serverTimestamp; // initialize the chat chat.init(this); getParams(); padcookie.init(); // initialize the cookies - pad.initTime = +(new Date()); - pad.padOptions = clientVars.initialOptions; - - pad.myUserInfo = { - userId: clientVars.userId, - name: clientVars.userName, - ip: pad.getClientIp(), - colorId: clientVars.userColor, + this.initTime = +(new Date()); + this.padOptions = window.clientVars.initialOptions; + + this.myUserInfo = { + userId: window.clientVars.userId, + name: window.clientVars.userName, + ip: this.getClientIp(), + colorId: window.clientVars.userColor, }; const postAceInit = () => { @@ -442,7 +475,9 @@ const pad = { padeditor.ace.focus(); }, 0); const optionsStickyChat = $('#options-stickychat'); - optionsStickyChat.on('click', () => { chat.stickToScreen(); }); + optionsStickyChat.on('click', () => { + chat.stickToScreen(); + }); // if we have a cookie for always showing chat then show it if (padcookie.getPref('chatAlwaysVisible')) { chat.stickToScreen(true); // stick it to the screen @@ -454,15 +489,16 @@ const pad = { $('#options-chatandusers').prop('checked', true); // set the checkbox to on } if (padcookie.getPref('showAuthorshipColors') === false) { - pad.changeViewOption('showAuthorColors', false); + this.changeViewOption('showAuthorColors', false); } if (padcookie.getPref('showLineNumbers') === false) { - pad.changeViewOption('showLineNumbers', false); + this.changeViewOption('showLineNumbers', false); } if (padcookie.getPref('rtlIsTrue') === true) { - pad.changeViewOption('rtlIsTrue', true); + this.changeViewOption('rtlIsTrue', true); } - pad.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily')); + this.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily')); + // @ts-ignore $('#viewfontmenu').val(padcookie.getPref('padFontFamily')).niceSelect('update'); // Prevent sticky chat or chat and users to be checked for mobiles @@ -474,37 +510,39 @@ const pad = { }; const mobileMatch = window.matchMedia('(max-width: 800px)'); mobileMatch.addListener(checkChatAndUsersVisibility); // check if window resized - setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0); // check now after load + setTimeout(() => { + checkChatAndUsersVisibility(mobileMatch); + }, 0); // check now after load $('#editorcontainer').addClass('initialized'); - hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad}); + hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars: window.clientVars, pad}); }; // order of inits is important here: padimpexp.init(this); padsavedrevs.init(this); - padeditor.init(pad.padOptions.view || {}, this).then(postAceInit); - paduserlist.init(pad.myUserInfo, this); + padeditor.init(this.padOptions.view || {}, this).then(postAceInit); + paduserlist.init(this.myUserInfo, this); padconnectionstatus.init(); padmodals.init(this); - pad.collabClient = getCollabClient( - padeditor.ace, clientVars.collab_client_vars, pad.myUserInfo, - {colorPalette: pad.getColorPalette()}, pad); + this.collabClient = getCollabClient( + padeditor.ace, window.clientVars.collab_client_vars, this.myUserInfo, + {colorPalette: this.getColorPalette()}, pad); this._messageQ.setCollabClient(this.collabClient); - pad.collabClient.setOnUserJoin(pad.handleUserJoin); - pad.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate); - pad.collabClient.setOnUserLeave(pad.handleUserLeave); - pad.collabClient.setOnClientMessage(pad.handleClientMessage); - pad.collabClient.setOnChannelStateChange(pad.handleChannelStateChange); - pad.collabClient.setOnInternalAction(pad.handleCollabAction); + this.collabClient.setOnUserJoin(this.handleUserJoin); + this.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate); + this.collabClient.setOnUserLeave(pad.handleUserLeave); + this.collabClient.setOnClientMessage(pad.handleClientMessage); + this.collabClient.setOnChannelStateChange(pad.handleChannelStateChange); + this.collabClient.setOnInternalAction(pad.handleCollabAction); // load initial chat-messages - if (clientVars.chatHead !== -1) { - const chatHead = clientVars.chatHead; + if (window.clientVars.chatHead !== -1) { + const chatHead = window.clientVars.chatHead; const start = Math.max(chatHead - 100, 0); - pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead}); + this.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead}); } else { // there are no messages $('#chatloadmessagesbutton').css('display', 'none'); @@ -517,7 +555,9 @@ const pad = { $('#chaticon').hide(); $('#options-chatandusers').parent().hide(); $('#options-stickychat').parent().hide(); - } else if (!settings.hideChat) { $('#chaticon').show(); } + } else if (!settings.hideChat) { + $('#chaticon').show(); + } $('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite'); @@ -558,223 +598,259 @@ const pad = { this.notifyChangeColor(settings.globalUserColor); // Updates this.myUserInfo.colorId paduserlist.setMyUserInfo(this.myUserInfo); } - }, + } - dispose: () => { + dispose = () => { padeditor.dispose(); - }, - notifyChangeName: (newName) => { - pad.myUserInfo.name = newName; - pad.collabClient.updateUserInfo(pad.myUserInfo); - }, - notifyChangeColor: (newColorId) => { - pad.myUserInfo.colorId = newColorId; - pad.collabClient.updateUserInfo(pad.myUserInfo); - }, - changePadOption: (key, value) => { - const options = {}; + } + notifyChangeName = (newName) => { + this.myUserInfo.name = newName; + this.collabClient.updateUserInfo(this.myUserInfo); + } + notifyChangeColor = (newColorId) => { + this.myUserInfo.colorId = newColorId; + this.collabClient.updateUserInfo(this.myUserInfo); + } + + changePadOption = (key: string, value: string) => { + const options: MapArrayType = {}; options[key] = value; - pad.handleOptionsChange(options); - pad.collabClient.sendClientMessage( - { - type: 'padoptions', - options, - changedBy: pad.myUserInfo.name || 'unnamed', - }); - }, - changeViewOption: (key, value) => { - const options = { - view: {}, - }; + this.handleOptionsChange(options); + this.collabClient.sendClientMessage( + { + type: 'padoptions', + options, + changedBy: this.myUserInfo.name || 'unnamed', + }) + } + + changeViewOption = (key: string, value: string) => { + const options: MapArrayType> = + { + view: {} + , + } + ; options.view[key] = value; - pad.handleOptionsChange(options); - }, - handleOptionsChange: (opts) => { + this.handleOptionsChange(options); + } + + handleOptionsChange = (opts: MapArrayType>) => { // opts object is a full set of options or just // some options to change if (opts.view) { - if (!pad.padOptions.view) { - pad.padOptions.view = {}; + if (!this.padOptions.view) { + this.padOptions.view = {}; } for (const [k, v] of Object.entries(opts.view)) { - pad.padOptions.view[k] = v; + this.padOptions.view[k] = v; padcookie.setPref(k, v); } - padeditor.setViewOptions(pad.padOptions.view); + padeditor.setViewOptions(this.padOptions.view); } - }, - // caller shouldn't mutate the object - getPadOptions: () => pad.padOptions, - suggestUserName: (userId, name) => { - pad.collabClient.sendClientMessage( + } + getPadOptions = () => this.padOptions + suggestUserName = + (userId: string, name: string) => { + this.collabClient.sendClientMessage( { type: 'suggestUserName', unnamedId: userId, newName: name, }); - }, - handleUserJoin: (userInfo) => { + } + handleUserJoin = (userInfo) => { paduserlist.userJoinOrUpdate(userInfo); - }, - handleUserUpdate: (userInfo) => { + } + handleUserUpdate = (userInfo) => { paduserlist.userJoinOrUpdate(userInfo); - }, - handleUserLeave: (userInfo) => { - paduserlist.userLeave(userInfo); - }, - handleClientMessage: (msg) => { - if (msg.type === 'suggestUserName') { - if (msg.unnamedId === pad.myUserInfo.userId && msg.newName && !pad.myUserInfo.name) { - pad.notifyChangeName(msg.newName); - paduserlist.setMyUserInfo(pad.myUserInfo); - } - } else if (msg.type === 'newRevisionList') { - padsavedrevs.newRevisionList(msg.revisionList); - } else if (msg.type === 'revisionLabel') { - padsavedrevs.newRevisionList(msg.revisionList); - } else if (msg.type === 'padoptions') { - const opts = msg.options; - pad.handleOptionsChange(opts); + } + handleUserLeave = + (userInfo) => { + paduserlist.userLeave(userInfo); } - }, - handleChannelStateChange: (newState, message) => { - const oldFullyConnected = !!padconnectionstatus.isFullyConnected(); - const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting'); - if (newState === 'CONNECTED') { - padeditor.enable(); - padeditbar.enable(); - padimpexp.enable(); - padconnectionstatus.connected(); - } else if (newState === 'RECONNECTING') { - padeditor.disable(); - padeditbar.disable(); - padimpexp.disable(); - padconnectionstatus.reconnecting(); - } else if (newState === 'DISCONNECTED') { - pad.diagnosticInfo.disconnectedMessage = message; - pad.diagnosticInfo.padId = pad.getPadId(); - pad.diagnosticInfo.socket = {}; - - // we filter non objects from the socket object and put them in the diagnosticInfo - // this ensures we have no cyclic data - this allows us to stringify the data - for (const [i, value] of Object.entries(socket.socket || {})) { - const type = typeof value; - - if (type === 'string' || type === 'number') { - pad.diagnosticInfo.socket[i] = value; + // caller shouldn't mutate the object + handleClientMessage = + (msg) => { + if (msg.type === 'suggestUserName') { + if (msg.unnamedId === pad.myUserInfo.userId && msg.newName && !pad.myUserInfo.name) { + pad.notifyChangeName(msg.newName); + paduserlist.setMyUserInfo(pad.myUserInfo); } + } else if (msg.type === 'newRevisionList') { + padsavedrevs.newRevisionList(msg.revisionList); + } else if (msg.type === 'revisionLabel') { + padsavedrevs.newRevisionList(msg.revisionList); + } else if (msg.type === 'padoptions') { + const opts = msg.options; + pad.handleOptionsChange(opts); } + } - pad.asyncSendDiagnosticInfo(); - if (typeof window.ajlog === 'string') { - window.ajlog += (`Disconnected: ${message}\n`); - } - padeditor.disable(); - padeditbar.disable(); - padimpexp.disable(); + handleChannelStateChange + = + (newState, message) => { + const oldFullyConnected = !!padconnectionstatus.isFullyConnected(); + const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting'); + if (newState === 'CONNECTED') { + padeditor.enable(); + padeditbar.enable(); + padimpexp.enable(); + padconnectionstatus.connected(); + } else if (newState === 'RECONNECTING') { + padeditor.disable(); + padeditbar.disable(); + padimpexp.disable(); + padconnectionstatus.reconnecting(); + } else if (newState === 'DISCONNECTED') { + pad.diagnosticInfo.disconnectedMessage = message; + pad.diagnosticInfo.padId = pad.getPadId(); + pad.diagnosticInfo.socket = {}; + + // we filter non objects from the socket object and put them in the diagnosticInfo + // this ensures we have no cyclic data - this allows us to stringify the data + for (const [i, value] of Object.entries(socket.socket || {})) { + const type = typeof value; + + if (type === 'string' || type === 'number') { + pad.diagnosticInfo.socket[i] = value; + } + } - padconnectionstatus.disconnected(message); + pad.asyncSendDiagnosticInfo(); + if (typeof window.ajlog === 'string') { + window.ajlog += (`Disconnected: ${message}\n`); + } + padeditor.disable(); + padeditbar.disable(); + padimpexp.disable(); + + padconnectionstatus.disconnected(message); + } + const newFullyConnected = !!padconnectionstatus.isFullyConnected(); + if (newFullyConnected !== oldFullyConnected) { + pad.handleIsFullyConnected(newFullyConnected, wasConnecting); + } } - const newFullyConnected = !!padconnectionstatus.isFullyConnected(); - if (newFullyConnected !== oldFullyConnected) { - pad.handleIsFullyConnected(newFullyConnected, wasConnecting); + handleIsFullyConnected + = + (isConnected, isInitialConnect) => { + pad.determineChatVisibility(isConnected && !isInitialConnect); + pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect); + pad.determineAuthorshipColorsVisibility(); + setTimeout(() => { + padeditbar.toggleDropDown('none'); + }, 1000); } - }, - handleIsFullyConnected: (isConnected, isInitialConnect) => { - pad.determineChatVisibility(isConnected && !isInitialConnect); - pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect); - pad.determineAuthorshipColorsVisibility(); - setTimeout(() => { - padeditbar.toggleDropDown('none'); - }, 1000); - }, - determineChatVisibility: (asNowConnectedFeedback) => { - const chatVisCookie = padcookie.getPref('chatAlwaysVisible'); - if (chatVisCookie) { // if the cookie is set for chat always visible - chat.stickToScreen(true); // stick it to the screen - $('#options-stickychat').prop('checked', true); // set the checkbox to on - } else { - $('#options-stickychat').prop('checked', false); // set the checkbox for off + determineChatVisibility + = + (asNowConnectedFeedback) => { + const chatVisCookie = padcookie.getPref('chatAlwaysVisible'); + if (chatVisCookie) { // if the cookie is set for chat always visible + chat.stickToScreen(true); // stick it to the screen + $('#options-stickychat').prop('checked', true); // set the checkbox to on + } else { + $('#options-stickychat').prop('checked', false); // set the checkbox for off + } } - }, - determineChatAndUsersVisibility: (asNowConnectedFeedback) => { - const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible'); - if (chatAUVisCookie) { // if the cookie is set for chat always visible - chat.chatAndUsers(true); // stick it to the screen - $('#options-chatandusers').prop('checked', true); // set the checkbox to on - } else { - $('#options-chatandusers').prop('checked', false); // set the checkbox for off + determineChatAndUsersVisibility + = + (asNowConnectedFeedback) => { + const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible'); + if (chatAUVisCookie) { // if the cookie is set for chat always visible + chat.chatAndUsers(true); // stick it to the screen + $('#options-chatandusers').prop('checked', true); // set the checkbox to on + } else { + $('#options-chatandusers').prop('checked', false); // set the checkbox for off + } } - }, - determineAuthorshipColorsVisibility: () => { - const authColCookie = padcookie.getPref('showAuthorshipColors'); - if (authColCookie) { - pad.changeViewOption('showAuthorColors', true); - $('#options-colorscheck').prop('checked', true); - } else { - $('#options-colorscheck').prop('checked', false); + determineAuthorshipColorsVisibility + = + () => { + const authColCookie = padcookie.getPref('showAuthorshipColors'); + if (authColCookie) { + pad.changeViewOption('showAuthorColors', true); + $('#options-colorscheck').prop('checked', true); + } else { + $('#options-colorscheck').prop('checked', false); + } } - }, - handleCollabAction: (action) => { - if (action === 'commitPerformed') { - padeditbar.setSyncStatus('syncing'); - } else if (action === 'newlyIdle') { - padeditbar.setSyncStatus('done'); + handleCollabAction + = + (action) => { + if (action === 'commitPerformed') { + padeditbar.setSyncStatus('syncing'); + } else if (action === 'newlyIdle') { + padeditbar.setSyncStatus('done'); + } } - }, - asyncSendDiagnosticInfo: () => { - window.setTimeout(() => { - $.ajax( + asyncSendDiagnosticInfo + = + () => { + window.setTimeout(() => { + $.ajax( { type: 'post', url: '../ep/pad/connection-diagnostic-info', data: { diagnosticInfo: JSON.stringify(pad.diagnosticInfo), }, - success: () => {}, - error: () => {}, + success: () => { + }, + error: () => { + }, }); - }, 0); - }, - forceReconnect: () => { - $('form#reconnectform input.padId').val(pad.getPadId()); - pad.diagnosticInfo.collabDiagnosticInfo = pad.collabClient.getDiagnosticInfo(); - $('form#reconnectform input.diagnosticInfo').val(JSON.stringify(pad.diagnosticInfo)); - $('form#reconnectform input.missedChanges') + }, 0); + } + forceReconnect + = + () => { + $('form#reconnectform input.padId').val(pad.getPadId()); + pad.diagnosticInfo.collabDiagnosticInfo = pad.collabClient.getDiagnosticInfo(); + $('form#reconnectform input.diagnosticInfo').val(JSON.stringify(pad.diagnosticInfo)); + $('form#reconnectform input.missedChanges') .val(JSON.stringify(pad.collabClient.getMissedChanges())); - $('form#reconnectform').trigger('submit'); - }, - callWhenNotCommitting: (f) => { - pad.collabClient.callWhenNotCommitting(f); - }, - getCollabRevisionNumber: () => pad.collabClient.getCurrentRevisionNumber(), - isFullyConnected: () => padconnectionstatus.isFullyConnected(), - addHistoricalAuthors: (data) => { - if (!pad.collabClient) { - window.setTimeout(() => { - pad.addHistoricalAuthors(data); - }, 1000); - } else { - pad.collabClient.addHistoricalAuthors(data); + $('form#reconnectform').trigger('submit'); } - }, -}; + callWhenNotCommitting + = + (f) => { + pad.collabClient.callWhenNotCommitting(f); + } + getCollabRevisionNumber + = + () => pad.collabClient.getCurrentRevisionNumber() + isFullyConnected + = + () => padconnectionstatus.isFullyConnected() + addHistoricalAuthors + = + (data) => { + if (!pad.collabClient) { + window.setTimeout(() => { + pad.addHistoricalAuthors(data); + }, 1000); + } else { + pad.collabClient.addHistoricalAuthors(data); + } + } +} -const init = () => pad.init(); -const settings = { - LineNumbersDisabled: false, - noColors: false, - useMonospaceFontGlobal: false, - globalUserName: false, - globalUserColor: false, - rtlIsTrue: false, -}; +export type PadSettings = { + LineNumbersDisabled: boolean, + noColors: boolean, + useMonospaceFontGlobal: boolean, + globalUserName: string | boolean, + globalUserColor: string | boolean, + rtlIsTrue: boolean, + hideChat?: boolean, +} + +export const pad = new Pad() -pad.settings = settings; exports.baseURL = ''; -exports.settings = settings; exports.randomString = randomString; exports.getParams = getParams; exports.pad = pad; diff --git a/src/static/js/pad_editor.js b/src/static/js/pad_editor.js deleted file mode 100644 index 739b73a6a44..00000000000 --- a/src/static/js/pad_editor.js +++ /dev/null @@ -1,211 +0,0 @@ -'use strict'; -/** - * This code is mostly from the old Etherpad. Please help us to comment this code. - * This helps other people to understand this code better and helps them to improve it. - * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED - */ - -/** - * Copyright 2009 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS-IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const Cookies = require('./pad_utils').Cookies; - -import padcookie from "./pad_cookie"; -import {padUtils as padutils} from "./pad_utils"; -const Ace2Editor = require('./ace').Ace2Editor; -import html10n from '../js/vendors/html10n' - -const padeditor = (() => { - let pad = undefined; - let settings = undefined; - - const self = { - ace: null, - // this is accessed directly from other files - viewZoom: 100, - init: async (initialViewOptions, _pad) => { - pad = _pad; - settings = pad.settings; - self.ace = new Ace2Editor(); - await self.ace.init('editorcontainer', ''); - $('#editorloadingbox').hide(); - // Listen for clicks on sidediv items - const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody'); - $outerdoc.find('#sidedivinner').on('click', 'div', function () { - const targetLineNumber = $(this).index() + 1; - window.location.hash = `L${targetLineNumber}`; - }); - exports.focusOnLine(self.ace); - self.ace.setProperty('wraps', true); - self.initViewOptions(); - self.setViewOptions(initialViewOptions); - // view bar - $('#viewbarcontents').show(); - }, - initViewOptions: () => { - // Line numbers - padutils.bindCheckboxChange($('#options-linenoscheck'), () => { - pad.changeViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck'))); - }); - - // Author colors - padutils.bindCheckboxChange($('#options-colorscheck'), () => { - padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck')); - pad.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck')); - }); - - // Right to left - padutils.bindCheckboxChange($('#options-rtlcheck'), () => { - pad.changeViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck'))); - }); - html10n.bind('localized', () => { - pad.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection())); - padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection())); - }); - - // font family change - $('#viewfontmenu').on('change', () => { - pad.changeViewOption('padFontFamily', $('#viewfontmenu').val()); - }); - - // Language - html10n.bind('localized', () => { - $('#languagemenu').val(html10n.getLanguage()); - // translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist - - // this does not interfere with html10n's normal value-setting because - // html10n just ingores s - // also, a value which has been set by the user will be not overwritten - // since a user-edited does *not* have the editempty-class - $('input[data-l10n-id]').each((key, input) => { - input = $(input); - if (input.hasClass('editempty')) { - input.val(html10n.get(input.attr('data-l10n-id'))); - } - }); - }); - $('#languagemenu').val(html10n.getLanguage()); - $('#languagemenu').on('change', () => { - Cookies.set('language', $('#languagemenu').val()); - html10n.localize([$('#languagemenu').val(), 'en']); - if ($('select').niceSelect) { - $('select').niceSelect('update'); - } - }); - }, - setViewOptions: (newOptions) => { - const getOption = (key, defaultValue) => { - const value = String(newOptions[key]); - if (value === 'true') return true; - if (value === 'false') return false; - return defaultValue; - }; - - let v; - - v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection())); - self.ace.setProperty('rtlIsTrue', v); - padutils.setCheckbox($('#options-rtlcheck'), v); - - v = getOption('showLineNumbers', true); - self.ace.setProperty('showslinenumbers', v); - padutils.setCheckbox($('#options-linenoscheck'), v); - - v = getOption('showAuthorColors', true); - self.ace.setProperty('showsauthorcolors', v); - $('#chattext').toggleClass('authorColors', v); - $('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v); - padutils.setCheckbox($('#options-colorscheck'), v); - - // Override from parameters if true - if (settings.noColors !== false) { - self.ace.setProperty('showsauthorcolors', !settings.noColors); - } - - self.ace.setProperty('textface', newOptions.padFontFamily || ''); - }, - dispose: () => { - if (self.ace) { - self.ace.destroy(); - self.ace = null; - } - }, - enable: () => { - if (self.ace) { - self.ace.setEditable(true); - } - }, - disable: () => { - if (self.ace) { - self.ace.setEditable(false); - } - }, - restoreRevisionText: (dataFromServer) => { - pad.addHistoricalAuthors(dataFromServer.historicalAuthorData); - self.ace.importAText(dataFromServer.atext, dataFromServer.apool, true); - }, - }; - return self; -})(); - -exports.padeditor = padeditor; - -exports.focusOnLine = (ace) => { - // If a number is in the URI IE #L124 go to that line number - const lineNumber = window.location.hash.substr(1); - if (lineNumber) { - if (lineNumber[0] === 'L') { - const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody'); - const lineNumberInt = parseInt(lineNumber.substr(1)); - if (lineNumberInt) { - const $inner = $('iframe[name="ace_outer"]').contents().find('iframe') - .contents().find('#innerdocbody'); - const line = $inner.find(`div:nth-child(${lineNumberInt})`); - if (line.length !== 0) { - let offsetTop = line.offset().top; - offsetTop += parseInt($outerdoc.css('padding-top').replace('px', '')); - const hasMobileLayout = $('body').hasClass('mobile-layout'); - if (!hasMobileLayout) { - offsetTop += parseInt($inner.css('padding-top').replace('px', '')); - } - const $outerdocHTML = $('iframe[name="ace_outer"]').contents() - .find('#outerdocbody').parent(); - $outerdoc.css({top: `${offsetTop}px`}); // Chrome - $outerdocHTML.animate({scrollTop: offsetTop}); // needed for FF - const node = line[0]; - ace.callWithAce((ace) => { - const selection = { - startPoint: { - index: 0, - focusAtStart: true, - maxIndex: 1, - node, - }, - endPoint: { - index: 0, - focusAtStart: true, - maxIndex: 1, - node, - }, - }; - ace.ace_setSelection(selection); - }); - } - } - } - } - // End of setSelection / set Y position of editor -}; diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts new file mode 100644 index 00000000000..f74fb7b4d5d --- /dev/null +++ b/src/static/js/pad_editor.ts @@ -0,0 +1,229 @@ +'use strict'; +/** + * This code is mostly from the old Etherpad. Please help us to comment this code. + * This helps other people to understand this code better and helps them to improve it. + * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED + */ + +import {PadType} from "../../node/types/PadType"; + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Cookies} from "./pad_utils"; + +import padcookie from "./pad_cookie"; +import {padUtils as padutils} from "./pad_utils"; + +import {Ace2Editor} from "./ace"; +import html10n from '../js/vendors/html10n' +import {MapArrayType} from "../../node/types/MapType"; +import {ClientVarData, ClientVarMessage} from "./types/SocketIOMessage"; + +export class PadEditor { + private pad?: PadType + private settings: undefined| ClientVarData + private ace: any + private viewZoom: number + + constructor() { + this.pad = undefined; + this.settings = undefined; + this.ace = null + // this is accessed directly from other files + this.viewZoom = 100 + } + + init = async (initialViewOptions: MapArrayType, _pad: PadType) => { + this.pad = _pad; + this.settings = this.pad.settings; + this.ace = new Ace2Editor(); + await this.ace.init('editorcontainer', ''); + $('#editorloadingbox').hide(); + // Listen for clicks on sidediv items + const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody'); + $outerdoc.find('#sidedivinner').on('click', 'div', function () { + const targetLineNumber = $(this).index() + 1; + window.location.hash = `L${targetLineNumber}`; + }); + this.focusOnLine(this.ace); + this.ace.setProperty('wraps', true); + this.initViewOptions(); + this.setViewOptions(initialViewOptions); + // view bar + $('#viewbarcontents').show(); + } + + + initViewOptions = () => { + // Line numbers + padutils.bindCheckboxChange($('#options-linenoscheck'), () => { + this.pad!.changeViewOption('showLineNumbers', padutils.getCheckbox('#options-linenoscheck')); + }); + +// Author colors + padutils.bindCheckboxChange($('#options-colorscheck'), () => { + padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck') as any); + this.pad!.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck')); + }); + +// Right to left + padutils.bindCheckboxChange($('#options-rtlcheck'), () => { + this.pad!.changeViewOption('rtlIsTrue', padutils.getCheckbox('#options-rtlcheck')); + }); + html10n.bind('localized', () => { + this.pad!.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection())); + padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection())); + }); + +// font family change + $('#viewfontmenu').on('change', () => { + this.pad!.changeViewOption('padFontFamily', $('#viewfontmenu').val()); + }); + +// Language + html10n.bind('localized', () => { + $('#languagemenu').val(html10n.getLanguage()!); + // translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist + + // this does not interfere with html10n's normal value-setting because + // html10n just ingores s + // also, a value which has been set by the user will be not overwritten + // since a user-edited does *not* have the editempty-class + $('input[data-l10n-id]').each((key, input) => { + // @ts-ignore + input = $(input); + // @ts-ignore + if (input.hasClass('editempty')) { + // @ts-ignore + input.val(html10n.get(input.attr('data-l10n-id'))); + } + }); + }); + $('#languagemenu').val(html10n.getLanguage()!); + $('#languagemenu').on('change', () => { + Cookies.set('language', $('#languagemenu').val() as string); + html10n.localize([$('#languagemenu').val() as string, 'en']); + // @ts-ignore + if ($('select').niceSelect) { + // @ts-ignore + $('select').niceSelect('update'); + } + }); + } + + setViewOptions = (newOptions: MapArrayType) => { + const getOption = (key: string, defaultValue: boolean) => { + const value = String(newOptions[key]); + if (value === 'true') return true; + if (value === 'false') return false; + return defaultValue; + }; + + let v; + + v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection())); + this.ace.setProperty('rtlIsTrue', v); + padutils.setCheckbox($('#options-rtlcheck'), v); + + v = getOption('showLineNumbers', true); + this.ace.setProperty('showslinenumbers', v); + padutils.setCheckbox($('#options-linenoscheck'), v); + + v = getOption('showAuthorColors', true); + this.ace.setProperty('showsauthorcolors', v); + $('#chattext').toggleClass('authorColors', v); + $('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v); + padutils.setCheckbox($('#options-colorscheck'), v); + + // Override from parameters if true + if (this.settings!.noColors !== false) { + this.ace.setProperty('showsauthorcolors', !settings.noColors); + } + + this.ace.setProperty('textface', newOptions.padFontFamily || ''); + } + + dispose = () => { + if (this.ace) { + this.ace.destroy(); + this.ace = null; + } + } + enable = () => { + if (this.ace) { + this.ace.setEditable(true); + } + } + disable = () => { + if (this.ace) { + this.ace.setEditable(false); + } + } + restoreRevisionText= (dataFromServer: ClientVarData) => { + this.pad!.addHistoricalAuthors(dataFromServer.historicalAuthorData); + this.ace.importAText(dataFromServer.atext, dataFromServer.apool, true); + } + + focusOnLine = (ace) => { + // If a number is in the URI IE #L124 go to that line number + const lineNumber = window.location.hash.substr(1); + if (lineNumber) { + if (lineNumber[0] === 'L') { + const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody'); + const lineNumberInt = parseInt(lineNumber.substr(1)); + if (lineNumberInt) { + const $inner = $('iframe[name="ace_outer"]').contents().find('iframe') + .contents().find('#innerdocbody'); + const line = $inner.find(`div:nth-child(${lineNumberInt})`); + if (line.length !== 0) { + let offsetTop = line.offset()!.top; + offsetTop += parseInt($outerdoc.css('padding-top').replace('px', '')); + const hasMobileLayout = $('body').hasClass('mobile-layout'); + if (!hasMobileLayout) { + offsetTop += parseInt($inner.css('padding-top').replace('px', '')); + } + const $outerdocHTML = $('iframe[name="ace_outer"]').contents() + .find('#outerdocbody').parent(); + $outerdoc.css({top: `${offsetTop}px`}); // Chrome + $outerdocHTML.animate({scrollTop: offsetTop}); // needed for FF + const node = line[0]; + ace.callWithAce((ace) => { + const selection = { + startPoint: { + index: 0, + focusAtStart: true, + maxIndex: 1, + node, + }, + endPoint: { + index: 0, + focusAtStart: true, + maxIndex: 1, + node, + }, + }; + ace.ace_setSelection(selection); + }); + } + } + } + } + // End of setSelection / set Y position of editor + } +} + +export const padEditor = new PadEditor(); diff --git a/src/static/js/pad_utils.ts b/src/static/js/pad_utils.ts index ab5dde6b64c..a4e43192e75 100644 --- a/src/static/js/pad_utils.ts +++ b/src/static/js/pad_utils.ts @@ -343,9 +343,9 @@ class PadUtils { clear, }; } - getCheckbox = (node: JQueryNode) => $(node).is(':checked') + getCheckbox = (node: string) => $(node).is(':checked') setCheckbox = - (node: JQueryNode, value: string) => { + (node: JQueryNode, value: boolean) => { if (value) { $(node).attr('checked', 'checked'); } else { diff --git a/src/static/js/types/AText.ts b/src/static/js/types/AText.ts new file mode 100644 index 00000000000..d58626de25f --- /dev/null +++ b/src/static/js/types/AText.ts @@ -0,0 +1,4 @@ +export type AText = { + text: string, + attribs: string, +} diff --git a/src/static/js/types/ChangeSet.ts b/src/static/js/types/ChangeSet.ts new file mode 100644 index 00000000000..15e67756cd2 --- /dev/null +++ b/src/static/js/types/ChangeSet.ts @@ -0,0 +1,6 @@ +export type ChangeSet = { + oldLen: number; + newLen: number; + ops: string; + charBank: string; +} diff --git a/src/static/js/types/InnerWindow.ts b/src/static/js/types/InnerWindow.ts new file mode 100644 index 00000000000..056ff1ac3f6 --- /dev/null +++ b/src/static/js/types/InnerWindow.ts @@ -0,0 +1,5 @@ +export type InnerWindow = Window & { + Ace2Inner?: any, + plugins?: any, + jQuery?: any +} diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index ca5c629e986..48f8378e651 100644 --- a/src/static/js/types/SocketIOMessage.ts +++ b/src/static/js/types/SocketIOMessage.ts @@ -1,13 +1,55 @@ +import {MapArrayType} from "../../../node/types/MapType"; +import {AText} from "./AText"; +import AttributePool from "../AttributePool"; + export type SocketIOMessage = { type: string accessStatus: string } +export type ClientVarData = { + sessionRefreshInterval: number, + historicalAuthorData:MapArrayType<{ + name: string; + colorId: string; + }>, + atext: AText, + apool: AttributePool, + noColors: boolean, + userName: string, + userColor:string, + hideChat: boolean, + padOptions: MapArrayType, + padId: string, + clientIp: string, + colorPalette: MapArrayType, + accountPrivs: MapArrayType, + collab_client_vars: MapArrayType, + chatHead: number, + readonly: boolean, + serverTimestamp: number, + initialOptions: MapArrayType, + userId: string, +} export type ClientVarMessage = { - data: { - sessionRefreshInterval: number - } + data: ClientVarData, type: string accessStatus: string } + +export type SocketClientReadyMessage = { + type: string + component: string + padId: string + sessionID: string + token: string + userInfo: { + colorId: string|null + name: string|null + }, + reconnect?: boolean + client_rev?: number +} + + diff --git a/src/static/js/types/Window.ts b/src/static/js/types/Window.ts index df19bc8cf14..feb1438319f 100644 --- a/src/static/js/types/Window.ts +++ b/src/static/js/types/Window.ts @@ -1,6 +1,9 @@ +import {ClientVarData} from "./SocketIOMessage"; + declare global { interface Window { - clientVars: any; - $: any + clientVars: ClientVarData; + $: any, + customStart?:any } } From fa2d6d15a9409675ffba46142f91d920e1421a3e Mon Sep 17 00:00:00 2001 From: SamTv12345 Date: Fri, 19 Jul 2024 19:22:04 +0200 Subject: [PATCH 3/8] Added more frontend ts files --- pnpm-lock.yaml | 8 + src/node/types/PadType.ts | 3 +- src/package.json | 1 + src/static/js/AttributeMap.ts | 3 +- src/static/js/Changeset.ts | 2 +- src/static/js/ChatMessage.ts | 10 +- src/static/js/ace.ts | 34 +- .../js/{ace2_common.js => ace2_common.ts} | 23 +- src/static/js/collab_client.js | 502 ------------- src/static/js/collab_client.ts | 524 ++++++++++++++ src/static/js/colorutils.js | 121 ---- src/static/js/colorutils.ts | 113 +++ ...ontentcollector.js => contentcollector.ts} | 529 +++++++------- src/static/js/domline.js | 279 -------- src/static/js/domline.ts | 299 ++++++++ src/static/js/pad.ts | 217 +++--- ...econnect.js => pad_automatic_reconnect.ts} | 164 ++--- ...ctionstatus.js => pad_connectionstatus.ts} | 92 +-- .../js/{pad_editbar.js => pad_editbar.ts} | 160 +++-- src/static/js/pad_editor.ts | 29 +- .../js/{pad_impexp.js => pad_impexp.ts} | 157 ++--- .../js/{pad_modals.js => pad_modals.ts} | 63 +- .../js/{pad_savedrevs.js => pad_savedrevs.ts} | 13 +- src/static/js/pad_userlist.js | 610 ---------------- src/static/js/pad_userlist.ts | 661 ++++++++++++++++++ src/static/js/socketio.ts | 9 +- src/static/js/timeslider.ts | 8 +- src/static/js/types/SocketIOMessage.ts | 192 ++++- src/static/js/types/Window.ts | 9 +- src/static/js/vendors/BrowserType.ts | 37 + src/static/js/vendors/browser.js | 310 -------- src/static/js/vendors/browser.ts | 216 ++++++ src/static/js/vendors/gritter.js | 1 - src/templates/padBootstrap.js | 6 +- src/templates/timeSliderBootstrap.js | 6 +- src/tests/backend/specs/chat.ts | 16 +- src/tests/frontend/index.html | 2 +- 37 files changed, 2883 insertions(+), 2546 deletions(-) rename src/static/js/{ace2_common.js => ace2_common.ts} (76%) delete mode 100644 src/static/js/collab_client.js create mode 100644 src/static/js/collab_client.ts delete mode 100644 src/static/js/colorutils.js create mode 100644 src/static/js/colorutils.ts rename src/static/js/{contentcollector.js => contentcollector.ts} (59%) delete mode 100644 src/static/js/domline.js create mode 100644 src/static/js/domline.ts rename src/static/js/{pad_automatic_reconnect.js => pad_automatic_reconnect.ts} (50%) rename src/static/js/{pad_connectionstatus.js => pad_connectionstatus.ts} (52%) rename src/static/js/{pad_editbar.js => pad_editbar.ts} (80%) rename src/static/js/{pad_impexp.js => pad_impexp.ts} (55%) rename src/static/js/{pad_modals.js => pad_modals.ts} (51%) rename src/static/js/{pad_savedrevs.js => pad_savedrevs.ts} (86%) delete mode 100644 src/static/js/pad_userlist.js create mode 100644 src/static/js/pad_userlist.ts create mode 100644 src/static/js/vendors/BrowserType.ts delete mode 100644 src/static/js/vendors/browser.js create mode 100644 src/static/js/vendors/browser.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 459df8954f1..2a99307aa07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -324,6 +324,9 @@ importers: '@types/underscore': specifier: ^1.11.15 version: 1.11.15 + '@types/unorm': + specifier: ^1.3.31 + version: 1.3.31 chokidar: specifier: ^3.6.0 version: 3.6.0 @@ -1612,6 +1615,9 @@ packages: '@types/unist@3.0.2': resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} + '@types/unorm@1.3.31': + resolution: {integrity: sha512-qCPX/Lo14ECb9Wkb/1sxdcTQqIiHTVNlaHczGrh2WqMVSlWjfn8Hu7DxraCtBYz1+Ud6Id/d+4OH/hkd+dlnpw==} + '@types/url-join@4.0.3': resolution: {integrity: sha512-3l1qMm3wqO0iyC5gkADzT95UVW7C/XXcdvUcShOideKF0ddgVRErEQQJXBd2kvQm+aSgqhBGHGB38TgMeT57Ww==} @@ -5603,6 +5609,8 @@ snapshots: '@types/unist@3.0.2': {} + '@types/unorm@1.3.31': {} + '@types/url-join@4.0.3': {} '@types/web-bluetooth@0.0.20': {} diff --git a/src/node/types/PadType.ts b/src/node/types/PadType.ts index 43d6e31c0d4..5b5cc7923e4 100644 --- a/src/node/types/PadType.ts +++ b/src/node/types/PadType.ts @@ -1,4 +1,5 @@ import {MapArrayType} from "./MapType"; +import {PadOption} from "../../static/js/types/SocketIOMessage"; export type PadType = { id: string, @@ -19,7 +20,7 @@ export type PadType = { getRevisionDate: (rev: number)=>Promise, getRevisionChangeset: (rev: number)=>Promise, appendRevision: (changeset: AChangeSet, author: string)=>Promise, - settings:any + settings: PadOption } diff --git a/src/package.json b/src/package.json index 1f896cfc6a2..ae39d0fec06 100644 --- a/src/package.json +++ b/src/package.json @@ -97,6 +97,7 @@ "@types/sinon": "^17.0.3", "@types/supertest": "^6.0.2", "@types/underscore": "^1.11.15", + "@types/unorm": "^1.3.31", "chokidar": "^3.6.0", "eslint": "^9.7.0", "eslint-config-etherpad": "^4.0.4", diff --git a/src/static/js/AttributeMap.ts b/src/static/js/AttributeMap.ts index 4e3fa636caf..e4a417f2bca 100644 --- a/src/static/js/AttributeMap.ts +++ b/src/static/js/AttributeMap.ts @@ -1,6 +1,7 @@ 'use strict'; import AttributePool from "./AttributePool"; +import {Attribute} from "./types/Attribute"; const attributes = require('./attributes'); @@ -66,7 +67,7 @@ class AttributeMap extends Map { * key is removed from this map (if present). * @returns {AttributeMap} `this` (for chaining). */ - update(entries: Iterable<[string, string]>, emptyValueIsDelete: boolean = false): AttributeMap { + update(entries: Attribute[], emptyValueIsDelete: boolean = false): AttributeMap { for (let [k, v] of entries) { k = k == null ? '' : String(k); v = v == null ? '' : String(v); diff --git a/src/static/js/Changeset.ts b/src/static/js/Changeset.ts index 9da2e710e58..d4530f7c4a7 100644 --- a/src/static/js/Changeset.ts +++ b/src/static/js/Changeset.ts @@ -1107,7 +1107,7 @@ export const attribsAttributeValue = (attribs: string, key: string, pool: Attrib * ignored if `attribs` is an attribute string. * @returns {AttributeString} */ -export const makeAttribsString = (opcode: string, attribs: Iterable<[string, string]>|string, pool: AttributePool | null | undefined): string => { +export const makeAttribsString = (opcode: string, attribs: Attribute[]|string, pool: AttributePool | null | undefined): string => { padutils.warnDeprecated( 'Changeset.makeAttribsString() is deprecated; ' + 'use AttributeMap.prototype.toString() or attributes.attribsToString() instead'); diff --git a/src/static/js/ChatMessage.ts b/src/static/js/ChatMessage.ts index 294057becc6..fb74f53aa01 100644 --- a/src/static/js/ChatMessage.ts +++ b/src/static/js/ChatMessage.ts @@ -9,11 +9,11 @@ import {padUtils} from './pad_utils' * Supports serialization to JSON. */ class ChatMessage { - - private text: string|null - private authorId: string|null + customMetadata: any + text: string|null + public authorId: string|null private displayName: string|null - private time: number|null + 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. @@ -108,4 +108,4 @@ class ChatMessage { } } -module.exports = ChatMessage; +export default ChatMessage diff --git a/src/static/js/ace.ts b/src/static/js/ace.ts index 091ab12f109..43920cbf47a 100644 --- a/src/static/js/ace.ts +++ b/src/static/js/ace.ts @@ -6,6 +6,8 @@ */ import {InnerWindow} from "./types/InnerWindow"; +import {AText} from "./types/AText"; +import AttributePool from "./AttributePool"; /** * Copyright 2009 Google Inc. @@ -30,7 +32,8 @@ const hooks = require('./pluginfw/hooks'); const pluginUtils = require('./pluginfw/shared'); const ace2_inner = require('ep_etherpad-lite/static/js/ace2_inner') -const debugLog = (...args: string[]|Object[]|null[]) => {}; +const debugLog = (...args: string[] | Object[] | null[]) => { +}; const cl_plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins') const {Cssmanager} = require("./cssmanager"); // The inner and outer iframe's locations are about:blank, so relative URLs are relative to that. @@ -58,7 +61,8 @@ const eventFired = async (obj: any, event: string, cleanups: Function[] = [], pr reject(err); }; cleanup = () => { - cleanup = () => {}; + cleanup = () => { + }; obj!.removeEventListener(event, successCb); obj!.removeEventListener('error', errorCb); }; @@ -90,6 +94,26 @@ const frameReady = async (frame: HTMLIFrameElement) => { }; export class Ace2Editor { + callWithAce(arg0: (ace: any) => void, cmd?: string, flag?: boolean) { + throw new Error("Method not implemented."); + } + + focus = () => { + + } + + setEditable = (editable: boolean)=>{ + + } + + importAText = (atext: AText, apool: AttributePool, flag: boolean)=>{ + +} + + setProperty = (ev: string, padFontFam: string|boolean)=>{ + + } + info = {editor: this}; loaded = false; actionsPendingInit: Function[] = []; @@ -108,7 +132,7 @@ export class Ace2Editor { } } - pendingInit = (func: Function) => (...args: any[])=> { + pendingInit = (func: Function) => (...args: any[]) => { const action = () => func.apply(this, args); if (this.loaded) return action(); this.actionsPendingInit.push(action); @@ -176,7 +200,7 @@ export class Ace2Editor { this.info = null; // prevent IE 6 closure memory leaks }); - init = async (containerId: string, initialCode: string)=> { + init = async (containerId: string, initialCode: string) => { debugLog('Ace2Editor.init()'); // @ts-ignore this.importText(initialCode); @@ -296,7 +320,7 @@ export class Ace2Editor { await innerWindow.Ace2Inner.init(this.info, { inner: new Cssmanager(innerStyle.sheet), outer: new Cssmanager(outerStyle.sheet), - parent: new Cssmanager((document.querySelector('style[title="dynamicsyntax"]') as HTMLStyleElement)!.sheet), + parent: new Cssmanager((document.querySelector('style[title="dynamicsyntax"]') as HTMLStyleElement)!.sheet), }); debugLog('Ace2Editor.init() Ace2Inner.init() returned'); this.loaded = true; diff --git a/src/static/js/ace2_common.js b/src/static/js/ace2_common.ts similarity index 76% rename from src/static/js/ace2_common.js rename to src/static/js/ace2_common.ts index c1dab5cfd8b..0a5f308e6a2 100644 --- a/src/static/js/ace2_common.js +++ b/src/static/js/ace2_common.ts @@ -6,6 +6,8 @@ * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ +import {MapArrayType} from "../../node/types/MapType"; + /** * Copyright 2009 Google Inc. * @@ -22,11 +24,13 @@ * limitations under the License. */ -const isNodeText = (node) => (node.nodeType === 3); +export const isNodeText = (node: { + nodeType: number +}) => (node.nodeType === 3); -const getAssoc = (obj, name) => obj[`_magicdom_${name}`]; +export const getAssoc = (obj: MapArrayType, name: string) => obj[`_magicdom_${name}`]; -const setAssoc = (obj, name, value) => { +export const setAssoc = (obj: MapArrayType, name: string, value: string) => { // note that in IE designMode, properties of a node can get // copied to new nodes that are spawned during editing; also, // properties representable in HTML text can survive copy-and-paste @@ -38,7 +42,7 @@ const setAssoc = (obj, name, value) => { // between false and true, a number between 0 and numItems inclusive. -const binarySearch = (numItems, func) => { +export const binarySearch = (numItems: number, func: (num: number)=>boolean) => { if (numItems < 1) return 0; if (func(0)) return 0; if (!func(numItems - 1)) return numItems; @@ -52,17 +56,10 @@ const binarySearch = (numItems, func) => { return high; }; -const binarySearchInfinite = (expectedLength, func) => { +export const binarySearchInfinite = (expectedLength: number, func: (num: number)=>boolean) => { let i = 0; while (!func(i)) i += expectedLength; return binarySearch(i, func); }; -const noop = () => {}; - -exports.isNodeText = isNodeText; -exports.getAssoc = getAssoc; -exports.setAssoc = setAssoc; -exports.binarySearch = binarySearch; -exports.binarySearchInfinite = binarySearchInfinite; -exports.noop = noop; +export const noop = () => {}; diff --git a/src/static/js/collab_client.js b/src/static/js/collab_client.js deleted file mode 100644 index 69c8e41f302..00000000000 --- a/src/static/js/collab_client.js +++ /dev/null @@ -1,502 +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 chat = require('./chat').chat; -const hooks = require('./pluginfw/hooks'); -const browser = require('./vendors/browser'); - -// Dependency fill on init. This exists for `pad.socket` only. -// TODO: bind directly to the socket. -let pad = undefined; -const getSocket = () => pad && pad.socket; - -/** Call this when the document is ready, and a new Ace2Editor() has been created and inited. - ACE's ready callback does not need to have fired yet. - "serverVars" are from calling doc.getCollabClientVars() on the server. */ -const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad) => { - const editor = ace2editor; - pad = _pad; // Inject pad to avoid a circular dependency. - - let rev = serverVars.rev; - let committing = false; - let stateMessage; - let channelState = 'CONNECTING'; - let lastCommitTime = 0; - let initialStartConnectTime = 0; - let commitDelay = 500; - - const userId = initialUserInfo.userId; - // var socket; - const userSet = {}; // userId -> userInfo - userSet[userId] = initialUserInfo; - - let isPendingRevision = false; - - const callbacks = { - onUserJoin: () => {}, - onUserLeave: () => {}, - onUpdateUserInfo: () => {}, - onChannelStateChange: () => {}, - onClientMessage: () => {}, - onInternalAction: () => {}, - onConnectionTrouble: () => {}, - onServerMessage: () => {}, - }; - if (browser.firefox) { - // Prevent "escape" from taking effect and canceling a comet connection; - // doesn't work if focus is on an iframe. - $(window).on('keydown', (evt) => { - if (evt.which === 27) { - evt.preventDefault(); - } - }); - } - - const handleUserChanges = () => { - if (editor.getInInternationalComposition()) { - // handleUserChanges() will be called again once composition ends so there's no need to set up - // a future call before returning. - return; - } - const now = Date.now(); - if ((!getSocket()) || channelState === 'CONNECTING') { - if (channelState === 'CONNECTING' && (now - initialStartConnectTime) > 20000) { - setChannelState('DISCONNECTED', 'initsocketfail'); - } else { - // check again in a bit - setTimeout(handleUserChanges, 1000); - } - return; - } - - if (committing) { - if (now - lastCommitTime > 20000) { - // a commit is taking too long - setChannelState('DISCONNECTED', 'slowcommit'); - } else if (now - lastCommitTime > 5000) { - callbacks.onConnectionTrouble('SLOW'); - } else { - // run again in a few seconds, to detect a disconnect - setTimeout(handleUserChanges, 3000); - } - return; - } - - const earliestCommit = lastCommitTime + commitDelay; - if (now < earliestCommit) { - setTimeout(handleUserChanges, earliestCommit - now); - return; - } - - let sentMessage = false; - // Check if there are any pending revisions to be received from server. - // Allow only if there are no pending revisions to be received from server - if (!isPendingRevision) { - const userChangesData = editor.prepareUserChangeset(); - if (userChangesData.changeset) { - lastCommitTime = now; - committing = true; - stateMessage = { - type: 'USER_CHANGES', - baseRev: rev, - changeset: userChangesData.changeset, - apool: userChangesData.apool, - }; - sendMessage(stateMessage); - sentMessage = true; - callbacks.onInternalAction('commitPerformed'); - } - } else { - // run again in a few seconds, to check if there was a reconnection attempt - setTimeout(handleUserChanges, 3000); - } - - if (sentMessage) { - // run again in a few seconds, to detect a disconnect - setTimeout(handleUserChanges, 3000); - } - }; - - const acceptCommit = () => { - editor.applyPreparedChangesetToBase(); - setStateIdle(); - try { - callbacks.onInternalAction('commitAcceptedByServer'); - callbacks.onConnectionTrouble('OK'); - } catch (err) { /* intentionally ignored */ } - handleUserChanges(); - }; - - const setUpSocket = () => { - setChannelState('CONNECTED'); - doDeferredActions(); - - initialStartConnectTime = Date.now(); - }; - - const sendMessage = (msg) => { - getSocket().emit('message', - { - type: 'COLLABROOM', - component: 'pad', - data: msg, - }); - }; - - const serverMessageTaskQueue = new class { - constructor() { - this._promiseChain = Promise.resolve(); - } - - async enqueue(fn) { - const taskPromise = this._promiseChain.then(fn); - // Use .catch() to prevent rejections from halting the queue. - this._promiseChain = taskPromise.catch(() => {}); - // Do NOT do `return await this._promiseChain;` because the caller would not see an error if - // fn() throws/rejects (due to the .catch() added above). - return await taskPromise; - } - }(); - - const handleMessageFromServer = (evt) => { - if (!getSocket()) return; - if (!evt.data) return; - const wrapper = evt; - if (wrapper.type !== 'COLLABROOM' && wrapper.type !== 'CUSTOM') return; - const msg = wrapper.data; - - if (msg.type === 'NEW_CHANGES') { - serverMessageTaskQueue.enqueue(async () => { - // Avoid updating the DOM while the user is composing a character. Notes about this `await`: - // * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not - // currently composing a character then execution will continue without error. - // * We assume that it is not possible for a new 'compositionstart' event to fire after - // the `await` but before the next line of code after the `await` (or, if it is - // possible, that the chances are so small or the consequences so minor that it's not - // worth addressing). - await editor.getInInternationalComposition(); - const {newRev, changeset, author = '', apool} = msg; - if (newRev !== (rev + 1)) { - window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${rev + 1}`); - // setChannelState("DISCONNECTED", "badmessage_newchanges"); - return; - } - rev = newRev; - editor.applyChangesToBase(changeset, author, apool); - }); - } else if (msg.type === 'ACCEPT_COMMIT') { - serverMessageTaskQueue.enqueue(() => { - const {newRev} = msg; - // newRev will equal rev if the changeset has no net effect (identity changeset, removing - // and re-adding the same characters with the same attributes, or retransmission of an - // already applied changeset). - if (![rev, rev + 1].includes(newRev)) { - window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${rev + 1}`); - // setChannelState("DISCONNECTED", "badmessage_acceptcommit"); - return; - } - rev = newRev; - acceptCommit(); - }); - } else if (msg.type === 'CLIENT_RECONNECT') { - // Server sends a CLIENT_RECONNECT message when there is a client reconnect. - // Server also returns all pending revisions along with this CLIENT_RECONNECT message - serverMessageTaskQueue.enqueue(() => { - if (msg.noChanges) { - // If no revisions are pending, just make everything normal - setIsPendingRevision(false); - return; - } - const {headRev, newRev, changeset, author = '', apool} = msg; - if (newRev !== (rev + 1)) { - window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`); - // setChannelState("DISCONNECTED", "badmessage_acceptcommit"); - return; - } - rev = newRev; - if (author === pad.getUserId()) { - acceptCommit(); - } else { - editor.applyChangesToBase(changeset, author, apool); - } - if (newRev === headRev) { - // Once we have applied all pending revisions, make everything normal - setIsPendingRevision(false); - } - }); - } else if (msg.type === 'USER_NEWINFO') { - const userInfo = msg.userInfo; - const id = userInfo.userId; - if (userSet[id]) { - userSet[id] = userInfo; - callbacks.onUpdateUserInfo(userInfo); - } else { - userSet[id] = userInfo; - callbacks.onUserJoin(userInfo); - } - tellAceActiveAuthorInfo(userInfo); - } else if (msg.type === 'USER_LEAVE') { - const userInfo = msg.userInfo; - const id = userInfo.userId; - if (userSet[id]) { - delete userSet[userInfo.userId]; - fadeAceAuthorInfo(userInfo); - callbacks.onUserLeave(userInfo); - } - } else if (msg.type === 'CLIENT_MESSAGE') { - callbacks.onClientMessage(msg.payload); - } else if (msg.type === 'CHAT_MESSAGE') { - chat.addMessage(msg.message, true, false); - } else if (msg.type === 'CHAT_MESSAGES') { - for (let i = msg.messages.length - 1; i >= 0; i--) { - chat.addMessage(msg.messages[i], true, true); - } - if (!chat.gotInitalMessages) { - chat.scrollDown(); - chat.gotInitalMessages = true; - chat.historyPointer = clientVars.chatHead - msg.messages.length; - } - - // messages are loaded, so hide the loading-ball - $('#chatloadmessagesball').css('display', 'none'); - - // there are less than 100 messages or we reached the top - if (chat.historyPointer <= 0) { - $('#chatloadmessagesbutton').css('display', 'none'); - } else { - // there are still more messages, re-show the load-button - $('#chatloadmessagesbutton').css('display', 'block'); - } - } - - // HACKISH: User messages do not have "payload" but "userInfo", so that all - // "handleClientMessage_USER_" hooks would work, populate payload - // FIXME: USER_* messages to have "payload" property instead of "userInfo", - // seems like a quite a big work - if (msg.type.indexOf('USER_') > -1) { - msg.payload = msg.userInfo; - } - // Similar for NEW_CHANGES - if (msg.type === 'NEW_CHANGES') msg.payload = msg; - - hooks.callAll(`handleClientMessage_${msg.type}`, {payload: msg.payload}); - }; - - const updateUserInfo = (userInfo) => { - userInfo.userId = userId; - userSet[userId] = userInfo; - tellAceActiveAuthorInfo(userInfo); - if (!getSocket()) return; - sendMessage( - { - type: 'USERINFO_UPDATE', - userInfo, - }); - }; - - const tellAceActiveAuthorInfo = (userInfo) => { - tellAceAuthorInfo(userInfo.userId, userInfo.colorId); - }; - - const tellAceAuthorInfo = (userId, colorId, inactive) => { - if (typeof colorId === 'number') { - colorId = clientVars.colorPalette[colorId]; - } - - const cssColor = colorId; - if (inactive) { - editor.setAuthorInfo(userId, { - bgcolor: cssColor, - fade: 0.5, - }); - } else { - editor.setAuthorInfo(userId, { - bgcolor: cssColor, - }); - } - }; - - const fadeAceAuthorInfo = (userInfo) => { - tellAceAuthorInfo(userInfo.userId, userInfo.colorId, true); - }; - - const getConnectedUsers = () => valuesArray(userSet); - - const tellAceAboutHistoricalAuthors = (hadata) => { - for (const [author, data] of Object.entries(hadata)) { - if (!userSet[author]) { - tellAceAuthorInfo(author, data.colorId, true); - } - } - }; - - const setChannelState = (newChannelState, moreInfo) => { - if (newChannelState !== channelState) { - channelState = newChannelState; - callbacks.onChannelStateChange(channelState, moreInfo); - } - }; - - const valuesArray = (obj) => { - const array = []; - $.each(obj, (k, v) => { - array.push(v); - }); - return array; - }; - - // We need to present a working interface even before the socket - // is connected for the first time. - let deferredActions = []; - - const defer = (func, tag) => function (...args) { - const action = () => { - func.call(this, ...args); - }; - action.tag = tag; - if (channelState === 'CONNECTING') { - deferredActions.push(action); - } else { - action(); - } - }; - - const doDeferredActions = (tag) => { - const newArray = []; - for (let i = 0; i < deferredActions.length; i++) { - const a = deferredActions[i]; - if ((!tag) || (tag === a.tag)) { - a(); - } else { - newArray.push(a); - } - } - deferredActions = newArray; - }; - - const sendClientMessage = (msg) => { - sendMessage( - { - type: 'CLIENT_MESSAGE', - payload: msg, - }); - }; - - const getCurrentRevisionNumber = () => rev; - - const getMissedChanges = () => { - const obj = {}; - obj.userInfo = userSet[userId]; - obj.baseRev = rev; - if (committing && stateMessage) { - obj.committedChangeset = stateMessage.changeset; - obj.committedChangesetAPool = stateMessage.apool; - editor.applyPreparedChangesetToBase(); - } - const userChangesData = editor.prepareUserChangeset(); - if (userChangesData.changeset) { - obj.furtherChangeset = userChangesData.changeset; - obj.furtherChangesetAPool = userChangesData.apool; - } - return obj; - }; - - const setStateIdle = () => { - committing = false; - callbacks.onInternalAction('newlyIdle'); - schedulePerhapsCallIdleFuncs(); - }; - - const setIsPendingRevision = (value) => { - isPendingRevision = value; - }; - - const idleFuncs = []; - - const callWhenNotCommitting = (func) => { - idleFuncs.push(func); - schedulePerhapsCallIdleFuncs(); - }; - - const schedulePerhapsCallIdleFuncs = () => { - setTimeout(() => { - if (!committing) { - while (idleFuncs.length > 0) { - const f = idleFuncs.shift(); - f(); - } - } - }, 0); - }; - - const self = { - setOnUserJoin: (cb) => { - callbacks.onUserJoin = cb; - }, - setOnUserLeave: (cb) => { - callbacks.onUserLeave = cb; - }, - setOnUpdateUserInfo: (cb) => { - callbacks.onUpdateUserInfo = cb; - }, - setOnChannelStateChange: (cb) => { - callbacks.onChannelStateChange = cb; - }, - setOnClientMessage: (cb) => { - callbacks.onClientMessage = cb; - }, - setOnInternalAction: (cb) => { - callbacks.onInternalAction = cb; - }, - setOnConnectionTrouble: (cb) => { - callbacks.onConnectionTrouble = cb; - }, - updateUserInfo: defer(updateUserInfo), - handleMessageFromServer, - getConnectedUsers, - sendClientMessage, - sendMessage, - getCurrentRevisionNumber, - getMissedChanges, - callWhenNotCommitting, - addHistoricalAuthors: tellAceAboutHistoricalAuthors, - setChannelState, - setStateIdle, - setIsPendingRevision, - set commitDelay(ms) { commitDelay = ms; }, - get commitDelay() { return commitDelay; }, - }; - - tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData); - tellAceActiveAuthorInfo(initialUserInfo); - - editor.setProperty('userAuthor', userId); - editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool); - editor.setUserChangeNotificationCallback(handleUserChanges); - - setUpSocket(); - return self; -}; - -exports.getCollabClient = getCollabClient; diff --git a/src/static/js/collab_client.ts b/src/static/js/collab_client.ts new file mode 100644 index 00000000000..9a2c4ce604d --- /dev/null +++ b/src/static/js/collab_client.ts @@ -0,0 +1,524 @@ +'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 {Ace2Editor} from "./ace"; +import {ClientAcceptCommitMessage, ClientNewChanges, ClientSendMessages, ClientSendUserInfoUpdate, ClientUserChangesMessage, ClientVarData, ClientVarMessage, HistoricalAuthorData, ServerVar, UserInfo} from "./types/SocketIOMessage"; +import {Pad} from "./pad"; +import AttributePool from "./AttributePool"; +import {MapArrayType} from "../../node/types/MapType"; + +/** + * 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 chat = require('./chat').chat; +const hooks = require('./pluginfw/hooks'); +const browser = require('./vendors/browser'); + +// Dependency fill on init. This exists for `pad.socket` only. +// TODO: bind directly to the socket. + +/** Call this when the document is ready, and a new Ace2Editor() has been created and inited. + ACE's ready callback does not need to have fired yet. + "serverVars" are from calling doc.getCollabClientVars() on the server. */ +export class CollabClient { + private editor: Ace2Editor; + private serverVars: ServerVar; + private initialUserInfo: any; + private pad: Pad; + private userSet = new Map // userId -> userInfo + private channelState: string; + private initialStartConnectTime: number; + private commitDelay: number; + private committing: boolean; + private rev: number; + private userId: string + // We need to present a working interface even before the socket + // is connected for the first time. + private deferredActions:any[] = []; + private stateMessage?: ClientUserChangesMessage; + private lastCommitTime: number; + private isPendingRevision: boolean; + private idleFuncs: Function[] = []; + + constructor(ace2editor: Ace2Editor, serverVars: ServerVar, initialUserInfo: UserInfo, options: { + colorPalette: MapArrayType + }, pad: Pad) { + this.serverVars = serverVars + this.initialUserInfo = initialUserInfo + this.pad = pad // Inject pad to avoid a circular dependency. + + this.editor = ace2editor; + + this.rev = serverVars.rev; + this.committing = false; + this.channelState = 'CONNECTING'; + this.lastCommitTime = 0; + this.initialStartConnectTime = 0; + this.commitDelay = 500; + + this.userId = initialUserInfo.userId; + // var socket; + this.userSet.set(this.userId,initialUserInfo); + + this.isPendingRevision = false; + if (browser.firefox) { + // Prevent "escape" from taking effect and canceling a comet connection; + // doesn't work if focus is on an iframe. + $(window).on('keydown', (evt) => { + if (evt.which === 27) { + evt.preventDefault(); + } + }); + } + + this.tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData); + this.tellAceActiveAuthorInfo(initialUserInfo); + + // @ts-ignore + this.editor.setProperty('userAuthor', this.userId); + // @ts-ignore + this.editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool); + // @ts-ignore + this.editor.setUserChangeNotificationCallback(this.handleUserChanges); + + this.setUpSocket(); + } + callbacks = { + onUserJoin: (userInfo: UserInfo) => {}, + onUserLeave: (userInfo: UserInfo) => {}, + onUpdateUserInfo: (userInfo: UserInfo) => {}, + onChannelStateChange: (newChannelState: string, moreInfo?: string) => {}, + onClientMessage: (clientmessage: ClientSendMessages) => {}, + onInternalAction: (res: string) => {}, + onConnectionTrouble: (res?: string) => {}, + onServerMessage: () => {}, + } + + handleUserChanges = () => { + if (this.editor.getInInternationalComposition()) { + // handleUserChanges() will be called again once composition ends so there's no need to set up + // a future call before returning. + return; + } + const now = Date.now(); + if ((!this.pad.socket) || this.channelState === 'CONNECTING') { + if (this.channelState === 'CONNECTING' && (now - this.initialStartConnectTime) > 20000) { + this.setChannelState('DISCONNECTED', 'initsocketfail'); + } else { + // check again in a bit + setTimeout(this.handleUserChanges, 1000); + } + return; + } + + if (this.committing) { + if (now - this.lastCommitTime > 20000) { + // a commit is taking too long + this.setChannelState('DISCONNECTED', 'slowcommit'); + } else if (now - this.lastCommitTime > 5000) { + this.callbacks.onConnectionTrouble('SLOW'); + } else { + // run again in a few seconds, to detect a disconnect + setTimeout(this.handleUserChanges, 3000); + } + return; + } + + const earliestCommit = this.lastCommitTime + this.commitDelay; + if (now < earliestCommit) { + setTimeout(this.handleUserChanges, earliestCommit - now); + return; + } + + let sentMessage = false; + // Check if there are any pending revisions to be received from server. + // Allow only if there are no pending revisions to be received from server + if (!this.isPendingRevision) { + const userChangesData = this.editor.prepareUserChangeset(); + if (userChangesData.changeset) { + this.lastCommitTime = now; + this.committing = true; + this.stateMessage = { + type: 'USER_CHANGES', + baseRev: this.rev, + changeset: userChangesData.changeset, + apool: userChangesData.apool, + } satisfies ClientUserChangesMessage; + this.sendMessage(this.stateMessage); + sentMessage = true; + this.callbacks.onInternalAction('commitPerformed'); + } + } else { + // run again in a few seconds, to check if there was a reconnection attempt + setTimeout(this.handleUserChanges, 3000); + } + + if (sentMessage) { + // run again in a few seconds, to detect a disconnect + setTimeout(this.handleUserChanges, 3000); + } + } + + acceptCommit = () => { + // @ts-ignore + this.editor.applyPreparedChangesetToBase(); + this.setStateIdle(); + try { + this.callbacks.onInternalAction('commitAcceptedByServer'); + this.callbacks.onConnectionTrouble('OK'); + } catch (err) { /* intentionally ignored */ } + this.handleUserChanges(); + } + + setUpSocket = () => { + this.setChannelState('CONNECTED'); + this.doDeferredActions(); + + this.initialStartConnectTime = Date.now(); + } + + sendMessage = (msg: ClientSendMessages) => { + this.pad.socket!.emit('message', + { + type: 'COLLABROOM', + component: 'pad', + data: msg, + }); + } + + serverMessageTaskQueue = new class { + private _promiseChain: Promise + constructor() { + this._promiseChain = Promise.resolve(); + } + + async enqueue(fn: (val: any)=>void) { + const taskPromise = this._promiseChain.then(fn); + // Use .catch() to prevent rejections from halting the queue. + this._promiseChain = taskPromise.catch(() => {}); + // Do NOT do `return await this._promiseChain;` because the caller would not see an error if + // fn() throws/rejects (due to the .catch() added above). + return await taskPromise; + } + }() + + handleMessageFromServer = (evt: ClientVarMessage) => { + if (!this.pad.socket()) return; + if (!("data" in evt)) return; + const wrapper = evt; + if (wrapper.type !== 'COLLABROOM' && wrapper.type !== 'CUSTOM') return; + const msg = wrapper.data; + + if (msg.type === 'NEW_CHANGES') { + this.serverMessageTaskQueue.enqueue(async () => { + // Avoid updating the DOM while the user is composing a character. Notes about this `await`: + // * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not + // currently composing a character then execution will continue without error. + // * We assume that it is not possible for a new 'compositionstart' event to fire after + // the `await` but before the next line of code after the `await` (or, if it is + // possible, that the chances are so small or the consequences so minor that it's not + // worth addressing). + await this.editor.getInInternationalComposition(); + const {newRev, changeset, author = '', apool} = msg; + if (newRev !== (this.rev + 1)) { + window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${this.rev + 1}`); + // setChannelState("DISCONNECTED", "badmessage_newchanges"); + return; + } + this.rev = newRev; + // @ts-ignore + this.editor.applyChangesToBase(changeset, author, apool); + }); + } else if (msg.type === 'ACCEPT_COMMIT') { + this.serverMessageTaskQueue.enqueue(() => { + const {newRev} = msg as ClientAcceptCommitMessage; + // newRev will equal rev if the changeset has no net effect (identity changeset, removing + // and re-adding the same characters with the same attributes, or retransmission of an + // already applied changeset). + if (![this.rev, this.rev + 1].includes(newRev)) { + window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${this.rev + 1}`); + // setChannelState("DISCONNECTED", "badmessage_acceptcommit"); + return; + } + this.rev = newRev; + this.acceptCommit(); + }); + } else if (msg.type === 'CLIENT_RECONNECT') { + // Server sends a CLIENT_RECONNECT message when there is a client reconnect. + // Server also returns all pending revisions along with this CLIENT_RECONNECT message + this.serverMessageTaskQueue.enqueue(() => { + if (msg.noChanges) { + // If no revisions are pending, just make everything normal + this.setIsPendingRevision(false); + return; + } + const {headRev, newRev, changeset, author = '', apool} = msg; + if (newRev !== (this.rev + 1)) { + window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${this.rev + 1}`); + // setChannelState("DISCONNECTED", "badmessage_acceptcommit"); + return; + } + this.rev = newRev; + if (author === this.pad.getUserId()) { + this.acceptCommit(); + } else { + // @ts-ignore + this.editor.applyChangesToBase(changeset, author, apool); + } + if (newRev === headRev) { + // Once we have applied all pending revisions, make everything normal + this.setIsPendingRevision(false); + } + }); + } else if (msg.type === 'USER_NEWINFO') { + const userInfo = msg.userInfo; + const id = userInfo.userId; + if (this.userSet.has(id)) { + this.userSet.set(id,userInfo); + this.callbacks.onUpdateUserInfo(userInfo); + } else { + this.userSet.set(id,userInfo); + this.callbacks.onUserJoin(userInfo); + } + this.tellAceActiveAuthorInfo(userInfo); + } else if (msg.type === 'USER_LEAVE') { + const userInfo = msg.userInfo; + const id = userInfo.userId; + if (this.userSet.has(id)) { + this.userSet.delete(userInfo.userId); + this.fadeAceAuthorInfo(userInfo); + this.callbacks.onUserLeave(userInfo); + } + } else if (msg.type === 'CLIENT_MESSAGE') { + this.callbacks.onClientMessage(msg.payload); + } else if (msg.type === 'CHAT_MESSAGE') { + chat.addMessage(msg.message, true, false); + } else if (msg.type === 'CHAT_MESSAGES') { + for (let i = msg.messages.length - 1; i >= 0; i--) { + chat.addMessage(msg.messages[i], true, true); + } + if (!chat.gotInitalMessages) { + chat.scrollDown(); + chat.gotInitalMessages = true; + chat.historyPointer = window.clientVars.chatHead - msg.messages.length; + } + + // messages are loaded, so hide the loading-ball + $('#chatloadmessagesball').css('display', 'none'); + + // there are less than 100 messages or we reached the top + if (chat.historyPointer <= 0) { + $('#chatloadmessagesbutton').css('display', 'none'); + } else { + // there are still more messages, re-show the load-button + $('#chatloadmessagesbutton').css('display', 'block'); + } + } + + // HACKISH: User messages do not have "payload" but "userInfo", so that all + // "handleClientMessage_USER_" hooks would work, populate payload + // FIXME: USER_* messages to have "payload" property instead of "userInfo", + // seems like a quite a big work + if (msg.type.indexOf('USER_') > -1) { + // @ts-ignore + msg.payload = msg.userInfo; + } + // Similar for NEW_CHANGES + if (msg.type === 'NEW_CHANGES') { + msg.payload = msg; + } + + // @ts-ignore + hooks.callAll(`handleClientMessage_${msg.type}`, {payload: msg.payload}); + } + + updateUserInfo = (userInfo: UserInfo) => { + userInfo.userId = this.userId; + this.userSet.set(this.userId, userInfo); + this.tellAceActiveAuthorInfo(userInfo); + if (!this.pad.socket()) return; + this.sendMessage( + { + type: 'USERINFO_UPDATE', + userInfo, + }); + }; + tellAceActiveAuthorInfo = (userInfo: UserInfo) => { + this.tellAceAuthorInfo(userInfo.userId, userInfo.colorId); + } + + tellAceAuthorInfo = (userId: string, colorId: number|object, inactive?: boolean) => { + if (typeof colorId === 'number') { + colorId = window.clientVars.colorPalette[colorId]; + } + + const cssColor = colorId; + if (inactive) { + // @ts-ignore + this.editor.setAuthorInfo(userId, { + bgcolor: cssColor, + fade: 0.5, + }); + } else { + // @ts-ignore + this.editor.setAuthorInfo(userId, { + bgcolor: cssColor, + }); + } + } + + fadeAceAuthorInfo = (userInfo: UserInfo) => { + this.tellAceAuthorInfo(userInfo.userId, userInfo.colorId, true); + } + getConnectedUsers = () => this.valuesArray(this.userSet); + tellAceAboutHistoricalAuthors = (hadata: HistoricalAuthorData) => { + for (const [author, data] of Object.entries(hadata)) { + if (!this.userSet.has(author)) { + this.tellAceAuthorInfo(author, data.colorId, true); + } + } + } + setChannelState = (newChannelState: string, moreInfo?: string) => { + if (newChannelState !== this.channelState) { + this.channelState = newChannelState; + this.callbacks.onChannelStateChange(this.channelState, moreInfo); + } + } + + valuesArray = (obj: Map) => { + const array: UserInfo[] = []; + + for (let entry of obj.values()) { + array.push(entry) + } + return array; + }; + + defer = (func: Function, tag?: string) => (...args:any[])=> { + const action = () => { + func.call(this, ...args); + }; + action.tag = tag; + if (this.channelState === 'CONNECTING') { + this.deferredActions.push(action); + } else { + action(); + } + } + doDeferredActions = (tag?: string) => { + const newArray = []; + for (let i = 0; i < this.deferredActions.length; i++) { + const a = this.deferredActions[i]; + if ((!tag) || (tag === a.tag)) { + a(); + } else { + newArray.push(a); + } + } + this.deferredActions = newArray; + } + sendClientMessage = (msg: ClientSendMessages) => { + this.sendMessage( + { + type: 'CLIENT_MESSAGE', + payload: msg, + }); + } + + getCurrentRevisionNumber = () => this.rev + getMissedChanges = () => { + const obj:{ + userInfo?: UserInfo, + baseRev?: number, + committedChangeset?: string, + committedChangesetAPool?: AttributePool, + furtherChangeset?: string, + furtherChangesetAPool?: AttributePool + } = {}; + obj.userInfo = this.userSet.get(this.userId); + obj.baseRev = this.rev; + if (this.committing && this.stateMessage) { + obj.committedChangeset = this.stateMessage.changeset; + obj.committedChangesetAPool = this.stateMessage.apool; + // @ts-ignore + this.editor.applyPreparedChangesetToBase(); + } + const userChangesData = this.editor.prepareUserChangeset(); + if (userChangesData.changeset) { + obj.furtherChangeset = userChangesData.changeset; + obj.furtherChangesetAPool = userChangesData.apool; + } + return obj; + } + setStateIdle = () => { + this.committing = false; + this.callbacks.onInternalAction('newlyIdle'); + this.schedulePerhapsCallIdleFuncs(); + } + setIsPendingRevision = (value: boolean) => { + this.isPendingRevision = value; + } + + callWhenNotCommitting = (func: Function) => { + this.idleFuncs.push(func); + this.schedulePerhapsCallIdleFuncs(); + } + + schedulePerhapsCallIdleFuncs = () => { + setTimeout(() => { + if (!this.committing) { + while (this.idleFuncs.length > 0) { + const f = this.idleFuncs.shift()!; + f(); + } + } + }, 0); + } + setOnUserJoin= (cb: (userInfo: UserInfo)=>void) => { + this.callbacks.onUserJoin = cb; + } + setOnUserLeave= (cb: (userInfo: UserInfo) => void) => { + this.callbacks.onUserLeave = cb; + } + setOnUpdateUserInfo= (cb: (userInfo: UserInfo) => void) => { + this.callbacks.onUpdateUserInfo = cb; + } + setOnChannelStateChange = (cb: (newChannelState: string, moreInfo?: string) => void) => { + this.callbacks.onChannelStateChange = cb; + } + setOnClientMessage = (cb: (clientmessage: ClientSendMessages) => void) => { + this.callbacks.onClientMessage = cb; + } + setOnInternalAction = (cb: (res: string) => void) => { + this.callbacks.onInternalAction = cb; + } + setOnConnectionTrouble = (cb: (res?: string) => void) => { + this.callbacks.onConnectionTrouble = cb; + } + pupdateUserInfo = this.defer(this.updateUserInfo) + addHistoricalAuthors= this.tellAceAboutHistoricalAuthors + setCommitDelay = (ms: number) => { + this.commitDelay = ms + } +} + + +export default CollabClient diff --git a/src/static/js/colorutils.js b/src/static/js/colorutils.js deleted file mode 100644 index 9688b8e59ce..00000000000 --- a/src/static/js/colorutils.js +++ /dev/null @@ -1,121 +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 - */ - -// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/colorutils.js -// THIS FILE IS ALSO SERVED AS CLIENT-SIDE JS -/** - * 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 colorutils = {}; - -// Check that a given value is a css hex color value, e.g. -// "#ffffff" or "#fff" -colorutils.isCssHex = (cssColor) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(cssColor); - -// "#ffffff" or "#fff" or "ffffff" or "fff" to [1.0, 1.0, 1.0] -colorutils.css2triple = (cssColor) => { - const sixHex = colorutils.css2sixhex(cssColor); - - const hexToFloat = (hh) => Number(`0x${hh}`) / 255; - return [ - hexToFloat(sixHex.substr(0, 2)), - hexToFloat(sixHex.substr(2, 2)), - hexToFloat(sixHex.substr(4, 2)), - ]; -}; - -// "#ffffff" or "#fff" or "ffffff" or "fff" to "ffffff" -colorutils.css2sixhex = (cssColor) => { - let h = /[0-9a-fA-F]+/.exec(cssColor)[0]; - if (h.length !== 6) { - const a = h.charAt(0); - const b = h.charAt(1); - const c = h.charAt(2); - h = a + a + b + b + c + c; - } - return h; -}; - -// [1.0, 1.0, 1.0] -> "#ffffff" -colorutils.triple2css = (triple) => { - const floatToHex = (n) => { - const n2 = colorutils.clamp(Math.round(n * 255), 0, 255); - return (`0${n2.toString(16)}`).slice(-2); - }; - return `#${floatToHex(triple[0])}${floatToHex(triple[1])}${floatToHex(triple[2])}`; -}; - - -colorutils.clamp = (v, bot, top) => v < bot ? bot : (v > top ? top : v); -colorutils.min3 = (a, b, c) => (a < b) ? (a < c ? a : c) : (b < c ? b : c); -colorutils.max3 = (a, b, c) => (a > b) ? (a > c ? a : c) : (b > c ? b : c); -colorutils.colorMin = (c) => colorutils.min3(c[0], c[1], c[2]); -colorutils.colorMax = (c) => colorutils.max3(c[0], c[1], c[2]); -colorutils.scale = (v, bot, top) => colorutils.clamp(bot + v * (top - bot), 0, 1); -colorutils.unscale = (v, bot, top) => colorutils.clamp((v - bot) / (top - bot), 0, 1); - -colorutils.scaleColor = (c, bot, top) => [ - colorutils.scale(c[0], bot, top), - colorutils.scale(c[1], bot, top), - colorutils.scale(c[2], bot, top), -]; - -colorutils.unscaleColor = (c, bot, top) => [ - colorutils.unscale(c[0], bot, top), - colorutils.unscale(c[1], bot, top), - colorutils.unscale(c[2], bot, top), -]; - -// rule of thumb for RGB brightness; 1.0 is white -colorutils.luminosity = (c) => c[0] * 0.30 + c[1] * 0.59 + c[2] * 0.11; - -colorutils.saturate = (c) => { - const min = colorutils.colorMin(c); - const max = colorutils.colorMax(c); - if (max - min <= 0) return [1.0, 1.0, 1.0]; - return colorutils.unscaleColor(c, min, max); -}; - -colorutils.blend = (c1, c2, t) => [ - colorutils.scale(t, c1[0], c2[0]), - colorutils.scale(t, c1[1], c2[1]), - colorutils.scale(t, c1[2], c2[2]), -]; - -colorutils.invert = (c) => [1 - c[0], 1 - c[1], 1 - c[2]]; - -colorutils.complementary = (c) => { - const inv = colorutils.invert(c); - return [ - (inv[0] >= c[0]) ? Math.min(inv[0] * 1.30, 1) : (c[0] * 0.30), - (inv[1] >= c[1]) ? Math.min(inv[1] * 1.59, 1) : (c[1] * 0.59), - (inv[2] >= c[2]) ? Math.min(inv[2] * 1.11, 1) : (c[2] * 0.11), - ]; -}; - -colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => { - const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff'; - const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222'; - - return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black; -}; - -exports.colorutils = colorutils; diff --git a/src/static/js/colorutils.ts b/src/static/js/colorutils.ts new file mode 100644 index 00000000000..70a5e35bfe4 --- /dev/null +++ b/src/static/js/colorutils.ts @@ -0,0 +1,113 @@ +'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 + */ + +// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/colorutils.js +// THIS FILE IS ALSO SERVED AS CLIENT-SIDE JS +/** + * 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. + */ + +type ColorTriplet = [number, number, number] + +export class Colorutils { + // Check that a given value is a css hex color value, e.g. +// "#ffffff" or "#fff" + isCssHex = (cssColor: string) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(cssColor) + // "#ffffff" or "#fff" or "ffffff" or "fff" to [1.0, 1.0, 1.0] + css2triple = (cssColor: string): ColorTriplet => { + const sixHex = this.css2sixhex(cssColor); + + const hexToFloat = (hh: string) => Number(`0x${hh}`) / 255; + return [ + hexToFloat(sixHex.substring(0, 2)), + hexToFloat(sixHex.substring(2, 2)), + hexToFloat(sixHex.substring(4, 2)), + ]; + } + // "#ffffff" or "#fff" or "ffffff" or "fff" to "ffffff" + css2sixhex = (cssColor: string) => { + let h = /[0-9a-fA-F]+/.exec(cssColor)![0]; + if (h.length !== 6) { + const a = h.charAt(0); + const b = h.charAt(1); + const c = h.charAt(2); + h = a + a + b + b + c + c; + } + return h; + } + + // [1.0, 1.0, 1.0] -> "#ffffff" + triple2css = (triple: number[]) => { + const floatToHex = (n:number) => { + const n2 = this.clamp(Math.round(n * 255), 0, 255); + return (`0${n2.toString(16)}`).slice(-2); + }; + return `#${floatToHex(triple[0])}${floatToHex(triple[1])}${floatToHex(triple[2])}`; + } + clamp = (v: number, bot: number, top: number) => v < bot ? bot : (v > top ? top : v) + min3 = (a: number, b: number, c: number) => (a < b) ? (a < c ? a : c) : (b < c ? b : c) + max3 = (a: number, b: number, c: number) => (a > b) ? (a > c ? a : c) : (b > c ? b : c) + colorMin = (c: ColorTriplet) => this.min3(c[0], c[1], c[2]) + colorMax = (c: ColorTriplet) => this.max3(c[0], c[1], c[2]) + scale = (v: number, bot: number, top: number) => this.clamp(bot + v * (top - bot), 0, 1) + unscale = (v: number, bot: number, top: number) => this.clamp((v - bot) / (top - bot), 0, 1); + scaleColor = (c: ColorTriplet, bot: number, top: number) => [ + this.scale(c[0], bot, top), + this.scale(c[1], bot, top), + this.scale(c[2], bot, top), + ] + unscaleColor = (c: ColorTriplet, bot: number, top: number) => [ + this.unscale(c[0], bot, top), + this.unscale(c[1], bot, top), + this.unscale(c[2], bot, top), + ] + // rule of thumb for RGB brightness; 1.0 is white + luminosity = (c: ColorTriplet) => c[0] * 0.30 + c[1] * 0.59 + c[2] * 0.11 + saturate = (c: ColorTriplet) => { + const min = this.colorMin(c); + const max = this.colorMax(c); + if (max - min <= 0) return [1.0, 1.0, 1.0]; + return this.unscaleColor(c, min, max); + } + blend = (c1: ColorTriplet, c2: ColorTriplet, t: number) => [ + this.scale(t, c1[0], c2[0]), + this.scale(t, c1[1], c2[1]), + this.scale(t, c1[2], c2[2]), + ] + invert = (c: ColorTriplet) => [1 - c[0], 1 - c[1], 1 - c[2]] + complementary = (c: ColorTriplet) => { + const inv = this.invert(c); + return [ + (inv[0] >= c[0]) ? Math.min(inv[0] * 1.30, 1) : (c[0] * 0.30), + (inv[1] >= c[1]) ? Math.min(inv[1] * 1.59, 1) : (c[1] * 0.59), + (inv[2] >= c[2]) ? Math.min(inv[2] * 1.11, 1) : (c[2] * 0.11), + ]; + } + textColorFromBackgroundColor = (bgcolor: string, skinName: string) => { + const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff'; + const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222'; + + return this.luminosity(this.css2triple(bgcolor)) < 0.5 ? white : black; + } +} + +const colorutils = new Colorutils(); + +export default colorutils diff --git a/src/static/js/contentcollector.js b/src/static/js/contentcollector.ts similarity index 59% rename from src/static/js/contentcollector.js rename to src/static/js/contentcollector.ts index 0bff7da7d93..071307a164d 100644 --- a/src/static/js/contentcollector.js +++ b/src/static/js/contentcollector.ts @@ -8,6 +8,8 @@ // THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.contentcollector // %APPJET%: import("etherpad.collab.ace.easysync2.Changeset"); // %APPJET%: import("etherpad.admin.plugins"); +import AttributePool from "./AttributePool"; + /** * Copyright 2009 Google Inc. * @@ -27,12 +29,22 @@ const _MAX_LIST_LEVEL = 16; import AttributeMap from './AttributeMap' -const UNorm = require('unorm'); +import UNorm from 'unorm' +import {MapArrayType} from "../../node/types/MapType"; +import {SmartOpAssembler} from "./SmartOpAssembler"; +import {Attribute} from "./types/Attribute"; +import {Browser} from "@playwright/test"; +import {BrowserDetector} from "./vendors/browser"; const Changeset = require('./Changeset'); const hooks = require('./pluginfw/hooks'); -const sanitizeUnicode = (s) => UNorm.nfc(s); -const tagName = (n) => n.tagName && n.tagName.toLowerCase(); +type Tag = { + tagName: string + +} + +const sanitizeUnicode = (s: string) => UNorm.nfc(s); +const tagName = (n: Element) => n.tagName && n.tagName.toLowerCase(); // supportedElems are Supported natively within Etherpad and don't require a plugin const supportedElems = new Set([ 'author', @@ -56,131 +68,178 @@ const supportedElems = new Set([ 'ul', ]); -const makeContentCollector = (collectStyles, abrowser, apool, className2Author) => { - const _blockElems = { - div: 1, - p: 1, - pre: 1, - li: 1, - }; - - hooks.callAll('ccRegisterBlockElements').forEach((element) => { - _blockElems[element] = 1; - supportedElems.add(element); - }); - - const isBlockElement = (n) => !!_blockElems[tagName(n) || '']; - - const textify = (str) => sanitizeUnicode( - str.replace(/(\n | \n)/g, ' ') - .replace(/[\n\r ]/g, ' ') - .replace(/\xa0/g, ' ') - .replace(/\t/g, ' ')); - - const getAssoc = (node, name) => node[`_magicdom_${name}`]; - - const lines = (() => { - const textArray = []; - const attribsArray = []; - let attribsBuilder = null; - const op = new Changeset.Op('+'); - const self = { - length: () => textArray.length, - atColumnZero: () => textArray[textArray.length - 1] === '', - startNew: () => { - textArray.push(''); - self.flush(true); - attribsBuilder = Changeset.smartOpAssembler(); - }, - textOfLine: (i) => textArray[i], - appendText: (txt, attrString = '') => { - textArray[textArray.length - 1] += txt; - op.attribs = attrString; - op.chars = txt.length; - attribsBuilder.append(op); - }, - textLines: () => textArray.slice(), - attribLines: () => attribsArray, - // call flush only when you're done - flush: (withNewline) => { - if (attribsBuilder) { - attribsArray.push(attribsBuilder.toString()); - attribsBuilder = null; - } - }, - }; - self.startNew(); - return self; +type ContentElem = Element & { + name?: string +} + +class Lines { + private textArray: string[] = []; + private attribsArray: string[] = []; + private attribsBuilder:SmartOpAssembler|null = null; + private op = new Changeset.Op('+'); + + + length= () => this.textArray.length + atColumnZero = () => this.textArray[this.textArray.length - 1] === '' + startNew= () => { + this.textArray.push(''); + this.flush(true); + this.attribsBuilder = new SmartOpAssembler(); + } + textOfLine= (i: number) => this.textArray[i] + appendText= (txt: string, attrString = '') => { + this.textArray[this.textArray.length - 1] += txt; + this.op.attribs = attrString; + this.op.chars = txt.length; + this.attribsBuilder!.append(this.op); + } + textLines= () => this.textArray.slice() + attribLines= () => this.attribsArray + // call flush only when you're done + flush= (_withNewline?: boolean) => { + if (this.attribsBuilder) { + this.attribsArray.push(this.attribsBuilder.toString()); + this.attribsBuilder = null; +} +} +} + +type ContentCollectorState = { + author?:string + authorLevel?: number + listNesting?: number + lineAttributes: { + list?: string, + img?: string + start?: number + }, + start?: number + flags: MapArrayType, + attribs: MapArrayType + attribString: string + localAttribs: string[]|null, + unsupportedElements: Set +} + +type ContentCollectorPoint = { + index: number; + node: Node +} + +type ContentCollectorSel = { + startPoint: ContentCollectorPoint + endPoint: ContentCollectorPoint +} + + +class ContentCollector { + private blockElems: MapArrayType; + private cc = {}; + private selection?: ContentCollectorSel + private startPoint?: ContentCollectorPoint + private endPoint?: ContentCollectorPoint; + private selStart = [-1, -1]; + private selEnd = [-1, -1]; + private collectStyles: boolean; + private apool: AttributePool; + private className2Author: (c: string) => string; + private breakLine?: boolean + private abrowser?: null|BrowserDetector; + + constructor(collectStyles: boolean, abrowser: null, apool: AttributePool, className2Author: (c: string)=>string) { + this.blockElems = { + div: 1, + p: 1, + pre: 1, + li: 1, + } + this.abrowser = abrowser + this.collectStyles = collectStyles + this.apool = apool + this.className2Author = className2Author + + hooks.callAll('ccRegisterBlockElements').forEach((element: "div"|"p"|"pre"|"li") => { + this.blockElems[element] = 1; + supportedElems.add(element); + }) + + + } + isBlockElement = (n: Element) => !!this.blockElems[tagName(n) || '']; + textify = (str: string) => sanitizeUnicode( + str.replace(/(\n | \n)/g, ' ') + .replace(/[\n\r ]/g, ' ') + .replace(/\xa0/g, ' ') + .replace(/\t/g, ' ')) + getAssoc = (node: MapArrayType, name: string) => node[`_magicdom_${name}`]; + lines = (() => { + const line = new Lines() + line.startNew() + return line; })(); - const cc = {}; - - const _ensureColumnZero = (state) => { - if (!lines.atColumnZero()) { - cc.startNewLine(state); + private ensureColumnZero = (state: ContentCollectorState|null) => { + if (!this.lines.atColumnZero()) { + this.startNewLine(state); } - }; - let selection, startPoint, endPoint; - let selStart = [-1, -1]; - let selEnd = [-1, -1]; - const _isEmpty = (node, state) => { + } + private isEmpty = (node: Element, state?: ContentCollectorState) => { // consider clean blank lines pasted in IE to be empty if (node.childNodes.length === 0) return true; if (node.childNodes.length === 1 && - getAssoc(node, 'shouldBeEmpty') && - node.innerHTML === ' ' && - !getAssoc(node, 'unpasted')) { + // @ts-ignore + this.getAssoc(node, 'shouldBeEmpty') && + node.innerHTML === ' ' && + // @ts-ignore + !this.getAssoc(node, 'unpasted')) { if (state) { const child = node.childNodes[0]; - _reachPoint(child, 0, state); - _reachPoint(child, 1, state); + this.reachPoint(child, 0, state); + this.reachPoint(child, 1, state); } return true; } return false; - }; - - const _pointHere = (charsAfter, state) => { - const ln = lines.length() - 1; - let chr = lines.textOfLine(ln).length; + } + pointHere = (charsAfter: number, state: ContentCollectorState) => { + const ln = this.lines.length() - 1; + let chr = this.lines.textOfLine(ln).length; if (chr === 0 && Object.keys(state.lineAttributes).length !== 0) { chr += 1; // listMarker } chr += charsAfter; return [ln, chr]; - }; - - const _reachBlockPoint = (nd, idx, state) => { - if (nd.nodeType !== nd.TEXT_NODE) _reachPoint(nd, idx, state); - }; - - const _reachPoint = (nd, idx, state) => { - if (startPoint && nd === startPoint.node && startPoint.index === idx) { - selStart = _pointHere(0, state); + } + + reachBlockPoint = (nd: ContentElem, idx: number, state: ContentCollectorState) => { + if (nd.nodeType !== nd.TEXT_NODE) this.reachPoint(nd, idx, state); + } + reachPoint = (nd: Node, idx: number, state: ContentCollectorState) => { + if (this.startPoint && nd === this.startPoint.node && this.startPoint.index === idx) { + this.selStart = this.pointHere(0, state); } - if (endPoint && nd === endPoint.node && endPoint.index === idx) { - selEnd = _pointHere(0, state); + if (this.endPoint && nd === this.endPoint.node && this.endPoint.index === idx) { + this.selEnd = this.pointHere(0, state); } - }; - cc.incrementFlag = (state, flagName) => { + } + incrementFlag = (state: ContentCollectorState, flagName: string) => { state.flags[flagName] = (state.flags[flagName] || 0) + 1; - }; - cc.decrementFlag = (state, flagName) => { + } + decrementFlag = (state: ContentCollectorState, flagName: string) => { state.flags[flagName]--; - }; - cc.incrementAttrib = (state, attribName) => { + } + incrementAttrib = (state: ContentCollectorState, attribName: string) => { if (!state.attribs[attribName]) { state.attribs[attribName] = 1; } else { state.attribs[attribName]++; } - _recalcAttribString(state); - }; - cc.decrementAttrib = (state, attribName) => { + this.recalcAttribString(state); + } + decrementAttrib = (state: ContentCollectorState, attribName: string) => { state.attribs[attribName]--; - _recalcAttribString(state); - }; - - const _enterList = (state, listType) => { + this.recalcAttribString(state); + } + private enterList = (state: ContentCollectorState, listType?: string) => { if (!listType) return; const oldListType = state.lineAttributes.list; if (listType !== 'none') { @@ -196,13 +255,13 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) } else { state.lineAttributes.list = listType; } - _recalcAttribString(state); + this.recalcAttribString(state); return oldListType; - }; + } - const _exitList = (state, oldListType) => { + private exitList = (state: ContentCollectorState, oldListType: string) => { if (state.lineAttributes.list) { - state.listNesting--; + state.listNesting!--; } if (oldListType && oldListType !== 'none') { state.lineAttributes.list = oldListType; @@ -210,25 +269,22 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) delete state.lineAttributes.list; delete state.lineAttributes.start; } - _recalcAttribString(state); - }; - - const _enterAuthor = (state, author) => { + this.recalcAttribString(state); + } + private enterAuthor = (state: ContentCollectorState, author: string) => { const oldAuthor = state.author; state.authorLevel = (state.authorLevel || 0) + 1; state.author = author; - _recalcAttribString(state); + this.recalcAttribString(state); return oldAuthor; - }; - - const _exitAuthor = (state, oldAuthor) => { - state.authorLevel--; + } + private exitAuthor = (state: ContentCollectorState, oldAuthor: string) => { + state.authorLevel!--; state.author = oldAuthor; - _recalcAttribString(state); - }; - - const _recalcAttribString = (state) => { - const attribs = new AttributeMap(apool); + this.recalcAttribString(state); + } + private recalcAttribString = (state: ContentCollectorState) => { + const attribs = new AttributeMap(this.apool); for (const [a, count] of Object.entries(state.attribs)) { if (!count) continue; // The following splitting of the attribute name is a workaround @@ -253,49 +309,50 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) attribs.set(a, 'true'); } } - if (state.authorLevel > 0) { - if (apool.putAttrib(['author', state.author], true) >= 0) { + if (state.authorLevel! > 0) { + if (this.apool!.putAttrib(['author', state.author!], true) >= 0) { // require that author already be in pool // (don't add authors from other documents, etc.) if (state.author) attribs.set('author', state.author); } } state.attribString = attribs.toString(); - }; - - const _produceLineAttributesMarker = (state) => { + } + private produceLineAttributesMarker = (state: ContentCollectorState) => { // TODO: This has to go to AttributeManager. - const attribs = new AttributeMap(apool) - .set('lmkr', '1') - .set('insertorder', 'first') - // TODO: Converting all falsy values in state.lineAttributes into removals is awkward. - // Better would be to never add 0, false, null, or undefined to state.lineAttributes in the - // first place (I'm looking at you, state.lineAttributes.start). - .update(Object.entries(state.lineAttributes).map(([k, v]) => [k, v || '']), true); - lines.appendText('*', attribs.toString()); - }; - cc.startNewLine = (state) => { + const attribsF = Object.entries(state.lineAttributes).map(([k, v]) => [k, v || '']) as Attribute[] + const attribs = new AttributeMap(this.apool) + .set('lmkr', '1') + .set('insertorder', 'first') + // TODO: Converting all falsy values in state.lineAttributes into removals is awkward. + // Better would be to never add 0, false, null, or undefined to state.lineAttributes in the + // first place (I'm looking at you, state.lineAttributes.start). + .update(attribsF, true); + this.lines.appendText('*', attribs.toString()); + } + startNewLine = (state: ContentCollectorState|null) => { if (state) { - const atBeginningOfLine = lines.textOfLine(lines.length() - 1).length === 0; + const atBeginningOfLine = this.lines.textOfLine(this.lines.length() - 1).length === 0; if (atBeginningOfLine && Object.keys(state.lineAttributes).length !== 0) { - _produceLineAttributesMarker(state); + this.produceLineAttributesMarker(state); } } - lines.startNew(); - }; - cc.notifySelection = (sel) => { + this.lines.startNew(); + } + notifySelection = (sel: ContentCollectorSel) => { if (sel) { - selection = sel; - startPoint = selection.startPoint; - endPoint = selection.endPoint; + this.selection = sel; + this.startPoint = this.selection.startPoint; + this.endPoint = this.selection.endPoint; } - }; - cc.doAttrib = (state, na) => { + } + doAttrib = (state: ContentCollectorState, na: string) => { state.localAttribs = (state.localAttribs || []); state.localAttribs.push(na); - cc.incrementAttrib(state, na); - }; - cc.collectContent = function (node, state) { + this.incrementAttrib(state, na); + } + + collectContent = (node: ContentElem, state: ContentCollectorState)=> { let unsupportedElements = null; if (!state) { state = { @@ -318,33 +375,33 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) } const localAttribs = state.localAttribs; state.localAttribs = null; - const isBlock = isBlockElement(node); + const isBlock = this.isBlockElement(node); if (!isBlock && node.name && (node.name !== 'body')) { if (!supportedElems.has(node.name)) state.unsupportedElements.add(node.name); } - const isEmpty = _isEmpty(node, state); - if (isBlock) _ensureColumnZero(state); - const startLine = lines.length() - 1; - _reachBlockPoint(node, 0, state); + const isEmpty = this.isEmpty(node, state); + if (isBlock) this.ensureColumnZero(state); + const startLine = this.lines.length() - 1; + this.reachBlockPoint(node, 0, state); if (node.nodeType === node.TEXT_NODE) { - const tname = node.parentNode.getAttribute('name'); + const tname = (node.parentNode as Element)!.getAttribute('name'); const context = {cc: this, state, tname, node, text: node.nodeValue}; // Hook functions may either return a string (deprecated) or modify context.text. If any hook // function modifies context.text then all returned strings are ignored. If no hook functions // modify context.text, the first hook function to return a string wins. const [hookTxt] = - hooks.callAll('collectContentLineText', context).filter((s) => typeof s === 'string'); + hooks.callAll('collectContentLineText', context).filter((s: string|object) => typeof s === 'string'); let txt = context.text === node.nodeValue && hookTxt != null ? hookTxt : context.text; let rest = ''; let x = 0; // offset into original text if (txt.length === 0) { - if (startPoint && node === startPoint.node) { - selStart = _pointHere(0, state); + if (this.startPoint && node === this.startPoint.node) { + this.selStart = this.pointHere(0, state); } - if (endPoint && node === endPoint.node) { - selEnd = _pointHere(0, state); + if (this.endPoint && node === this.endPoint.node) { + this.selEnd = this.pointHere(0, state); } } while (txt.length > 0) { @@ -356,11 +413,11 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) txt = firstLine; } else { /* will only run this loop body once */ } - if (startPoint && node === startPoint.node && startPoint.index - x <= txt.length) { - selStart = _pointHere(startPoint.index - x, state); + if (this.startPoint && node === this.startPoint.node && this.startPoint.index - x <= txt.length) { + this.selStart = this.pointHere(this.startPoint.index - x, state); } - if (endPoint && node === endPoint.node && endPoint.index - x <= txt.length) { - selEnd = _pointHere(endPoint.index - x, state); + if (this.endPoint && node === this.endPoint.node && this.endPoint.index - x <= txt.length) { + this.selEnd = this.pointHere(this.endPoint.index - x, state); } let txt2 = txt; if ((!state.flags.preMode) && /^[\r\n]*$/.exec(txt)) { @@ -370,27 +427,27 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) // removing "\n" from pasted HTML will collapse words together. txt2 = ''; } - const atBeginningOfLine = lines.textOfLine(lines.length() - 1).length === 0; + const atBeginningOfLine = this.lines.textOfLine(this.lines.length() - 1).length === 0; if (atBeginningOfLine) { // newlines in the source mustn't become spaces at beginning of line box txt2 = txt2.replace(/^\n*/, ''); } if (atBeginningOfLine && Object.keys(state.lineAttributes).length !== 0) { - _produceLineAttributesMarker(state); + this.produceLineAttributesMarker(state); } - lines.appendText(textify(txt2), state.attribString); + this.lines.appendText(this.textify(txt2), state.attribString); x += consumed; txt = rest; if (txt.length > 0) { - cc.startNewLine(state); + this.startNewLine(state); } } } else if (node.nodeType === node.ELEMENT_NODE) { - const tname = tagName(node) || ''; + const tname = tagName(node as Element) || ''; if (tname === 'img') { hooks.callAll('collectContentImage', { - cc, + cc: this, state, tname, styl: null, @@ -414,18 +471,18 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) cls: null, }); if (startNewLine) { - cc.startNewLine(state); + this.startNewLine(state); } } else if (tname === 'script' || tname === 'style') { // ignore } else if (!isEmpty) { let styl = node.getAttribute('style'); let cls = node.getAttribute('class'); - let isPre = (tname === 'pre'); - if ((!isPre) && abrowser && abrowser.safari) { - isPre = (styl && /\bwhite-space:\s*pre\b/i.exec(styl)); + let isPre: boolean| RegExpExecArray|"" = (tname === 'pre'); + if ((!isPre) && this.abrowser && this.abrowser.safari) { + isPre = (styl && /\bwhite-space:\s*pre\b/i.exec(styl))!; } - if (isPre) cc.incrementFlag(state, 'preMode'); + if (isPre) this.incrementFlag(state, 'preMode'); let oldListTypeOrNull = null; let oldAuthorOrNull = null; @@ -438,33 +495,33 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) // for now it shows how to fix the problem return; } - if (collectStyles) { + if (this.collectStyles) { hooks.callAll('collectContentPre', { - cc, + cc: this, state, tname, styl, cls, }); if (tname === 'b' || - (styl && /\bfont-weight:\s*bold\b/i.exec(styl)) || - tname === 'strong') { - cc.doAttrib(state, 'bold'); + (styl && /\bfont-weight:\s*bold\b/i.exec(styl)) || + tname === 'strong') { + this.doAttrib(state, 'bold'); } if (tname === 'i' || - (styl && /\bfont-style:\s*italic\b/i.exec(styl)) || - tname === 'em') { - cc.doAttrib(state, 'italic'); + (styl && /\bfont-style:\s*italic\b/i.exec(styl)) || + tname === 'em') { + this.doAttrib(state, 'italic'); } if (tname === 'u' || - (styl && /\btext-decoration:\s*underline\b/i.exec(styl)) || - tname === 'ins') { - cc.doAttrib(state, 'underline'); + (styl && /\btext-decoration:\s*underline\b/i.exec(styl)) || + tname === 'ins') { + this.doAttrib(state, 'underline'); } if (tname === 's' || - (styl && /\btext-decoration:\s*line-through\b/i.exec(styl)) || - tname === 'del') { - cc.doAttrib(state, 'strikethrough'); + (styl && /\btext-decoration:\s*line-through\b/i.exec(styl)) || + tname === 'del') { + this.doAttrib(state, 'strikethrough'); } if (tname === 'ul' || tname === 'ol') { let type = node.getAttribute('class'); @@ -473,8 +530,8 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) // check if we find a better hint within the node's children if (!rr && !type) { for (const child of node.childNodes) { - if (tagName(child) !== 'ul') continue; - type = child.getAttribute('class'); + if (tagName(child as ContentElem) !== 'ul') continue; + type = (child as ContentElem).getAttribute('class'); if (type) break; } } @@ -493,22 +550,24 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) } type += String(Math.min(_MAX_LIST_LEVEL, (state.listNesting || 0) + 1)); } - oldListTypeOrNull = (_enterList(state, type) || 'none'); + oldListTypeOrNull = (this.enterList(state, type) || 'none'); } else if ((tname === 'div' || tname === 'p') && cls && cls.match(/(?:^| )ace-line\b/)) { // This has undesirable behavior in Chrome but is right in other browsers. // See https://github.com/ether/etherpad-lite/issues/2412 for reasoning - if (!abrowser.chrome) oldListTypeOrNull = (_enterList(state, undefined) || 'none'); + if (!this.abrowser!.chrome) { + oldListTypeOrNull = (this.enterList(state, undefined) || 'none'); + } } else if (tname === 'li') { state.lineAttributes.start = state.start || 0; - _recalcAttribString(state); - if (state.lineAttributes.list.indexOf('number') !== -1) { + this.recalcAttribString(state); + if (state.lineAttributes.list!.indexOf('number') !== -1) { /* Nested OLs are not -->
  1. 1
    1. nested
They are -->
  1. 1
    1. nested
Note how the
    item has to be inside a
  1. Because of this we don't increment the start number */ - if (node.parentNode && tagName(node.parentNode) !== 'ol') { + if (node.parentNode && tagName(node.parentNode as Element) !== 'ol') { /* TODO: start number has to increment based on indentLevel(numberX) This means we have to build an object IE @@ -521,12 +580,12 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) with exports? We can.. But let's leave this comment in because it might be useful in the future.. */ - state.start++; // not if it's parent is an OL or UL. + state.start!++; // not if it's parent is an OL or UL. } } // UL list items never modify the start value. - if (node.parentNode && tagName(node.parentNode) === 'ul') { - state.start++; + if (node.parentNode && tagName(node.parentNode as Element) === 'ul') { + state.start!++; // TODO, this is hacky. // Because if the first item is an UL it will increment a list no? // A much more graceful way would be to say, ul increases if it's within an OL @@ -539,14 +598,14 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) // delete state.listNesting; // _recalcAttribString(state); } - if (className2Author && cls) { + if (this.className2Author && cls) { const classes = cls.match(/\S+/g); if (classes && classes.length > 0) { for (let i = 0; i < classes.length; i++) { const c = classes[i]; - const a = className2Author(c); + const a = this.className2Author(c); if (a) { - oldAuthorOrNull = (_enterAuthor(state, a) || 'none'); + oldAuthorOrNull = (this.enterAuthor(state, a) || 'none'); break; } } @@ -555,12 +614,12 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) } for (const c of node.childNodes) { - cc.collectContent(c, state); + this.collectContent(c as ContentElem, state); } - if (collectStyles) { + if (this.collectStyles) { hooks.callAll('collectContentPost', { - cc, + cc: this, state, tname, styl, @@ -568,23 +627,23 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) }); } - if (isPre) cc.decrementFlag(state, 'preMode'); + if (isPre) this.decrementFlag(state, 'preMode'); if (state.localAttribs) { for (let i = 0; i < state.localAttribs.length; i++) { - cc.decrementAttrib(state, state.localAttribs[i]); + this.decrementAttrib(state, state.localAttribs[i]); } } if (oldListTypeOrNull) { - _exitList(state, oldListTypeOrNull); + this.exitList(state, oldListTypeOrNull); } if (oldAuthorOrNull) { - _exitAuthor(state, oldAuthorOrNull); + this.exitAuthor(state, oldAuthorOrNull); } } } - _reachBlockPoint(node, 1, state); + this.reachBlockPoint(node, 1, state); if (isBlock) { - if (lines.length() - 1 === startLine) { + if (this.lines.length() - 1 === startLine) { // added additional check to resolve https://github.com/JohnMcLear/ep_copy_paste_images/issues/20 // this does mean that images etc can't be pasted on lists but imho that's fine @@ -592,48 +651,50 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) // Export events don't have window available. // commented out to solve #2412 - https://github.com/ether/etherpad-lite/issues/2412 if ((state.lineAttributes && !state.lineAttributes.list) || typeof window === 'undefined') { - cc.startNewLine(state); + this.startNewLine(state); } } else { - _ensureColumnZero(state); + this.ensureColumnZero(state); } } state.localAttribs = localAttribs; if (unsupportedElements && unsupportedElements.size) { console.warn('Ignoring unsupported elements (you might want to install a plugin): ' + - `${[...unsupportedElements].join(', ')}`); + `${[...unsupportedElements].join(', ')}`); } - }; + } // can pass a falsy value for end of doc - cc.notifyNextNode = (node) => { + notifyNextNode = (node: ContentElem) => { // an "empty block" won't end a line; this addresses an issue in IE with // typing into a blank line at the end of the document. typed text // goes into the body, and the empty line div still looks clean. // it is incorporated as dirty by the rule that a dirty region has // to end a line. - if ((!node) || (isBlockElement(node) && !_isEmpty(node))) { - _ensureColumnZero(null); + if ((!node) || (this.isBlockElement(node) && !this.isEmpty(node))) { + this.ensureColumnZero(null); } - }; + } + // each returns [line, char] or [-1,-1] - const getSelectionStart = () => selStart; - const getSelectionEnd = () => selEnd; + getSelectionStart = () => this.selStart; + getSelectionEnd = () => this.selEnd; + // returns array of strings for lines found, last entry will be "" if // last line is complete (i.e. if a following span should be on a new line). // can be called at any point - cc.getLines = () => lines.textLines(); + getLines = () => this.lines.textLines(); - cc.finish = () => { - lines.flush(); - const lineAttribs = lines.attribLines(); - const lineStrings = cc.getLines(); + finish = () => { + this.lines.flush(); + const lineAttribs = this.lines.attribLines(); + const lineStrings = this.getLines(); lineStrings.length--; lineAttribs.length--; - const ss = getSelectionStart(); - const se = getSelectionEnd(); + const ss = this.getSelectionStart(); + const se = this.getSelectionEnd(); const fixLongLines = () => { // design mode does not deal with with really long lines! @@ -645,7 +706,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) let oldString = lineStrings[i]; let oldAttribString = lineAttribs[i]; if (oldString.length > lineLimit + buffer) { - const newStrings = []; + const newStrings: string[] = []; const newAttribStrings = []; while (oldString.length > lineLimit) { // var semiloc = oldString.lastIndexOf(';', lineLimit-1); @@ -661,7 +722,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) newAttribStrings.push(oldAttribString); } - const fixLineNumber = (lineChar) => { + const fixLineNumber = (lineChar: number[]) => { if (lineChar[0] < 0) return; let n = lineChar[0]; let c = lineChar[1]; @@ -701,11 +762,5 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) lines: lineStrings, lineAttribs, }; - }; - - return cc; -}; - -exports.sanitizeUnicode = sanitizeUnicode; -exports.makeContentCollector = makeContentCollector; -exports.supportedElems = supportedElems; + } +} diff --git a/src/static/js/domline.js b/src/static/js/domline.js deleted file mode 100644 index 5c3dfcbc478..00000000000 --- a/src/static/js/domline.js +++ /dev/null @@ -1,279 +0,0 @@ -'use strict'; - -// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.domline -// %APPJET%: import("etherpad.admin.plugins"); -/** - * Copyright 2009 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS-IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// requires: top -// requires: plugins -// requires: undefined - -const Security = require('security'); -const hooks = require('./pluginfw/hooks'); -const _ = require('underscore'); -import {lineAttributeMarker} from "./linestylefilter"; - -const noop = () => {}; - - -const domline = {}; - -domline.addToLineClass = (lineClass, cls) => { - // an "empty span" at any point can be used to add classes to - // the line, using line:className. otherwise, we ignore - // the span. - cls.replace(/\S+/g, (c) => { - if (c.indexOf('line:') === 0) { - // add class to line - lineClass = (lineClass ? `${lineClass} ` : '') + c.substring(5); - } - }); - return lineClass; -}; - -// if "document" is falsy we don't create a DOM node, just -// an object with innerHTML and className -domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => { - const result = { - node: null, - appendSpan: noop, - prepareForAdd: noop, - notifyAdded: noop, - clearSpans: noop, - finishUpdate: noop, - lineMarker: 0, - }; - - const document = optDocument; - - if (document) { - result.node = document.createElement('div'); - // JAWS and NVDA screen reader compatibility. Only needed if in a real browser. - result.node.setAttribute('aria-live', 'assertive'); - } else { - result.node = { - innerHTML: '', - className: '', - }; - } - - let html = []; - let preHtml = ''; - let postHtml = ''; - let curHTML = null; - - const processSpaces = (s) => domline.processSpaces(s, doesWrap); - const perTextNodeProcess = (doesWrap ? _.identity : processSpaces); - const perHtmlLineProcess = (doesWrap ? processSpaces : _.identity); - let lineClass = 'ace-line'; - - result.appendSpan = (txt, cls) => { - let processedMarker = false; - // Handle lineAttributeMarker, if present - if (cls.indexOf(lineAttributeMarker) >= 0) { - let listType = /(?:^| )list:(\S+)/.exec(cls); - const start = /(?:^| )start:(\S+)/.exec(cls); - - _.map(hooks.callAll('aceDomLinePreProcessLineAttributes', { - domline, - cls, - }), (modifier) => { - preHtml += modifier.preHtml; - postHtml += modifier.postHtml; - processedMarker |= modifier.processedMarker; - }); - if (listType) { - listType = listType[1]; - if (listType) { - if (listType.indexOf('number') < 0) { - preHtml += `
    • `; - postHtml = `
    ${postHtml}`; - } else { - if (start) { // is it a start of a list with more than one item in? - if (Number.parseInt(start[1]) === 1) { // if its the first one at this level? - // Add start class to DIV node - lineClass = `${lineClass} ` + `list-start-${listType}`; - } - preHtml += - `
    1. `; - } else { - // Handles pasted contents into existing lists - preHtml += `
      1. `; - } - postHtml += '
      '; - } - } - processedMarker = true; - } - _.map(hooks.callAll('aceDomLineProcessLineAttributes', { - domline, - cls, - }), (modifier) => { - preHtml += modifier.preHtml; - postHtml += modifier.postHtml; - processedMarker |= modifier.processedMarker; - }); - if (processedMarker) { - result.lineMarker += txt.length; - return; // don't append any text - } - } - let href = null; - let simpleTags = null; - if (cls.indexOf('url') >= 0) { - cls = cls.replace(/(^| )url:(\S+)/g, (x0, space, url) => { - href = url; - return `${space}url`; - }); - } - if (cls.indexOf('tag') >= 0) { - cls = cls.replace(/(^| )tag:(\S+)/g, (x0, space, tag) => { - if (!simpleTags) simpleTags = []; - simpleTags.push(tag.toLowerCase()); - return space + tag; - }); - } - - let extraOpenTags = ''; - let extraCloseTags = ''; - - _.map(hooks.callAll('aceCreateDomLine', { - domline, - cls, - }), (modifier) => { - cls = modifier.cls; - extraOpenTags += modifier.extraOpenTags; - extraCloseTags = modifier.extraCloseTags + extraCloseTags; - }); - - if ((!txt) && cls) { - lineClass = domline.addToLineClass(lineClass, cls); - } else if (txt) { - if (href) { - const urn_schemes = new RegExp('^(about|geo|mailto|tel):'); - // if the url doesn't include a protocol prefix, assume http - if (!~href.indexOf('://') && !urn_schemes.test(href)) { - href = `http://${href}`; - } - // Using rel="noreferrer" stops leaking the URL/location of the pad when - // clicking links in the document. - // Not all browsers understand this attribute, but it's part of the HTML5 standard. - // https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer - // Additionally, we do rel="noopener" to ensure a higher level of referrer security. - // https://html.spec.whatwg.org/multipage/links.html#link-type-noopener - // https://mathiasbynens.github.io/rel-noopener/ - // https://github.com/ether/etherpad-lite/pull/3636 - const escapedHref = Security.escapeHTMLAttribute(href); - extraOpenTags = `${extraOpenTags}`; - extraCloseTags = `${extraCloseTags}`; - } - if (simpleTags) { - simpleTags.sort(); - extraOpenTags = `${extraOpenTags}<${simpleTags.join('><')}>`; - simpleTags.reverse(); - extraCloseTags = `${extraCloseTags}`; - } - html.push( - '', - extraOpenTags, - perTextNodeProcess(Security.escapeHTML(txt)), - extraCloseTags, - ''); - } - }; - result.clearSpans = () => { - html = []; - lineClass = 'ace-line'; - result.lineMarker = 0; - }; - - const writeHTML = () => { - let newHTML = perHtmlLineProcess(html.join('')); - if (!newHTML) { - if ((!document) || (!optBrowser)) { - newHTML += ' '; - } else { - newHTML += '
      '; - } - } - if (nonEmpty) { - newHTML = (preHtml || '') + newHTML + (postHtml || ''); - } - html = preHtml = postHtml = ''; // free memory - if (newHTML !== curHTML) { - curHTML = newHTML; - result.node.innerHTML = curHTML; - } - if (lineClass != null) result.node.className = lineClass; - - hooks.callAll('acePostWriteDomLineHTML', { - node: result.node, - }); - }; - result.prepareForAdd = writeHTML; - result.finishUpdate = writeHTML; - return result; -}; - -domline.processSpaces = (s, doesWrap) => { - if (s.indexOf('<') < 0 && !doesWrap) { - // short-cut - return s.replace(/ /g, ' '); - } - const parts = []; - s.replace(/<[^>]*>?| |[^ <]+/g, (m) => { - parts.push(m); - }); - if (doesWrap) { - let endOfLine = true; - let beforeSpace = false; - // last space in a run is normal, others are nbsp, - // end of line is nbsp - for (let i = parts.length - 1; i >= 0; i--) { - const p = parts[i]; - if (p === ' ') { - if (endOfLine || beforeSpace) parts[i] = ' '; - endOfLine = false; - beforeSpace = true; - } else if (p.charAt(0) !== '<') { - endOfLine = false; - beforeSpace = false; - } - } - // beginning of line is nbsp - for (let i = 0; i < parts.length; i++) { - const p = parts[i]; - if (p === ' ') { - parts[i] = ' '; - break; - } else if (p.charAt(0) !== '<') { - break; - } - } - } else { - for (let i = 0; i < parts.length; i++) { - const p = parts[i]; - if (p === ' ') { - parts[i] = ' '; - } - } - } - return parts.join(''); -}; - -exports.domline = domline; diff --git a/src/static/js/domline.ts b/src/static/js/domline.ts new file mode 100644 index 00000000000..083632fb423 --- /dev/null +++ b/src/static/js/domline.ts @@ -0,0 +1,299 @@ +'use strict'; + +// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.domline +// %APPJET%: import("etherpad.admin.plugins"); + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// requires: top +// requires: plugins +// requires: undefined + +const Security = require('security'); +const hooks = require('./pluginfw/hooks'); +const _ = require('underscore'); +import {lineAttributeMarker} from "./linestylefilter"; + +const noop = () => {}; + + + + +class Domline { + private node?: HTMLElement| { + innerHTML: '', + className: '', + } + html:string[] = []; + preHtml = ''; + postHtml = ''; + curHTML: string|null = null; + private lineMarker: number + private readonly doesWrap: boolean; + private optBrowser: string | undefined; + private optDocument: Document | undefined; + private lineClass = 'ace-line'; + private nonEmpty: boolean; + + constructor(nonEmpty: boolean, doesWrap: boolean, optBrowser?: string, optDocument?: Document) { + this.lineMarker = 0 + this.doesWrap = doesWrap + this.nonEmpty = nonEmpty + this.optBrowser = optBrowser + this.optDocument = optDocument + } + addToLineClass = (lineClass: string, cls: string) => { + // an "empty span" at any point can be used to add classes to + // the line, using line:className. otherwise, we ignore + // the span. + cls.replace(/\S+/g, (c) => { + if (c.indexOf('line:') === 0) { + // add class to line + lineClass = (lineClass ? `${lineClass} ` : '') + c.substring(5); + return lineClass + } + return c + }); + return lineClass; + } + + ProcessSpaces = (s: string) => this.processSpaces(s, this.doesWrap); + perTextNodeProcess = (s: string):string=>{ + if (this.doesWrap){ + return _.identity() + } else { + return this.processSpaces(s) + } + } + perHtmlLineProcess = (s:string)=>{ + if (this.doesWrap) { + return this.processSpaces(s) + } else { + return _.identity() + } + } + + appendSpan = (txt: string, cls: string) => { + let processedMarker = false; + // Handle lineAttributeMarker, if present + if (cls.indexOf(lineAttributeMarker) >= 0) { + let listType = /(?:^| )list:(\S+)/.exec(cls); + const start = /(?:^| )start:(\S+)/.exec(cls); + + _.map(hooks.callAll('aceDomLinePreProcessLineAttributes', { + domline: this, + cls, + }), (modifier: { preHtml: any; postHtml: any; processedMarker: boolean; }) => { + this.preHtml += modifier.preHtml; + this.postHtml += modifier.postHtml; + processedMarker ||= modifier.processedMarker; + }); + if (listType) { + let listTypeExtracted = listType[1]; + if (listTypeExtracted) { + if (listTypeExtracted.indexOf('number') < 0) { + this.preHtml += `
      • `; + this.postHtml = `
      ${this.postHtml}`; + } else { + if (start) { // is it a start of a list with more than one item in? + if (Number.parseInt(start[1]) === 1) { // if its the first one at this level? + // Add start class to DIV node + this.lineClass = `${this.lineClass} ` + `list-start-${listTypeExtracted}`; + } + this.preHtml += + `
      1. `; + } else { + // Handles pasted contents into existing lists + this.preHtml += `
        1. `; + } + this.postHtml += '
        '; + } + } + processedMarker = true; + } + _.map(hooks.callAll('aceDomLineProcessLineAttributes', { + domline: this, + cls, + }), (modifier: { preHtml: string; postHtml: string; processedMarker: boolean; }) => { + this.preHtml += modifier.preHtml; + this.postHtml += modifier.postHtml; + processedMarker ||= modifier.processedMarker; + }); + if (processedMarker) { + this.lineMarker += txt.length; + return; // don't append any text + } + } + let href: null|string = null; + let simpleTags: null|string[] = null; + if (cls.indexOf('url') >= 0) { + cls = cls.replace(/(^| )url:(\S+)/g, (x0, space, url: string) => { + href = url; + return `${space}url`; + }); + } + if (cls.indexOf('tag') >= 0) { + cls = cls.replace(/(^| )tag:(\S+)/g, (x0, space, tag) => { + if (!simpleTags) simpleTags = []; + simpleTags.push(tag.toLowerCase()); + return space + tag; + }); + } + + let extraOpenTags = ''; + let extraCloseTags = ''; + + _.map(hooks.callAll('aceCreateDomLine', { + domline: this, + cls, + }), (modifier: { cls: string; extraOpenTags: string; extraCloseTags: string; }) => { + cls = modifier.cls; + extraOpenTags += modifier.extraOpenTags; + extraCloseTags = modifier.extraCloseTags + extraCloseTags; + }); + + if ((!txt) && cls) { + this.lineClass = this.addToLineClass(this.lineClass, cls); + } else if (txt) { + if (href) { + const urn_schemes = new RegExp('^(about|geo|mailto|tel):'); + // if the url doesn't include a protocol prefix, assume http + // @ts-ignore + if (!~href.indexOf('://') && !urn_schemes.test(href)) { + href = `http://${href}`; + } + // Using rel="noreferrer" stops leaking the URL/location of the pad when + // clicking links in the document. + // Not all browsers understand this attribute, but it's part of the HTML5 standard. + // https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer + // Additionally, we do rel="noopener" to ensure a higher level of referrer security. + // https://html.spec.whatwg.org/multipage/links.html#link-type-noopener + // https://mathiasbynens.github.io/rel-noopener/ + // https://github.com/ether/etherpad-lite/pull/3636 + const escapedHref = Security.escapeHTMLAttribute(href); + extraOpenTags = `${extraOpenTags}`; + extraCloseTags = `${extraCloseTags}`; + } + if (simpleTags) { + // @ts-ignore + simpleTags.sort(); + // @ts-ignore + extraOpenTags = `${extraOpenTags}<${simpleTags.join('><')}>`; + // @ts-ignore + simpleTags.reverse(); + // @ts-ignore + extraCloseTags = `${extraCloseTags}`; + } + this.html.push( + '', + extraOpenTags, + this.perTextNodeProcess(Security.escapeHTML(txt)), + extraCloseTags, + ''); + } + } + + writeHTML = () => { + let newHTML = this.perHtmlLineProcess(this.html.join('')); + if (!newHTML) { + if ((!document) || (!this.optBrowser)) { + newHTML += ' '; + } else { + newHTML += '
        '; + } + } + if (this.nonEmpty) { + newHTML = (this.preHtml || '') + newHTML + (this.postHtml || ''); + } + this.html! = [] + this.preHtml = this.postHtml = ''; // free memory + if (newHTML !== this.curHTML) { + this.curHTML = newHTML; + this.node!.innerHTML! = this.curHTML as string; + } + if (this.lineClass != null) this.node!.className = this.lineClass; + + hooks.callAll('acePostWriteDomLineHTML', { + node: this.node, + }); + }; + + clearSpans = () => { + this.html = []; + this.lineClass = 'ace-line'; + this.lineMarker = 0; + } + + prepareForAdd = this.writeHTML + finishUpdate = this.writeHTML + + private processSpaces = (s: string, doesWrap?: boolean) => { + if (s.indexOf('<') < 0 && !doesWrap) { + // short-cut + return s.replace(/ /g, ' '); + } + const parts = []; + s.replace(/<[^>]*>?| |[^ <]+/g, (m) => { + parts.push(m); + return m + }); + if (doesWrap) { + let endOfLine = true; + let beforeSpace = false; + // last space in a run is normal, others are nbsp, + // end of line is nbsp + for (let i = parts.length - 1; i >= 0; i--) { + const p = parts[i]; + if (p === ' ') { + if (endOfLine || beforeSpace) parts[i] = ' '; + endOfLine = false; + beforeSpace = true; + } else if (p.charAt(0) !== '<') { + endOfLine = false; + beforeSpace = false; + } + } + // beginning of line is nbsp + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + if (p === ' ') { + parts[i] = ' '; + break; + } else if (p.charAt(0) !== '<') { + break; + } + } + } else { + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + if (p === ' ') { + parts[i] = ' '; + } + } + } + return parts.join(''); + } +} + + + + +// if "document" is falsy we don't create a DOM node, just +// an object with innerHTML and className + +export default Domline diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index 8f1c7af7b1b..2d079fded8f 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -24,7 +24,7 @@ import {Socket} from "socket.io"; * limitations under the License. */ -let socket: null | Socket; +let socket: null | any; // These jQuery things should create local references, but for now `require()` @@ -37,12 +37,12 @@ import html10n from './vendors/html10n' const Cookies = require('./pad_utils').Cookies; const chat = require('./chat').chat; -const getCollabClient = require('./collab_client').getCollabClient; +import Collab_client, {CollabClient} from './collab_client' const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus; import padcookie from "./pad_cookie"; const padeditbar = require('./pad_editbar').padeditbar; -const padeditor = require('./pad_editor').padeditor; +import {padEditor as padeditor} from './pad_editor' const padimpexp = require('./pad_impexp').padimpexp; const padmodals = require('./pad_modals').padmodals; const padsavedrevs = require('./pad_savedrevs'); @@ -52,8 +52,9 @@ import {padUtils as padutils} from "./pad_utils"; const colorutils = require('./colorutils').colorutils; const randomString = require('./pad_utils').randomString; import connect from './socketio' -import {SocketClientReadyMessage} from "./types/SocketIOMessage"; +import {ClientSendMessages, ClientVarData, ClientVarMessage, HistoricalAuthorData, PadOption, SocketClientReadyMessage, SocketIOMessage, UserInfo} from "./types/SocketIOMessage"; import {MapArrayType} from "../../node/types/MapType"; +import {ChangeSetLoader} from "./timeslider"; const hooks = require('./pluginfw/hooks'); @@ -84,7 +85,7 @@ const getParameters = [ checkVal: null, callback: (val: any) => { if (val === 'false') { - settings.hideChat = true; + pad.settings.hideChat = true; chat.hide(); $('#chaticon').hide(); } @@ -93,58 +94,59 @@ const getParameters = [ { name: 'showLineNumbers', checkVal: 'false', - callback: (val) => { - settings.LineNumbersDisabled = true; + callback: (val: any) => { + pad.settings.LineNumbersDisabled = true; }, }, { name: 'useMonospaceFont', checkVal: 'true', - callback: (val) => { - settings.useMonospaceFontGlobal = true; + callback: (val: any) => { + pad.settings.useMonospaceFontGlobal = true; }, }, { name: 'userName', checkVal: null, - callback: (val) => { - settings.globalUserName = val; + callback: (val: string) => { + pad.settings.globalUserName = val; window.clientVars.userName = val; }, }, { name: 'userColor', checkVal: null, - callback: (val) => { - settings.globalUserColor = val; + callback: (val: number) => { + // @ts-ignore + pad.settings.globalUserColor = val; window.clientVars.userColor = val; }, }, { name: 'rtl', checkVal: 'true', - callback: (val) => { - settings.rtlIsTrue = true; + callback: (val: any) => { + pad.settings.rtlIsTrue = true; }, }, { name: 'alwaysShowChat', checkVal: 'true', - callback: (val) => { - if (!settings.hideChat) chat.stickToScreen(); + callback: (val: any) => { + if (!pad.settings.hideChat) chat.stickToScreen(); }, }, { name: 'chatAndUsers', checkVal: 'true', - callback: (val) => { + callback: (val: any) => { chat.chatAndUsers(); }, }, { name: 'lang', checkVal: null, - callback: (val) => { + callback: (val: any) => { console.log('Val is', val) html10n.localize([val, 'en']); Cookies.set('language', val); @@ -155,6 +157,7 @@ const getParameters = [ const getParams = () => { // Tries server enforced options first.. for (const setting of getParameters) { + // @ts-ignore let value = window.clientVars.padOptions[setting.name]; if (value == null) continue; value = value.toString(); @@ -213,7 +216,7 @@ const sendClientReady = (isReconnect: boolean) => { // this is a reconnect, lets tell the server our revisionnumber if (isReconnect) { - msg.client_rev = this.collabClient!.getCurrentRevisionNumber(); + msg.client_rev = pad.collabClient!.getCurrentRevisionNumber(); msg.reconnect = true; } @@ -257,7 +260,7 @@ const handshake = async () => { } }; - socket.on('disconnect', (reason) => { + socket.on('disconnect', (reason: string) => { // The socket.io client will automatically try to reconnect for all reasons other than "io // server disconnect". console.log(`Socket disconnected: ${reason}`) @@ -266,15 +269,18 @@ const handshake = async () => { }); - socket.on('shout', (obj) => { + socket.on('shout', (obj: ClientVarMessage) => { if (obj.type === "COLLABROOM") { + // @ts-ignore let date = new Date(obj.data.payload.timestamp); - $.gritter.add({ + window.$.gritter.add({ // (string | mandatory) the heading of the notification title: 'Admin message', // (string | mandatory) the text inside the notification + // @ts-ignore text: '[' + date.toLocaleTimeString() + ']: ' + obj.data.payload.message.message, // (bool | optional) if you want it to fade out on its own or just sit there + // @ts-ignore sticky: obj.data.payload.message.sticky }); } @@ -282,7 +288,7 @@ const handshake = async () => { socket.io.on('reconnect_attempt', socketReconnecting); - socket.io.on('reconnect_failed', (error) => { + socket.io.on('reconnect_failed', (error: string) => { // pad.collabClient might be null if the hanshake failed (or it never got that far). if (pad.collabClient != null) { pad.collabClient.setChannelState('DISCONNECTED', 'reconnect_timeout'); @@ -292,7 +298,7 @@ const handshake = async () => { }); - socket.on('error', (error) => { + socket.on('error', (error: string) => { // pad.collabClient might be null if the error occurred before the hanshake completed. if (pad.collabClient != null) { pad.collabClient.setStateIdle(); @@ -303,9 +309,9 @@ const handshake = async () => { // just annoys users and fills logs. }); - socket.on('message', (obj) => { + socket.on('message', (obj: ClientVarMessage) => { // the access was not granted, give the user a message - if (obj.accessStatus) { + if ("accessStatus" in obj) { if (obj.accessStatus === 'deny') { $('#loading').hide(); $('#permissionDenied').show(); @@ -334,7 +340,7 @@ const handshake = async () => { }) } - } else if (obj.disconnect) { + } else if ("disconnect" in obj && obj.disconnect) { padconnectionstatus.disconnected(obj.disconnect); socket.disconnect(); @@ -350,9 +356,9 @@ const handshake = async () => { }); await Promise.all([ - new Promise((resolve) => { - const h = (obj) => { - if (obj.accessStatus || obj.type !== 'CLIENT_VARS') return; + new Promise((resolve) => { + const h = (obj: ClientVarData) => { + if ("accessStatus" in obj || obj.type !== 'CLIENT_VARS') return; socket.off('message', h); resolve(); }; @@ -368,39 +374,45 @@ const handshake = async () => { /** Defers message handling until setCollabClient() is called with a non-null value. */ class MessageQueue { + private _q: ClientVarMessage[] + private _cc: Collab_client | null constructor() { this._q = []; this._cc = null; } - setCollabClient(cc) { + setCollabClient(cc: Collab_client) { this._cc = cc; this.enqueue(); // Flush. } - enqueue(...msgs) { + enqueue(...msgs: ClientVarMessage[]) { if (this._cc == null) { this._q.push(...msgs); } else { - while (this._q.length > 0) this._cc.handleMessageFromServer(this._q.shift()); + while (this._q.length > 0) this._cc.handleMessageFromServer(this._q.shift()!); for (const msg of msgs) this._cc.handleMessageFromServer(msg); } } } export class Pad { - private collabClient: null; - private myUserInfo: null | { - userId: string, - name: string, - ip: string, - colorId: string, + public collabClient: null| CollabClient; + private myUserInfo: null | UserInfo &{ + globalUserColor?: string| boolean + name?: string + ip?: string + }; + private diagnosticInfo: { + disconnectedMessage?: string + padId?: string + socket?: MapArrayType, + collabDiagnosticInfo?: any }; - private diagnosticInfo: {}; private initTime: number; private clientTimeOffset: null | number; - private _messageQ: MessageQueue; - private padOptions: MapArrayType>; + _messageQ: MessageQueue; + private padOptions: PadOption; settings: PadSettings = { LineNumbersDisabled: false, noColors: false, @@ -409,6 +421,7 @@ export class Pad { globalUserColor: false, rtlIsTrue: false, } + socket: any; constructor() { // don't access these directly from outside this file, except @@ -430,8 +443,8 @@ export class Pad { getUserId = () => this.myUserInfo!.userId getUserName = () => this.myUserInfo!.name userList = () => paduserlist.users() - sendClientMessage = (msg: string) => { - this.collabClient.sendClientMessage(msg); + sendClientMessage = (msg: ClientSendMessages) => { + this.collabClient!.sendClientMessage(msg); } init = () => { padutils.setupGlobalExceptionHandler(); @@ -472,7 +485,7 @@ export class Pad { const postAceInit = () => { padeditbar.init(); setTimeout(() => { - padeditor.ace.focus(); + padeditor.ace!.focus(); }, 0); const optionsStickyChat = $('#options-stickychat'); optionsStickyChat.on('click', () => { @@ -502,14 +515,14 @@ export class Pad { $('#viewfontmenu').val(padcookie.getPref('padFontFamily')).niceSelect('update'); // Prevent sticky chat or chat and users to be checked for mobiles - const checkChatAndUsersVisibility = (x) => { + const checkChatAndUsersVisibility = (x: MediaQueryListEvent|MediaQueryList) => { if (x.matches) { // If media query matches $('#options-chatandusers:checked').trigger('click'); $('#options-stickychat:checked').trigger('click'); } }; const mobileMatch = window.matchMedia('(max-width: 800px)'); - mobileMatch.addListener(checkChatAndUsersVisibility); // check if window resized + mobileMatch.addListener((ev)=>checkChatAndUsersVisibility(ev)); // check if window resized setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0); // check now after load @@ -522,21 +535,23 @@ export class Pad { // order of inits is important here: padimpexp.init(this); padsavedrevs.init(this); + // @ts-ignore padeditor.init(this.padOptions.view || {}, this).then(postAceInit); paduserlist.init(this.myUserInfo, this); padconnectionstatus.init(); padmodals.init(this); - this.collabClient = getCollabClient( - padeditor.ace, window.clientVars.collab_client_vars, this.myUserInfo, + this.collabClient = new CollabClient( + padeditor.ace!, window.clientVars.collab_client_vars, this.myUserInfo!, {colorPalette: this.getColorPalette()}, pad); this._messageQ.setCollabClient(this.collabClient); this.collabClient.setOnUserJoin(this.handleUserJoin); this.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate); this.collabClient.setOnUserLeave(pad.handleUserLeave); - this.collabClient.setOnClientMessage(pad.handleClientMessage); + this.collabClient.setOnClientMessage(pad.handleClientMessage!); + // @ts-ignore this.collabClient.setOnChannelStateChange(pad.handleChannelStateChange); - this.collabClient.setOnInternalAction(pad.handleCollabAction); + this.collabClient.setOnInternalAction(pad.handleCollabAction!); // load initial chat-messages if (window.clientVars.chatHead !== -1) { @@ -550,52 +565,54 @@ export class Pad { if (window.clientVars.readonly) { chat.hide(); + // @ts-ignore $('#myusernameedit').attr('disabled', true); + // @ts-ignore $('#chatinput').attr('disabled', true); $('#chaticon').hide(); $('#options-chatandusers').parent().hide(); $('#options-stickychat').parent().hide(); - } else if (!settings.hideChat) { + } else if (!this.settings.hideChat) { $('#chaticon').show(); } $('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite'); - padeditor.ace.callWithAce((ace) => { + padeditor.ace!.callWithAce((ace) => { ace.ace_setEditable(!window.clientVars.readonly); }); // If the LineNumbersDisabled value is set to true then we need to hide the Line Numbers - if (settings.LineNumbersDisabled === true) { + if (this.settings.LineNumbersDisabled === true) { this.changeViewOption('showLineNumbers', false); } // If the noColors value is set to true then we need to // hide the background colors on the ace spans - if (settings.noColors === true) { + if (this.settings.noColors === true) { this.changeViewOption('noColors', true); } - if (settings.rtlIsTrue === true) { + if (this.settings.rtlIsTrue === true) { this.changeViewOption('rtlIsTrue', true); } // If the Monospacefont value is set to true then change it to monospace. - if (settings.useMonospaceFontGlobal === true) { + if (this.settings.useMonospaceFontGlobal === true) { this.changeViewOption('padFontFamily', 'RobotoMono'); } // if the globalUserName value is set we need to tell the server and // the client about the new authorname - if (settings.globalUserName !== false) { - this.notifyChangeName(settings.globalUserName); // Notifies the server - this.myUserInfo.name = settings.globalUserName; - $('#myusernameedit').val(settings.globalUserName); // Updates the current users UI + if (this.settings.globalUserName !== false) { + this.notifyChangeName(this.settings.globalUserName as string); // Notifies the server + this.myUserInfo!.name = this.settings.globalUserName as string; + $('#myusernameedit').val(this.settings.globalUserName as string); // Updates the current users UI } - if (settings.globalUserColor !== false && colorutils.isCssHex(settings.globalUserColor)) { + if (this.settings.globalUserColor !== false && colorutils.isCssHex(this.settings.globalUserColor)) { // Add a 'globalUserColor' property to myUserInfo, // so collabClient knows we have a query parameter. - this.myUserInfo.globalUserColor = settings.globalUserColor; - this.notifyChangeColor(settings.globalUserColor); // Updates this.myUserInfo.colorId + this.myUserInfo!.globalUserColor = this.settings.globalUserColor!; + this.notifyChangeColor(this.settings.globalUserColor as unknown as number); // Updates this.myUserInfo.colorId paduserlist.setMyUserInfo(this.myUserInfo); } } @@ -603,39 +620,39 @@ export class Pad { dispose = () => { padeditor.dispose(); } - notifyChangeName = (newName) => { - this.myUserInfo.name = newName; - this.collabClient.updateUserInfo(this.myUserInfo); + notifyChangeName = (newName: string) => { + this.myUserInfo!.name = newName; + this.collabClient!.updateUserInfo(this.myUserInfo!); } - notifyChangeColor = (newColorId) => { - this.myUserInfo.colorId = newColorId; - this.collabClient.updateUserInfo(this.myUserInfo); + notifyChangeColor = (newColorId: number) => { + this.myUserInfo!.colorId = newColorId; + this.collabClient!.updateUserInfo(this.myUserInfo!); } changePadOption = (key: string, value: string) => { - const options: MapArrayType = {}; + const options: any = {}; // PadOption options[key] = value; this.handleOptionsChange(options); - this.collabClient.sendClientMessage( + this.collabClient!.sendClientMessage( { type: 'padoptions', options, - changedBy: this.myUserInfo.name || 'unnamed', + changedBy: this.myUserInfo!.name || 'unnamed', }) } - changeViewOption = (key: string, value: string) => { - const options: MapArrayType> = + changeViewOption = (key: string, value: any) => { + const options: PadOption = { view: {} , } ; - options.view[key] = value; + options.view![key] = value; this.handleOptionsChange(options); } - handleOptionsChange = (opts: MapArrayType>) => { + handleOptionsChange = (opts: PadOption) => { // opts object is a full set of options or just // some options to change if (opts.view) { @@ -643,7 +660,9 @@ export class Pad { this.padOptions.view = {}; } for (const [k, v] of Object.entries(opts.view)) { + // @ts-ignore this.padOptions.view[k] = v; + // @ts-ignore padcookie.setPref(k, v); } padeditor.setViewOptions(this.padOptions.view); @@ -652,28 +671,28 @@ export class Pad { getPadOptions = () => this.padOptions suggestUserName = (userId: string, name: string) => { - this.collabClient.sendClientMessage( + this.collabClient!.sendClientMessage( { type: 'suggestUserName', unnamedId: userId, newName: name, }); } - handleUserJoin = (userInfo) => { + handleUserJoin = (userInfo: UserInfo) => { paduserlist.userJoinOrUpdate(userInfo); } - handleUserUpdate = (userInfo) => { + handleUserUpdate = (userInfo: UserInfo) => { paduserlist.userJoinOrUpdate(userInfo); } handleUserLeave = - (userInfo) => { + (userInfo: UserInfo) => { paduserlist.userLeave(userInfo); } // caller shouldn't mutate the object handleClientMessage = - (msg) => { + (msg: ClientSendMessages) => { if (msg.type === 'suggestUserName') { - if (msg.unnamedId === pad.myUserInfo.userId && msg.newName && !pad.myUserInfo.name) { + if (msg.unnamedId === pad.myUserInfo!.userId && msg.newName && !pad.myUserInfo!.name) { pad.notifyChangeName(msg.newName); paduserlist.setMyUserInfo(pad.myUserInfo); } @@ -689,7 +708,7 @@ export class Pad { handleChannelStateChange = - (newState, message) => { + (newState: string, message: string) => { const oldFullyConnected = !!padconnectionstatus.isFullyConnected(); const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting'); if (newState === 'CONNECTED') { @@ -709,10 +728,9 @@ export class Pad { // we filter non objects from the socket object and put them in the diagnosticInfo // this ensures we have no cyclic data - this allows us to stringify the data - for (const [i, value] of Object.entries(socket.socket || {})) { - const type = typeof value; + for (const [i, value] of Object.entries(socket!.socket || {})) { - if (type === 'string' || type === 'number') { + if (typeof value === 'string' || typeof value === 'number') { pad.diagnosticInfo.socket[i] = value; } } @@ -734,7 +752,7 @@ export class Pad { } handleIsFullyConnected = - (isConnected, isInitialConnect) => { + (isConnected: boolean, isInitialConnect: boolean) => { pad.determineChatVisibility(isConnected && !isInitialConnect); pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect); pad.determineAuthorshipColorsVisibility(); @@ -744,7 +762,7 @@ export class Pad { } determineChatVisibility = - (asNowConnectedFeedback) => { + (asNowConnectedFeedback: boolean) => { const chatVisCookie = padcookie.getPref('chatAlwaysVisible'); if (chatVisCookie) { // if the cookie is set for chat always visible chat.stickToScreen(true); // stick it to the screen @@ -755,7 +773,7 @@ export class Pad { } determineChatAndUsersVisibility = - (asNowConnectedFeedback) => { + (asNowConnectedFeedback: boolean) => { const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible'); if (chatAUVisCookie) { // if the cookie is set for chat always visible chat.chatAndUsers(true); // stick it to the screen @@ -777,7 +795,7 @@ export class Pad { } handleCollabAction = - (action) => { + (action: string) => { if (action === 'commitPerformed') { padeditbar.setSyncStatus('syncing'); } else if (action === 'newlyIdle') { @@ -806,26 +824,27 @@ export class Pad { = () => { $('form#reconnectform input.padId').val(pad.getPadId()); - pad.diagnosticInfo.collabDiagnosticInfo = pad.collabClient.getDiagnosticInfo(); + // @ts-ignore //FIxME What is that + pad.diagnosticInfo.collabDiagnosticInfo = pad.collabClient!.getDiagnosticInfo(); $('form#reconnectform input.diagnosticInfo').val(JSON.stringify(pad.diagnosticInfo)); $('form#reconnectform input.missedChanges') - .val(JSON.stringify(pad.collabClient.getMissedChanges())); + .val(JSON.stringify(pad.collabClient!.getMissedChanges())); $('form#reconnectform').trigger('submit'); } callWhenNotCommitting = - (f) => { - pad.collabClient.callWhenNotCommitting(f); + (f: Function) => { + pad.collabClient!.callWhenNotCommitting(f); } getCollabRevisionNumber = - () => pad.collabClient.getCurrentRevisionNumber() + () => pad.collabClient!.getCurrentRevisionNumber() isFullyConnected = () => padconnectionstatus.isFullyConnected() addHistoricalAuthors = - (data) => { + (data: HistoricalAuthorData) => { if (!pad.collabClient) { window.setTimeout(() => { pad.addHistoricalAuthors(data); @@ -849,9 +868,7 @@ export type PadSettings = { export const pad = new Pad() - exports.baseURL = ''; exports.randomString = randomString; exports.getParams = getParams; exports.pad = pad; -exports.init = init; diff --git a/src/static/js/pad_automatic_reconnect.js b/src/static/js/pad_automatic_reconnect.ts similarity index 50% rename from src/static/js/pad_automatic_reconnect.js rename to src/static/js/pad_automatic_reconnect.ts index 03fc9143258..2122c098fe9 100644 --- a/src/static/js/pad_automatic_reconnect.js +++ b/src/static/js/pad_automatic_reconnect.ts @@ -1,8 +1,10 @@ 'use strict'; import html10n from './vendors/html10n'; +import {PadOption} from "./types/SocketIOMessage"; +import {Pad} from "./pad"; -exports.showCountDownTimerToReconnectOnModal = ($modal, pad) => { - if (clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) { +export const showCountDownTimerToReconnectOnModal = ($modal: JQuery, pad: Pad) => { + if (window.clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) { createCountDownElementsIfNecessary($modal); const timer = createTimerForModal($modal, pad); @@ -16,7 +18,7 @@ exports.showCountDownTimerToReconnectOnModal = ($modal, pad) => { } }; -const createCountDownElementsIfNecessary = ($modal) => { +const createCountDownElementsIfNecessary = ($modal: JQuery) => { const elementsDoNotExist = $modal.find('#cancelreconnect').length === 0; if (elementsDoNotExist) { const $defaultMessage = $modal.find('#defaulttext'); @@ -48,16 +50,16 @@ const createCountDownElementsIfNecessary = ($modal) => { } }; -const localize = ($element) => { +const localize = ($element: JQuery) => { html10n.translateElement(html10n.translations, $element.get(0)); }; -const createTimerForModal = ($modal, pad) => { +const createTimerForModal = ($modal: JQuery, pad: Pad) => { const timeUntilReconnection = - clientVars.automaticReconnectionTimeout * reconnectionTries.nextTry(); + window.clientVars.automaticReconnectionTimeout * reconnectionTries.nextTry(); const timer = new CountDownTimer(timeUntilReconnection); - timer.onTick((minutes, seconds) => { + timer.onTick((minutes: number, seconds: number) => { updateCountDownTimerMessage($modal, minutes, seconds); }).onExpire(() => { const wasANetworkError = $modal.is('.disconnected'); @@ -72,23 +74,23 @@ const createTimerForModal = ($modal, pad) => { return timer; }; -const disableAutomaticReconnection = ($modal) => { +const disableAutomaticReconnection = ($modal: JQuery) => { toggleAutomaticReconnectionOption($modal, true); }; -const enableAutomaticReconnection = ($modal) => { +const enableAutomaticReconnection = ($modal: JQuery) => { toggleAutomaticReconnectionOption($modal, false); }; -const toggleAutomaticReconnectionOption = ($modal, disableAutomaticReconnect) => { +const toggleAutomaticReconnectionOption = ($modal: JQuery, disableAutomaticReconnect: boolean) => { $modal.find('#cancelreconnect, .reconnecttimer').toggleClass('hidden', disableAutomaticReconnect); $modal.find('#defaulttext').toggleClass('hidden', !disableAutomaticReconnect); }; -const waitUntilClientCanConnectToServerAndThen = (callback, pad) => { +const waitUntilClientCanConnectToServerAndThen = (callback: Function, pad: Pad) => { whenConnectionIsRestablishedWithServer(callback, pad); pad.socket.connect(); }; -const whenConnectionIsRestablishedWithServer = (callback, pad) => { +const whenConnectionIsRestablishedWithServer = (callback: Function, pad: Pad) => { // only add listener for the first try, don't need to add another listener // on every unsuccessful try if (reconnectionTries.counter === 1) { @@ -96,15 +98,15 @@ const whenConnectionIsRestablishedWithServer = (callback, pad) => { } }; -const forceReconnection = ($modal) => { +const forceReconnection = ($modal: JQuery) => { $modal.find('#forcereconnect').trigger('click'); }; -const updateCountDownTimerMessage = ($modal, minutes, seconds) => { - minutes = minutes < 10 ? `0${minutes}` : minutes; - seconds = seconds < 10 ? `0${seconds}` : seconds; +const updateCountDownTimerMessage = ($modal: JQuery, minutes: number, seconds: number) => { + let minutesFormatted = minutes < 10 ? `0${minutes}` : minutes; + let secondsFormatted = seconds < 10 ? `0${seconds}` : seconds; - $modal.find('.timetoexpire').text(`${minutes}:${seconds}`); + $modal.find('.timetoexpire').text(`${minutesFormatted}:${secondsFormatted}`); }; // store number of tries to reconnect to server, in order to increase time to wait @@ -125,71 +127,75 @@ const reconnectionTries = { // duration: how many **seconds** until the timer ends // granularity (optional): how many **milliseconds** // between each 'tick' of timer. Default: 1000ms (1s) -const CountDownTimer = function (duration, granularity) { - this.duration = duration; - this.granularity = granularity || 1000; - this.running = false; - this.onTickCallbacks = []; - this.onExpireCallbacks = []; -}; - -CountDownTimer.prototype.start = function () { - if (this.running) { - return; +class CountDownTimer { + private duration: number + private granularity: number + private running: boolean + private onTickCallbacks: Function[] + private onExpireCallbacks: Function[] + private timeoutId: any = 0 + constructor(duration: number, granularity?: number) { + this.duration = duration; + this.granularity = granularity || 1000; + this.running = false; + + this.onTickCallbacks = []; + this.onExpireCallbacks = []; } - this.running = true; - const start = Date.now(); - const that = this; - let diff; - const timer = () => { - diff = that.duration - Math.floor((Date.now() - start) / 1000); - - if (diff > 0) { - that.timeoutId = setTimeout(timer, that.granularity); - that.tick(diff); - } else { - that.running = false; - that.tick(0); - that.expire(); + start = ()=> { + if (this.running) { + return; } - }; - timer(); -}; - -CountDownTimer.prototype.tick = function (diff) { - const obj = CountDownTimer.parse(diff); - this.onTickCallbacks.forEach(function (callback) { - callback.call(this, obj.minutes, obj.seconds); - }, this); -}; -CountDownTimer.prototype.expire = function () { - this.onExpireCallbacks.forEach(function (callback) { - callback.call(this); - }, this); -}; - -CountDownTimer.prototype.onTick = function (callback) { - if (typeof callback === 'function') { - this.onTickCallbacks.push(callback); + this.running = true; + const start = Date.now(); + const that = this; + let diff; + const timer = () => { + diff = that.duration - Math.floor((Date.now() - start) / 1000); + + if (diff > 0) { + that.timeoutId = setTimeout(timer, that.granularity); + that.tick(diff); + } else { + that.running = false; + that.tick(0); + that.expire(); + } + }; + timer(); } - return this; -}; - -CountDownTimer.prototype.onExpire = function (callback) { - if (typeof callback === 'function') { - this.onExpireCallbacks.push(callback); + tick = (diff: number)=> { + const obj = this.parse(diff); + this.onTickCallbacks.forEach( (callback)=> { + callback.call(this, obj.minutes, obj.seconds); + }, this); + } + expire = ()=> { + this.onExpireCallbacks.forEach( (callback)=> { + callback.call(this); + }, this); + } + onTick = (callback: Function)=> { + if (typeof callback === 'function') { + this.onTickCallbacks.push(callback); + } + return this; } - return this; -}; - -CountDownTimer.prototype.cancel = function () { - this.running = false; - clearTimeout(this.timeoutId); - return this; -}; -CountDownTimer.parse = (seconds) => ({ - minutes: (seconds / 60) | 0, - seconds: (seconds % 60) | 0, -}); + onExpire = (callback: Function)=> { + if (typeof callback === 'function') { + this.onExpireCallbacks.push(callback); + } + return this; + } + cancel = () => { + this.running = false; + clearTimeout(this.timeoutId); + return this; + } + parse = (seconds: number) => ({ + minutes: (seconds / 60) | 0, + seconds: (seconds % 60) | 0, + }); +} diff --git a/src/static/js/pad_connectionstatus.js b/src/static/js/pad_connectionstatus.ts similarity index 52% rename from src/static/js/pad_connectionstatus.js rename to src/static/js/pad_connectionstatus.ts index 7b0497d96d8..85a57f1ead9 100644 --- a/src/static/js/pad_connectionstatus.js +++ b/src/static/js/pad_connectionstatus.ts @@ -22,45 +22,51 @@ * limitations under the License. */ -const padmodals = require('./pad_modals').padmodals; +import {padModals} from "./pad_modals"; -const padconnectionstatus = (() => { - let status = { +class PadConnectionStatus { + private status: { + what: string, + why?: string + } = { what: 'connecting', - }; + } - const self = { - init: () => { - $('button#forcereconnect').on('click', () => { - window.location.reload(); - }); - }, - connected: () => { - status = { - what: 'connected', - }; - padmodals.showModal('connected'); - padmodals.hideOverlay(); - }, - reconnecting: () => { - status = { - what: 'reconnecting', - }; + init = () => { + $('button#forcereconnect').on('click', () => { + window.location.reload(); + }); + } - padmodals.showModal('reconnecting'); - padmodals.showOverlay(); - }, - disconnected: (msg) => { - if (status.what === 'disconnected') return; + connected = () => { + this.status = { + what: 'connected', + }; + padModals.showModal('connected'); + padModals.hideOverlay(); + } + reconnecting = () => { + this.status = { + what: 'reconnecting', + }; - status = { - what: 'disconnected', - why: msg, - }; + padModals.showModal('reconnecting'); + padModals.showOverlay(); + } + disconnected + = + (msg: string) => { + if (this.status.what === 'disconnected') return; - // These message IDs correspond to localized strings that are presented to the user. If a new - // message ID is added here then a new div must be added to src/templates/pad.html and the - // corresponding l10n IDs must be added to the language files in src/locales. + this.status = + { + what: 'disconnected', + why: msg, + } + +// These message IDs correspond to localized strings that are presented to the user. If a new +// message ID is added here then a new div must be added to src/templates/pad.html and the +// corresponding l10n IDs must be added to the language files in src/locales. const knownReasons = [ 'badChangeset', 'corruptPad', @@ -80,13 +86,17 @@ const padconnectionstatus = (() => { k = 'disconnected'; } - padmodals.showModal(k); - padmodals.showOverlay(); - }, - isFullyConnected: () => status.what === 'connected', - getStatus: () => status, - }; - return self; -})(); + padModals.showModal(k); + padModals.showOverlay(); + } + isFullyConnected + = + () => this.status.what === 'connected' + getStatus + = + () => this.status +} + +export const padconnectionstatus = new PadConnectionStatus() exports.padconnectionstatus = padconnectionstatus; diff --git a/src/static/js/pad_editbar.js b/src/static/js/pad_editbar.ts similarity index 80% rename from src/static/js/pad_editbar.js rename to src/static/js/pad_editbar.ts index d392fa7a3a8..b496bb278b8 100644 --- a/src/static/js/pad_editbar.js +++ b/src/static/js/pad_editbar.ts @@ -6,6 +6,8 @@ * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ +import {MapArrayType} from "../../node/types/MapType"; + /** * Copyright 2009 Google Inc. * @@ -26,13 +28,16 @@ const browser = require('./vendors/browser'); const hooks = require('./pluginfw/hooks'); import {padUtils as padutils} from "./pad_utils"; -const padeditor = require('./pad_editor').padeditor; +import {PadEditor, padEditor as padeditor} from "./pad_editor"; +import {Ace2Editor} from "./ace"; +import html10n from "./vendors/html10n"; const padsavedrevs = require('./pad_savedrevs'); const _ = require('underscore'); require('./vendors/nice-select'); class ToolbarItem { - constructor(element) { + private $el: JQuery; + constructor(element: JQuery) { this.$el = element; } @@ -46,8 +51,9 @@ class ToolbarItem { } } - setValue(val) { + setValue(val: boolean) { if (this.isSelect()) { + // @ts-ignore return this.$el.find('select').val(val); } } @@ -64,7 +70,7 @@ class ToolbarItem { return this.getType() === 'button'; } - bind(callback) { + bind(callback: (cmd: string|undefined, tb: ToolbarItem)=>void) { if (this.isButton()) { this.$el.on('click', (event) => { $(':focus').trigger('blur'); @@ -79,52 +85,62 @@ class ToolbarItem { } } -const syncAnimation = (() => { - const SYNCING = -100; - const DONE = 100; - let state = DONE; - const fps = 25; - const step = 1 / fps; - const T_START = -0.5; - const T_FADE = 1.0; - const T_GONE = 1.5; - const animator = padutils.makeAnimationScheduler(() => { - if (state === SYNCING || state === DONE) { - return false; - } else if (state >= T_GONE) { - state = DONE; - $('#syncstatussyncing').css('display', 'none'); - $('#syncstatusdone').css('display', 'none'); - return false; - } else if (state < 0) { - state += step; - if (state >= 0) { +class SyncAnimation { + static SYNCING = -100; + static DONE = 100; + state = SyncAnimation.DONE; + fps = 25; + step = 1 / this.fps; + static T_START = -0.5; + static T_FADE = 1.0; + static T_GONE = 1.5; + private animator: { scheduleAnimation: () => void }; + constructor() { + + this.animator = padutils.makeAnimationScheduler(() => { + if (this.state === SyncAnimation.SYNCING || this.state === SyncAnimation.DONE) { + return false; + } else if (this.state >= SyncAnimation.T_GONE) { + this.state = SyncAnimation.DONE; $('#syncstatussyncing').css('display', 'none'); - $('#syncstatusdone').css('display', 'block').css('opacity', 1); - } - return true; - } else { - state += step; - if (state >= T_FADE) { - $('#syncstatusdone').css('opacity', (T_GONE - state) / (T_GONE - T_FADE)); + $('#syncstatusdone').css('display', 'none'); + return false; + } else if (this.state < 0) { + this.state += this.step; + if (this.state >= 0) { + $('#syncstatussyncing').css('display', 'none'); + $('#syncstatusdone').css('display', 'block').css('opacity', 1); + } + return true; + } else { + this.state += this.step; + if (this.state >= SyncAnimation.T_FADE) { + $('#syncstatusdone').css('opacity', (SyncAnimation.T_GONE - this.state) / (SyncAnimation.T_GONE - SyncAnimation.T_FADE)); + } + return true; } - return true; - } - }, step * 1000); - return { - syncing: () => { - state = SYNCING; - $('#syncstatussyncing').css('display', 'block'); - $('#syncstatusdone').css('display', 'none'); - }, - done: () => { - state = T_START; - animator.scheduleAnimation(); - }, - }; -})(); - -exports.padeditbar = new class { + }, this.step * 1000); + } + syncing = () => { + this.state = SyncAnimation.SYNCING; + $('#syncstatussyncing').css('display', 'block'); + $('#syncstatusdone').css('display', 'none'); + } + done = () => { + this.state = SyncAnimation.T_START; + this.animator.scheduleAnimation(); + } +} + +const syncAnimation = new SyncAnimation() + +type ToolbarCallback = (cmd: string, el: ToolbarItem)=>void +type ToolbarAceCallback = (cmd: string, ace: any, el: ToolbarItem)=>void + +class Padeditbar { + private _editbarPosition: number; + private commands: MapArrayType; + private dropdowns: any[]; constructor() { this._editbarPosition = 0; this.commands = {}; @@ -137,7 +153,7 @@ exports.padeditbar = new class { $('#editbar [data-key]').each((i, elt) => { $(elt).off('click'); new ToolbarItem($(elt)).bind((command, item) => { - this.triggerCommand(command, item); + this.triggerCommand(command!, item); }); }); @@ -165,12 +181,13 @@ exports.padeditbar = new class { * overflow:hidden on parent */ if (!browser.safari) { + // @ts-ignore $('select').niceSelect(); } // When editor is scrolled, we add a class to style the editbar differently $('iframe[name="ace_outer"]').contents().on('scroll', (ev) => { - $('#editbar').toggleClass('editor-scrolled', $(ev.currentTarget).scrollTop() > 2); + $('#editbar').toggleClass('editor-scrolled', $(ev.currentTarget).scrollTop()! > 2); }); } isEnabled() { return true; } @@ -180,25 +197,25 @@ exports.padeditbar = new class { enable() { $('#editbar').addClass('enabledtoolbar').removeClass('disabledtoolbar'); } - registerCommand(cmd, callback) { + registerCommand(cmd: string, callback: (cmd: string, ace: Ace2Editor, item: ToolbarItem)=>void) { this.commands[cmd] = callback; return this; } - registerDropdownCommand(cmd, dropdown) { + registerDropdownCommand(cmd: string, dropdown?: string) { dropdown = dropdown || cmd; this.dropdowns.push(dropdown); this.registerCommand(cmd, () => { this.toggleDropDown(dropdown); }); } - registerAceCommand(cmd, callback) { + registerAceCommand(cmd: string, callback: ToolbarAceCallback) { this.registerCommand(cmd, (cmd, ace, item) => { ace.callWithAce((ace) => { callback(cmd, ace, item); }, cmd, true); }); } - triggerCommand(cmd, item) { + triggerCommand(cmd: string, item: ToolbarItem) { if (this.isEnabled() && this.commands[cmd]) { this.commands[cmd](cmd, padeditor.ace, item); } @@ -206,8 +223,8 @@ exports.padeditbar = new class { } // cb is deprecated (this function is synchronous so a callback is unnecessary). - toggleDropDown(moduleName, cb = null) { - let cbErr = null; + toggleDropDown(moduleName: string, cb:Function|null = null) { + let cbErr: Error|null = null; try { // do nothing if users are sticked if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) { @@ -249,12 +266,13 @@ exports.padeditbar = new class { } } } catch (err) { + // @ts-ignore cbErr = err || new Error(err); } finally { if (cb) Promise.resolve().then(() => cb(cbErr)); } } - setSyncStatus(status) { + setSyncStatus(status: string) { if (status === 'syncing') { syncAnimation.syncing(); } else if (status === 'done') { @@ -269,7 +287,7 @@ exports.padeditbar = new class { if ($('#readonlyinput').is(':checked')) { const urlParts = padUrl.split('/'); urlParts.pop(); - const readonlyLink = `${urlParts.join('/')}/${clientVars.readOnlyId}`; + const readonlyLink = `${urlParts.join('/')}/${window.clientVars.readOnlyId}`; $('#embedinput') .val(``); $('#linkinput').val(readonlyLink); @@ -288,16 +306,16 @@ exports.padeditbar = new class { // this is approximate, we cannot measure it because on mobile // Layout it takes the full width on the bottom of the page const menuRightWidth = 280; - if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width() - menuRightWidth || - $('.toolbar').width() < 1000) { + if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width()! - menuRightWidth || + $('.toolbar').width()! < 1000) { $('body').addClass('mobile-layout'); } - if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width()) { + if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width()!) { $('.toolbar').addClass('cropped'); } } - _bodyKeyEvent(evt) { + _bodyKeyEvent(evt: JQuery.KeyDownEvent) { // If the event is Alt F9 or Escape & we're already in the editbar menu // Send the users focus back to the pad if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) { @@ -313,15 +331,17 @@ exports.padeditbar = new class { // Timeslider probably.. $('#editorcontainerbox').trigger('focus'); // Focus back onto the pad } else { - padeditor.ace.focus(); // Sends focus back to pad + padeditor.ace!.focus(); // Sends focus back to pad // The above focus doesn't always work in FF, you have to hit enter afterwards evt.preventDefault(); } } else { // Focus on the editbar :) const firstEditbarElement = $('#editbar button').first(); + // @ts-ignore + const evTarget:JQuery = $(evt.currentTarget) as any - $(evt.currentTarget).trigger('blur'); + evTarget.trigger('blur'); firstEditbarElement.trigger('focus'); evt.preventDefault(); } @@ -337,7 +357,8 @@ exports.padeditbar = new class { // On left arrow move to next button in editbar if (evt.keyCode === 37) { // If a dropdown is visible or we're in an input don't move to the next button - if ($('.popup').is(':visible') || evt.target.localName === 'input') return; + // @ts-ignore + if ($('.popup').is(':visible') || evt.target!.localName === 'input') return; this._editbarPosition--; // Allow focus to shift back to end of row and start of row @@ -348,6 +369,7 @@ exports.padeditbar = new class { // On right arrow move to next button in editbar if (evt.keyCode === 39) { // If a dropdown is visible or we're in an input don't move to the next button + // @ts-ignore if ($('.popup').is(':visible') || evt.target.localName === 'input') return; this._editbarPosition++; @@ -394,14 +416,14 @@ exports.padeditbar = new class { }); this.registerCommand('savedRevision', () => { - padsavedrevs.saveNow(); + padsavedrevs.saveNow(pad); }); this.registerCommand('showTimeSlider', () => { document.location = `${document.location.pathname}/timeslider`; }); - const aceAttributeCommand = (cmd, ace) => { + const aceAttributeCommand = (cmd: string, ace: any) => { ace.ace_toggleAttributeOnSelection(cmd); }; this.registerAceCommand('bold', aceAttributeCommand); @@ -479,4 +501,6 @@ exports.padeditbar = new class { } }); } -}(); +}; + +export const padeditbar = new PadEditor() diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts index f74fb7b4d5d..c698dbb0e72 100644 --- a/src/static/js/pad_editor.ts +++ b/src/static/js/pad_editor.ts @@ -31,12 +31,13 @@ import {padUtils as padutils} from "./pad_utils"; import {Ace2Editor} from "./ace"; import html10n from '../js/vendors/html10n' import {MapArrayType} from "../../node/types/MapType"; -import {ClientVarData, ClientVarMessage} from "./types/SocketIOMessage"; +import {ClientVarPayload, PadOption} from "./types/SocketIOMessage"; +import {Pad} from "./pad"; export class PadEditor { - private pad?: PadType - private settings: undefined| ClientVarData - private ace: any + private pad?: Pad + private settings: undefined| PadOption + ace: Ace2Editor|null private viewZoom: number constructor() { @@ -47,7 +48,7 @@ export class PadEditor { this.viewZoom = 100 } - init = async (initialViewOptions: MapArrayType, _pad: PadType) => { + init = async (initialViewOptions: MapArrayType, _pad: Pad) => { this.pad = _pad; this.settings = this.pad.settings; this.ace = new Ace2Editor(); @@ -125,7 +126,7 @@ export class PadEditor { }); } - setViewOptions = (newOptions: MapArrayType) => { + setViewOptions = (newOptions: MapArrayType) => { const getOption = (key: string, defaultValue: boolean) => { const value = String(newOptions[key]); if (value === 'true') return true; @@ -136,25 +137,25 @@ export class PadEditor { let v; v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection())); - this.ace.setProperty('rtlIsTrue', v); + this.ace!.setProperty('rtlIsTrue', v); padutils.setCheckbox($('#options-rtlcheck'), v); v = getOption('showLineNumbers', true); - this.ace.setProperty('showslinenumbers', v); + this.ace!.setProperty('showslinenumbers', v); padutils.setCheckbox($('#options-linenoscheck'), v); v = getOption('showAuthorColors', true); - this.ace.setProperty('showsauthorcolors', v); + this.ace!.setProperty('showsauthorcolors', v); $('#chattext').toggleClass('authorColors', v); $('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v); padutils.setCheckbox($('#options-colorscheck'), v); // Override from parameters if true if (this.settings!.noColors !== false) { - this.ace.setProperty('showsauthorcolors', !settings.noColors); + this.ace!.setProperty('showsauthorcolors', !this.settings!.noColors); } - this.ace.setProperty('textface', newOptions.padFontFamily || ''); + this.ace!.setProperty('textface', newOptions.padFontFamily || ''); } dispose = () => { @@ -173,12 +174,12 @@ export class PadEditor { this.ace.setEditable(false); } } - restoreRevisionText= (dataFromServer: ClientVarData) => { + restoreRevisionText= (dataFromServer: ClientVarPayload) => { this.pad!.addHistoricalAuthors(dataFromServer.historicalAuthorData); - this.ace.importAText(dataFromServer.atext, dataFromServer.apool, true); + this.ace!.importAText(dataFromServer.atext, dataFromServer.apool, true); } - focusOnLine = (ace) => { + focusOnLine = (ace: Ace2Editor) => { // If a number is in the URI IE #L124 go to that line number const lineNumber = window.location.hash.substr(1); if (lineNumber) { diff --git a/src/static/js/pad_impexp.js b/src/static/js/pad_impexp.ts similarity index 55% rename from src/static/js/pad_impexp.js rename to src/static/js/pad_impexp.ts index 3aca9fb7cdd..6cdcd6f1f2d 100644 --- a/src/static/js/pad_impexp.js +++ b/src/static/js/pad_impexp.ts @@ -23,29 +23,27 @@ */ import html10n from './vendors/html10n'; +import {Pad} from "./pad"; - -const padimpexp = (() => { - let pad; +class PadImpExp { + private pad?: Pad; // /// import - const addImportFrames = () => { + addImportFrames = () => { $('#import .importframe').remove(); const iframe = $('