From fa2d6d15a9409675ffba46142f91d920e1421a3e Mon Sep 17 00:00:00 2001 From: SamTv12345 Date: Fri, 19 Jul 2024 19:22:04 +0200 Subject: [PATCH] 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} | 517 ++++++++------ src/static/js/domline.js | 279 -------- src/static/js/domline.ts | 299 ++++++++ src/static/js/pad.ts | 217 +++--- ...econnect.js => pad_automatic_reconnect.ts} | 160 +++-- ...ctionstatus.js => pad_connectionstatus.ts} | 92 +-- .../js/{pad_editbar.js => pad_editbar.ts} | 162 +++-- src/static/js/pad_editor.ts | 29 +- .../js/{pad_impexp.js => pad_impexp.ts} | 155 ++-- .../js/{pad_modals.js => pad_modals.ts} | 55 +- .../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, 2871 insertions(+), 2534 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 459df8954..2a99307aa 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 43d6e31c0..5b5cc7923 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 1f896cfc6..ae39d0fec 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 4e3fa636c..e4a417f2b 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 9da2e710e..d4530f7c4 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 294057bec..fb74f53aa 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 091ab12f1..43920cbf4 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 c1dab5cfd..0a5f308e6 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 69c8e41f3..000000000 --- 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 000000000..9a2c4ce60 --- /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 9688b8e59..000000000 --- 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 000000000..70a5e35bf --- /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 0bff7da7d..071307a16 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, - }; +type ContentElem = Element & { + name?: string +} - hooks.callAll('ccRegisterBlockElements').forEach((element) => { - _blockElems[element] = 1; - supportedElems.add(element); - }); +class Lines { + private textArray: string[] = []; + private attribsArray: string[] = []; + private attribsBuilder:SmartOpAssembler|null = null; + private op = new Changeset.Op('+'); - 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, ' ')); + 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; +} +} +} - const getAssoc = (node, name) => node[`_magicdom_${name}`]; +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 +} - 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; - })(); - const cc = {}; +type ContentCollectorPoint = { + index: number; + node: Node +} - const _ensureColumnZero = (state) => { - if (!lines.atColumnZero()) { - cc.startNewLine(state); +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, } - }; - let selection, startPoint, endPoint; - let selStart = [-1, -1]; - let selEnd = [-1, -1]; - const _isEmpty = (node, state) => { + 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; + })(); + private ensureColumnZero = (state: ContentCollectorState|null) => { + if (!this.lines.atColumnZero()) { + this.startNewLine(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 5c3dfcbc4..000000000 --- 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 000000000..083632fb4 --- /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 8f1c7af7b..2d079fded 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 03fc91432..2122c098f 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 = []; -}; +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; -CountDownTimer.prototype.start = function () { - if (this.running) { - return; + 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(); -}; + this.running = true; + const start = Date.now(); + const that = this; + let diff; + const timer = () => { + diff = that.duration - Math.floor((Date.now() - start) / 1000); -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); + 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 7b0497d96..85a57f1ea 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 d392fa7a3..b496bb278 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) { - $('#syncstatussyncing').css('display', 'none'); - $('#syncstatusdone').css('display', 'block').css('opacity', 1); - } - return true; - } else { - state += step; - if (state >= T_FADE) { - $('#syncstatusdone').css('opacity', (T_GONE - state) / (T_GONE - T_FADE)); - } - return true; - } - }, step * 1000); - return { - syncing: () => { - state = SYNCING; - $('#syncstatussyncing').css('display', 'block'); - $('#syncstatusdone').css('display', 'none'); - }, - done: () => { - state = T_START; - animator.scheduleAnimation(); - }, - }; -})(); +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() { -exports.padeditbar = new class { + 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', '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; + } + }, 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 f74fb7b4d..c698dbb0e 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 3aca9fb7c..6cdcd6f1f 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 = $('