Added more frontend ts files

This commit is contained in:
SamTv12345 2024-07-19 19:22:04 +02:00
parent cef2af15b9
commit fa2d6d15a9
37 changed files with 2871 additions and 2534 deletions

View file

@ -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': {}

View file

@ -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<number>,
getRevisionChangeset: (rev: number)=>Promise<AChangeSet>,
appendRevision: (changeset: AChangeSet, author: string)=>Promise<void>,
settings:any
settings: PadOption
}

View file

@ -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",

View file

@ -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);

View file

@ -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');

View file

@ -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

View file

@ -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;

View file

@ -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<any>, name: string) => obj[`_magicdom_${name}`];
const setAssoc = (obj, name, value) => {
export const setAssoc = (obj: MapArrayType<any>, 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 = () => {};

View file

@ -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;

View file

@ -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<string, UserInfo> // 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<number>
}, 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<any>
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<string, UserInfo>) => {
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

View file

@ -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;

113
src/static/js/colorutils.ts Normal file
View file

@ -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

View file

@ -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<number>,
attribs: MapArrayType<number>
attribString: string
localAttribs: string[]|null,
unsupportedElements: Set<string>
}
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<number>;
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<string>, 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 === '&nbsp;' &&
!getAssoc(node, 'unpasted')) {
// @ts-ignore
this.getAssoc(node, 'shouldBeEmpty') &&
node.innerHTML === '&nbsp;' &&
// @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 --> <ol><li>1</li><ol>nested</ol></ol>
They are --> <ol><li>1</li><li><ol><li>nested</li></ol></li></ol>
Note how the <ol> item has to be inside a <li>
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;
}
}

View file

@ -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 += `<ul class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
postHtml = `</li></ul>${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 +=
`<ol start=${start[1]} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
} else {
// Handles pasted contents into existing lists
preHtml += `<ol class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
}
postHtml += '</li></ol>';
}
}
processedMarker = true;
}
_.map(hooks.callAll('aceDomLineProcessLineAttributes', {
domline,
cls,
}), (modifier) => {
preHtml += modifier.preHtml;
postHtml += modifier.postHtml;
processedMarker |= modifier.processedMarker;
});
if (processedMarker) {
result.lineMarker += txt.length;
return; // don't append any text
}
}
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}<a href="${escapedHref}" rel="noreferrer noopener">`;
extraCloseTags = `</a>${extraCloseTags}`;
}
if (simpleTags) {
simpleTags.sort();
extraOpenTags = `${extraOpenTags}<${simpleTags.join('><')}>`;
simpleTags.reverse();
extraCloseTags = `</${simpleTags.join('></')}>${extraCloseTags}`;
}
html.push(
'<span class="', Security.escapeHTMLAttribute(cls || ''),
'">',
extraOpenTags,
perTextNodeProcess(Security.escapeHTML(txt)),
extraCloseTags,
'</span>');
}
};
result.clearSpans = () => {
html = [];
lineClass = 'ace-line';
result.lineMarker = 0;
};
const writeHTML = () => {
let newHTML = perHtmlLineProcess(html.join(''));
if (!newHTML) {
if ((!document) || (!optBrowser)) {
newHTML += '&nbsp;';
} else {
newHTML += '<br/>';
}
}
if (nonEmpty) {
newHTML = (preHtml || '') + newHTML + (postHtml || '');
}
html = preHtml = postHtml = ''; // free memory
if (newHTML !== curHTML) {
curHTML = newHTML;
result.node.innerHTML = curHTML;
}
if (lineClass != null) result.node.className = lineClass;
hooks.callAll('acePostWriteDomLineHTML', {
node: result.node,
});
};
result.prepareForAdd = writeHTML;
result.finishUpdate = writeHTML;
return result;
};
domline.processSpaces = (s, doesWrap) => {
if (s.indexOf('<') < 0 && !doesWrap) {
// short-cut
return s.replace(/ /g, '&nbsp;');
}
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] = '&nbsp;';
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] = '&nbsp;';
break;
} else if (p.charAt(0) !== '<') {
break;
}
}
} else {
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === ' ') {
parts[i] = '&nbsp;';
}
}
}
return parts.join('');
};
exports.domline = domline;

299
src/static/js/domline.ts Normal file
View file

@ -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 += `<ul class="list-${Security.escapeHTMLAttribute(listTypeExtracted)}"><li>`;
this.postHtml = `</li></ul>${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 +=
`<ol start=${start[1]} class="list-${Security.escapeHTMLAttribute(listTypeExtracted)}"><li>`;
} else {
// Handles pasted contents into existing lists
this.preHtml += `<ol class="list-${Security.escapeHTMLAttribute(listTypeExtracted)}"><li>`;
}
this.postHtml += '</li></ol>';
}
}
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}<a href="${escapedHref}" rel="noreferrer noopener">`;
extraCloseTags = `</a>${extraCloseTags}`;
}
if (simpleTags) {
// @ts-ignore
simpleTags.sort();
// @ts-ignore
extraOpenTags = `${extraOpenTags}<${simpleTags.join('><')}>`;
// @ts-ignore
simpleTags.reverse();
// @ts-ignore
extraCloseTags = `</${simpleTags.join('></')}>${extraCloseTags}`;
}
this.html.push(
'<span class="', Security.escapeHTMLAttribute(cls || ''),
'">',
extraOpenTags,
this.perTextNodeProcess(Security.escapeHTML(txt)),
extraCloseTags,
'</span>');
}
}
writeHTML = () => {
let newHTML = this.perHtmlLineProcess(this.html.join(''));
if (!newHTML) {
if ((!document) || (!this.optBrowser)) {
newHTML += '&nbsp;';
} else {
newHTML += '<br/>';
}
}
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, '&nbsp;');
}
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] = '&nbsp;';
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] = '&nbsp;';
break;
} else if (p.charAt(0) !== '<') {
break;
}
}
} else {
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === ' ') {
parts[i] = '&nbsp;';
}
}
}
return parts.join('');
}
}
// if "document" is falsy we don't create a DOM node, just
// an object with innerHTML and className
export default Domline

View file

@ -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<void>((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<string|number>,
collabDiagnosticInfo?: any
};
private diagnosticInfo: {};
private initTime: number;
private clientTimeOffset: null | number;
private _messageQ: MessageQueue;
private padOptions: MapArrayType<MapArrayType<string>>;
_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<string> = {};
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<MapArrayType<any>> =
changeViewOption = (key: string, value: any) => {
const options: PadOption =
{
view: {}
,
}
;
options.view[key] = value;
options.view![key] = value;
this.handleOptionsChange(options);
}
handleOptionsChange = (opts: MapArrayType<MapArrayType<string>>) => {
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;

View file

@ -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<HTMLElement>, 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<HTMLElement>) => {
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<HTMLElement>) => {
html10n.translateElement(html10n.translations, $element.get(0));
};
const createTimerForModal = ($modal, pad) => {
const createTimerForModal = ($modal: JQuery<HTMLElement>, 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<HTMLElement>) => {
toggleAutomaticReconnectionOption($modal, true);
};
const enableAutomaticReconnection = ($modal) => {
const enableAutomaticReconnection = ($modal: JQuery<HTMLElement>) => {
toggleAutomaticReconnectionOption($modal, false);
};
const toggleAutomaticReconnectionOption = ($modal, disableAutomaticReconnect) => {
const toggleAutomaticReconnectionOption = ($modal: JQuery<HTMLElement>, 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<HTMLElement>) => {
$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<HTMLElement>, 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,
});
}

View file

@ -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;

View file

@ -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<HTMLElement>;
constructor(element: JQuery<HTMLElement>) {
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<ToolbarCallback| ToolbarAceCallback>;
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(`<iframe name="embed_readonly" src="${readonlyLink}${params}" ${props}></iframe>`);
$('#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<HTMLElement, undefined, HTMLElement, HTMLElement>) {
// 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<HTMLElement> = $(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()

View file

@ -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<string>, _pad: PadType) => {
init = async (initialViewOptions: MapArrayType<boolean>, _pad: Pad) => {
this.pad = _pad;
this.settings = this.pad.settings;
this.ace = new Ace2Editor();
@ -125,7 +126,7 @@ export class PadEditor {
});
}
setViewOptions = (newOptions: MapArrayType<string>) => {
setViewOptions = (newOptions: MapArrayType<boolean>) => {
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) {

View file

@ -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 = $('<iframe>')
.css('display', 'none')
.attr('name', 'importiframe')
.addClass('importframe');
.css('display', 'none')
.attr('name', 'importiframe')
.addClass('importframe');
$('#import').append(iframe);
};
const fileInputUpdated = () => {
}
fileInputUpdated = () => {
$('#importsubmitinput').addClass('throbbold');
$('#importformfilediv').addClass('importformenabled');
$('#importsubmitinput').prop('disabled', false);
$('#importmessagefail').fadeOut('fast');
};
const fileInputSubmit = function (e) {
}
fileInputSubmit = (e: Event) => {
e.preventDefault();
$('#importmessagefail').fadeOut('fast');
if (!window.confirm(html10n.get('pad.impexp.confirmimport'))) return;
@ -54,10 +52,13 @@ const padimpexp = (() => {
$('#importarrow').stop(true, true).hide();
$('#importstatusball').show();
(async () => {
// @ts-ignore
const {code, message, data: {directDatabaseAccess} = {}} = await $.ajax({
url: `${window.location.href.split('?')[0].split('#')[0]}/import`,
method: 'POST',
data: new FormData(this),
// FIXME is this correct
// @ts-ignore
data: new FormData(this.fileInputSubmit),
processData: false,
contentType: false,
dataType: 'json',
@ -67,7 +68,7 @@ const padimpexp = (() => {
return {code: 2, message: 'Unknown import error'};
});
if (code !== 0) {
importErrorMessage(message);
this.importErrorMessage(message);
} else {
$('#import_export').removeClass('popup-show');
if (directDatabaseAccess) window.location.reload();
@ -75,11 +76,11 @@ const padimpexp = (() => {
$('#importsubmitinput').prop('disabled', false).val(html10n.get('pad.impexp.importbutton'));
window.setTimeout(() => $('#importfileinput').prop('disabled', false), 0);
$('#importstatusball').hide();
addImportFrames();
this.addImportFrames();
})();
};
}
const importErrorMessage = (status) => {
importErrorMessage = (status: string) => {
const known = [
'convertFailed',
'uploadFailed',
@ -89,12 +90,12 @@ const padimpexp = (() => {
];
const msg = html10n.get(`pad.impexp.${known.indexOf(status) !== -1 ? status : 'copypaste'}`);
const showError = (fade) => {
const showError = (fade?: boolean) => {
const popup = $('#importmessagefail').empty()
.append($('<strong>')
.css('color', 'red')
.text(`${html10n.get('pad.impexp.importfailed')}: `))
.append(document.createTextNode(msg));
.append($('<strong>')
.css('color', 'red')
.text(`${html10n.get('pad.impexp.importfailed')}: `))
.append(document.createTextNode(msg));
popup[(fade ? 'fadeIn' : 'show')]();
};
@ -104,83 +105,83 @@ const padimpexp = (() => {
} else {
showError();
}
};
}
// /// export
function cantExport() {
cantExport = () => {
let type = $(this);
if (type.hasClass('exporthrefpdf')) {
// @ts-ignore
type = 'PDF';
} else if (type.hasClass('exporthrefdoc')) {
// @ts-ignore
type = 'Microsoft Word';
} else if (type.hasClass('exporthrefodt')) {
// @ts-ignore
type = 'OpenDocument';
} else {
// @ts-ignore
type = 'this file';
}
alert(html10n.get('pad.impexp.exportdisabled', {type}));
return false;
}
// ///
const self = {
init: (_pad) => {
pad = _pad;
init = (_pad: Pad) => {
this.pad = _pad;
// get /p/padname
// if /p/ isn't available due to a rewrite we use the clientVars padId
const padRootPath = /.*\/p\/[^/]+/.exec(document.location.pathname) || clientVars.padId;
// get /p/padname
// if /p/ isn't available due to a rewrite we use the clientVars padId
const padRootPath = /.*\/p\/[^/]+/.exec(document.location.pathname) || window.clientVars.padId;
// i10l buttom import
// i10l buttom import
$('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));
html10n.bind('localized', () => {
$('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));
html10n.bind('localized', () => {
$('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));
});
});
// build the export links
$('#exporthtmla').attr('href', `${padRootPath}/export/html`);
$('#exportetherpada').attr('href', `${padRootPath}/export/etherpad`);
$('#exportplaina').attr('href', `${padRootPath}/export/txt`);
// build the export links
$('#exporthtmla').attr('href', `${padRootPath}/export/html`);
$('#exportetherpada').attr('href', `${padRootPath}/export/etherpad`);
$('#exportplaina').attr('href', `${padRootPath}/export/txt`);
// hide stuff thats not avaible if abiword/soffice is disabled
if (clientVars.exportAvailable === 'no') {
$('#exportworda').remove();
$('#exportpdfa').remove();
$('#exportopena').remove();
// hide stuff thats not avaible if abiword/soffice is disabled
if (window.clientVars.exportAvailable === 'no') {
$('#exportworda').remove();
$('#exportpdfa').remove();
$('#exportopena').remove();
$('#importmessageabiword').show();
} else if (clientVars.exportAvailable === 'withoutPDF') {
$('#exportpdfa').remove();
$('#importmessageabiword').show();
} else if (window.clientVars.exportAvailable === 'withoutPDF') {
$('#exportpdfa').remove();
$('#exportworda').attr('href', `${padRootPath}/export/doc`);
$('#exportopena').attr('href', `${padRootPath}/export/odt`);
$('#exportworda').attr('href', `${padRootPath}/export/doc`);
$('#exportopena').attr('href', `${padRootPath}/export/odt`);
$('#importexport').css({height: '142px'});
$('#importexportline').css({height: '142px'});
} else {
$('#exportworda').attr('href', `${padRootPath}/export/doc`);
$('#exportpdfa').attr('href', `${padRootPath}/export/pdf`);
$('#exportopena').attr('href', `${padRootPath}/export/odt`);
}
$('#importexport').css({height: '142px'});
$('#importexportline').css({height: '142px'});
} else {
$('#exportworda').attr('href', `${padRootPath}/export/doc`);
$('#exportpdfa').attr('href', `${padRootPath}/export/pdf`);
$('#exportopena').attr('href', `${padRootPath}/export/odt`);
}
addImportFrames();
$('#importfileinput').on('change', fileInputUpdated);
$('#importform').off('submit').on('submit', fileInputSubmit);
$('.disabledexport').on('click', cantExport);
},
disable: () => {
$('#impexp-disabled-clickcatcher').show();
$('#import').css('opacity', 0.5);
$('#impexp-export').css('opacity', 0.5);
},
enable: () => {
$('#impexp-disabled-clickcatcher').hide();
$('#import').css('opacity', 1);
$('#impexp-export').css('opacity', 1);
},
};
return self;
})();
this.addImportFrames();
$('#importfileinput').on('change', this.fileInputUpdated);
$('#importform').off('submit').on('submit', this.fileInputSubmit);
$('.disabledexport').on('click', this.cantExport);
}
exports.padimpexp = padimpexp;
disable= () => {
$('#impexp-disabled-clickcatcher').show();
$('#import').css('opacity', 0.5);
$('#impexp-export').css('opacity', 0.5);
}
enable= () => {
$('#impexp-disabled-clickcatcher').hide();
$('#import').css('opacity', 1);
$('#impexp-export').css('opacity', 1);
}
}
export const padImpExp = new PadImpExp();

View file

@ -6,6 +6,8 @@
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
import {Pad} from "./pad";
/**
* Copyright 2009 Google Inc.
*
@ -23,33 +25,34 @@
*/
const padeditbar = require('./pad_editbar').padeditbar;
const automaticReconnect = require('./pad_automatic_reconnect');
import {showCountDownTimerToReconnectOnModal} from './pad_automatic_reconnect'
const padmodals = (() => {
let pad = undefined;
const self = {
init: (_pad) => {
pad = _pad;
},
showModal: (messageId) => {
padeditbar.toggleDropDown('none');
$('#connectivity .visible').removeClass('visible');
$(`#connectivity .${messageId}`).addClass('visible');
class PadModals {
private pad?: Pad
const $modal = $(`#connectivity .${messageId}`);
automaticReconnect.showCountDownTimerToReconnectOnModal($modal, pad);
constructor() {
}
padeditbar.toggleDropDown('connectivity');
},
showOverlay: () => {
// Prevent the user to interact with the toolbar. Useful when user is disconnected for example
$('#toolbar-overlay').show();
},
hideOverlay: () => {
$('#toolbar-overlay').hide();
},
};
return self;
})();
init = (pad: Pad) => {
this.pad = pad
}
showModal = (messageId: string) => {
padeditbar.toggleDropDown('none');
$('#connectivity .visible').removeClass('visible');
$(`#connectivity .${messageId}`).addClass('visible');
exports.padmodals = padmodals;
const $modal = $(`#connectivity .${messageId}`);
showCountDownTimerToReconnectOnModal($modal, this.pad!);
padeditbar.toggleDropDown('connectivity');
}
showOverlay = () => {
// Prevent the user to interact with the toolbar. Useful when user is disconnected for example
$('#toolbar-overlay').show();
}
hideOverlay = () => {
$('#toolbar-overlay').hide();
}
}
export const padModals = new PadModals()

View file

@ -1,5 +1,8 @@
'use strict';
import html10n from "./vendors/html10n";
import {Pad} from "./pad";
/**
* Copyright 2012 Peter 'Pita' Martischka
*
@ -16,10 +19,8 @@
* limitations under the License.
*/
let pad;
exports.saveNow = () => {
pad.collabClient.sendMessage({type: 'SAVE_REVISION'});
export const saveNow = (pad: Pad) => {
pad!.collabClient!.sendMessage({type: 'SAVE_REVISION'});
window.$.gritter.add({
// (string | mandatory) the heading of the notification
title: html10n.get('pad.savedrevs.marked'),
@ -32,7 +33,3 @@ exports.saveNow = () => {
class_name: 'saved-revision',
});
};
exports.init = (_pad) => {
pad = _pad;
};

View file

@ -1,610 +0,0 @@
'use strict';
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {padUtils as padutils} from "./pad_utils";
const hooks = require('./pluginfw/hooks');
import html10n from './vendors/html10n';
let myUserInfo = {};
let colorPickerOpen = false;
let colorPickerSetup = false;
const paduserlist = (() => {
const rowManager = (() => {
// The row manager handles rendering rows of the user list and animating
// their insertion, removal, and reordering. It manipulates TD height
// and TD opacity.
const nextRowId = () => `usertr${nextRowId.counter++}`;
nextRowId.counter = 1;
// objects are shared; fields are "domId","data","animationStep"
const rowsFadingOut = []; // unordered set
const rowsFadingIn = []; // unordered set
const rowsPresent = []; // in order
const ANIMATION_START = -12; // just starting to fade in
const ANIMATION_END = 12; // just finishing fading out
const animateStep = () => {
// animation must be symmetrical
for (let i = rowsFadingIn.length - 1; i >= 0; i--) { // backwards to allow removal
const row = rowsFadingIn[i];
const step = ++row.animationStep;
const animHeight = getAnimationHeight(step, row.animationPower);
const node = rowNode(row);
const baseOpacity = (row.opacity === undefined ? 1 : row.opacity);
if (step <= -OPACITY_STEPS) {
node.find('td').height(animHeight);
} else if (step === -OPACITY_STEPS + 1) {
node.empty().append(createUserRowTds(animHeight, row.data))
.find('td').css('opacity', baseOpacity * 1 / OPACITY_STEPS);
} else if (step < 0) {
node.find('td').css('opacity', baseOpacity * (OPACITY_STEPS - (-step)) / OPACITY_STEPS)
.height(animHeight);
} else if (step === 0) {
// set HTML in case modified during animation
node.empty().append(createUserRowTds(animHeight, row.data))
.find('td').css('opacity', baseOpacity * 1).height(animHeight);
rowsFadingIn.splice(i, 1); // remove from set
}
}
for (let i = rowsFadingOut.length - 1; i >= 0; i--) { // backwards to allow removal
const row = rowsFadingOut[i];
const step = ++row.animationStep;
const node = rowNode(row);
const animHeight = getAnimationHeight(step, row.animationPower);
const baseOpacity = (row.opacity === undefined ? 1 : row.opacity);
if (step < OPACITY_STEPS) {
node.find('td').css('opacity', baseOpacity * (OPACITY_STEPS - step) / OPACITY_STEPS)
.height(animHeight);
} else if (step === OPACITY_STEPS) {
node.empty().append(createEmptyRowTds(animHeight));
} else if (step <= ANIMATION_END) {
node.find('td').height(animHeight);
} else {
rowsFadingOut.splice(i, 1); // remove from set
node.remove();
}
}
handleOtherUserInputs();
return (rowsFadingIn.length > 0) || (rowsFadingOut.length > 0); // is more to do
};
const getAnimationHeight = (step, power) => {
let a = Math.abs(step / 12);
if (power === 2) a **= 2;
else if (power === 3) a **= 3;
else if (power === 4) a **= 4;
else if (power >= 5) a **= 5;
return Math.round(26 * (1 - a));
};
const OPACITY_STEPS = 6;
const ANIMATION_STEP_TIME = 20;
const LOWER_FRAMERATE_FACTOR = 2;
const {scheduleAnimation} =
padutils.makeAnimationScheduler(animateStep, ANIMATION_STEP_TIME, LOWER_FRAMERATE_FACTOR);
const NUMCOLS = 4;
// we do lots of manipulation of table rows and stuff that JQuery makes ok, despite
// IE's poor handling when manipulating the DOM directly.
const createEmptyRowTds = (height) => $('<td>')
.attr('colspan', NUMCOLS)
.css('border', 0)
.css('height', `${height}px`);
const isNameEditable = (data) => (!data.name) && (data.status !== 'Disconnected');
const replaceUserRowContents = (tr, height, data) => {
const tds = createUserRowTds(height, data);
if (isNameEditable(data) && tr.find('td.usertdname input:enabled').length > 0) {
// preserve input field node
tds.each((i, td) => {
const oldTd = $(tr.find('td').get(i));
if (!oldTd.hasClass('usertdname')) {
oldTd.replaceWith(td);
} else {
// Prevent leak. I'm not 100% confident that this is necessary, but it shouldn't hurt.
$(td).remove();
}
});
} else {
tr.empty().append(tds);
}
return tr;
};
const createUserRowTds = (height, data) => {
let name;
if (data.name) {
name = document.createTextNode(data.name);
} else {
name = $('<input>')
.attr('data-l10n-id', 'pad.userlist.unnamed')
.attr('type', 'text')
.addClass('editempty')
.addClass('newinput')
.attr('value', html10n.get('pad.userlist.unnamed'));
if (isNameEditable(data)) name.attr('disabled', 'disabled');
}
return $()
.add($('<td>')
.css('height', `${height}px`)
.addClass('usertdswatch')
.append($('<div>')
.addClass('swatch')
.css('background', padutils.escapeHtml(data.color))
.html('&nbsp;')))
.add($('<td>')
.css('height', `${height}px`)
.addClass('usertdname')
.append(name))
.add($('<td>')
.css('height', `${height}px`)
.addClass('activity')
.text(data.activity));
};
const createRow = (id, contents, authorId) => $('<tr>')
.attr('data-authorId', authorId)
.attr('id', id)
.append(contents);
const rowNode = (row) => $(`#${row.domId}`);
const handleRowData = (row) => {
if (row.data && row.data.status === 'Disconnected') {
row.opacity = 0.5;
} else {
delete row.opacity;
}
};
const handleOtherUserInputs = () => {
// handle 'INPUT' elements for naming other unnamed users
$('#otheruserstable input.newinput').each(function () {
const input = $(this);
const tr = input.closest('tr');
if (tr.length > 0) {
const index = tr.parent().children().index(tr);
if (index >= 0) {
const userId = rowsPresent[index].data.id;
rowManagerMakeNameEditor($(this), userId);
}
}
}).removeClass('newinput');
};
// animationPower is 0 to skip animation, 1 for linear, 2 for quadratic, etc.
const insertRow = (position, data, animationPower) => {
position = Math.max(0, Math.min(rowsPresent.length, position));
animationPower = (animationPower === undefined ? 4 : animationPower);
const domId = nextRowId();
const row = {
data,
animationStep: ANIMATION_START,
domId,
animationPower,
};
const authorId = data.id;
handleRowData(row);
rowsPresent.splice(position, 0, row);
let tr;
if (animationPower === 0) {
tr = createRow(domId, createUserRowTds(getAnimationHeight(0), data), authorId);
row.animationStep = 0;
} else {
rowsFadingIn.push(row);
tr = createRow(domId, createEmptyRowTds(getAnimationHeight(ANIMATION_START)), authorId);
}
$('table#otheruserstable').show();
if (position === 0) {
$('table#otheruserstable').prepend(tr);
} else {
rowNode(rowsPresent[position - 1]).after(tr);
}
if (animationPower !== 0) {
scheduleAnimation();
}
handleOtherUserInputs();
return row;
};
const updateRow = (position, data) => {
const row = rowsPresent[position];
if (row) {
row.data = data;
handleRowData(row);
if (row.animationStep === 0) {
// not currently animating
const tr = rowNode(row);
replaceUserRowContents(tr, getAnimationHeight(0), row.data)
.find('td')
.css('opacity', (row.opacity === undefined ? 1 : row.opacity));
handleOtherUserInputs();
}
}
};
const removeRow = (position, animationPower) => {
animationPower = (animationPower === undefined ? 4 : animationPower);
const row = rowsPresent[position];
if (row) {
rowsPresent.splice(position, 1); // remove
if (animationPower === 0) {
rowNode(row).remove();
} else {
row.animationStep = -row.animationStep; // use symmetry
row.animationPower = animationPower;
rowsFadingOut.push(row);
scheduleAnimation();
}
}
if (rowsPresent.length === 0) {
$('table#otheruserstable').hide();
}
};
// newPosition is position after the row has been removed
const moveRow = (oldPosition, newPosition, animationPower) => {
animationPower = (animationPower === undefined ? 1 : animationPower); // linear is best
const row = rowsPresent[oldPosition];
if (row && oldPosition !== newPosition) {
const rowData = row.data;
removeRow(oldPosition, animationPower);
insertRow(newPosition, rowData, animationPower);
}
};
const self = {
insertRow,
removeRow,
moveRow,
updateRow,
};
return self;
})(); // //////// rowManager
const otherUsersInfo = [];
const otherUsersData = [];
const rowManagerMakeNameEditor = (jnode, userId) => {
setUpEditable(jnode, () => {
const existingIndex = findExistingIndex(userId);
if (existingIndex >= 0) {
return otherUsersInfo[existingIndex].name || '';
} else {
return '';
}
}, (newName) => {
if (!newName) {
jnode.addClass('editempty');
jnode.val(html10n.get('pad.userlist.unnamed'));
} else {
jnode.attr('disabled', 'disabled');
pad.suggestUserName(userId, newName);
}
});
};
const findExistingIndex = (userId) => {
let existingIndex = -1;
for (let i = 0; i < otherUsersInfo.length; i++) {
if (otherUsersInfo[i].userId === userId) {
existingIndex = i;
break;
}
}
return existingIndex;
};
const setUpEditable = (jqueryNode, valueGetter, valueSetter) => {
jqueryNode.on('focus', (evt) => {
const oldValue = valueGetter();
if (jqueryNode.val() !== oldValue) {
jqueryNode.val(oldValue);
}
jqueryNode.addClass('editactive').removeClass('editempty');
});
jqueryNode.on('blur', (evt) => {
const newValue = jqueryNode.removeClass('editactive').val();
valueSetter(newValue);
});
padutils.bindEnterAndEscape(jqueryNode, () => {
jqueryNode.trigger('blur');
}, () => {
jqueryNode.val(valueGetter()).trigger('blur');
});
jqueryNode.prop('disabled', false).addClass('editable');
};
let pad = undefined;
const self = {
init: (myInitialUserInfo, _pad) => {
pad = _pad;
self.setMyUserInfo(myInitialUserInfo);
if ($('#online_count').length === 0) {
$('#editbar [data-key=showusers] > a').append('<span id="online_count">1</span>');
}
$('#otheruserstable tr').remove();
$('#myusernameedit').addClass('myusernameedithoverable');
setUpEditable($('#myusernameedit'), () => myUserInfo.name || '', (newValue) => {
myUserInfo.name = newValue;
pad.notifyChangeName(newValue);
// wrap with setTimeout to do later because we get
// a double "blur" fire in IE...
window.setTimeout(() => {
self.renderMyUserInfo();
}, 0);
});
// color picker
$('#myswatchbox').on('click', showColorPicker);
$('#mycolorpicker .pickerswatchouter').on('click', function () {
$('#mycolorpicker .pickerswatchouter').removeClass('picked');
$(this).addClass('picked');
});
$('#mycolorpickersave').on('click', () => {
closeColorPicker(true);
});
$('#mycolorpickercancel').on('click', () => {
closeColorPicker(false);
});
//
},
usersOnline: () => {
// Returns an object of users who are currently online on this pad
// Make a copy of the otherUsersInfo, otherwise every call to users
// modifies the referenced array
const userList = [].concat(otherUsersInfo);
// Now we need to add ourselves..
userList.push(myUserInfo);
return userList;
},
users: () => {
// Returns an object of users who have been on this pad
const userList = self.usersOnline();
// Now we add historical authors
const historical = clientVars.collab_client_vars.historicalAuthorData;
for (const [key, {userId}] of Object.entries(historical)) {
// Check we don't already have this author in our array
let exists = false;
userList.forEach((user) => {
if (user.userId === userId) exists = true;
});
if (exists === false) {
userList.push(historical[key]);
}
}
return userList;
},
setMyUserInfo: (info) => {
// translate the colorId
if (typeof info.colorId === 'number') {
info.colorId = clientVars.colorPalette[info.colorId];
}
myUserInfo = $.extend(
{}, info);
self.renderMyUserInfo();
},
userJoinOrUpdate: (info) => {
if ((!info.userId) || (info.userId === myUserInfo.userId)) {
// not sure how this would happen
return;
}
hooks.callAll('userJoinOrUpdate', {
userInfo: info,
});
const userData = {};
userData.color = typeof info.colorId === 'number'
? clientVars.colorPalette[info.colorId] : info.colorId;
userData.name = info.name;
userData.status = '';
userData.activity = '';
userData.id = info.userId;
const existingIndex = findExistingIndex(info.userId);
let numUsersBesides = otherUsersInfo.length;
if (existingIndex >= 0) {
numUsersBesides--;
}
const newIndex = padutils.binarySearch(numUsersBesides, (n) => {
if (existingIndex >= 0 && n >= existingIndex) {
// pretend existingIndex isn't there
n++;
}
const infoN = otherUsersInfo[n];
const nameN = (infoN.name || '').toLowerCase();
const nameThis = (info.name || '').toLowerCase();
const idN = infoN.userId;
const idThis = info.userId;
return (nameN > nameThis) || (nameN === nameThis && idN > idThis);
});
if (existingIndex >= 0) {
// update
if (existingIndex === newIndex) {
otherUsersInfo[existingIndex] = info;
otherUsersData[existingIndex] = userData;
rowManager.updateRow(existingIndex, userData);
} else {
otherUsersInfo.splice(existingIndex, 1);
otherUsersData.splice(existingIndex, 1);
otherUsersInfo.splice(newIndex, 0, info);
otherUsersData.splice(newIndex, 0, userData);
rowManager.updateRow(existingIndex, userData);
rowManager.moveRow(existingIndex, newIndex);
}
} else {
otherUsersInfo.splice(newIndex, 0, info);
otherUsersData.splice(newIndex, 0, userData);
rowManager.insertRow(newIndex, userData);
}
self.updateNumberOfOnlineUsers();
},
updateNumberOfOnlineUsers: () => {
let online = 1; // you are always online!
for (let i = 0; i < otherUsersData.length; i++) {
if (otherUsersData[i].status === '') {
online++;
}
}
$('#online_count').text(online);
return online;
},
userLeave: (info) => {
const existingIndex = findExistingIndex(info.userId);
if (existingIndex >= 0) {
const userData = otherUsersData[existingIndex];
userData.status = 'Disconnected';
rowManager.updateRow(existingIndex, userData);
if (userData.leaveTimer) {
window.clearTimeout(userData.leaveTimer);
}
// set up a timer that will only fire if no leaves,
// joins, or updates happen for this user in the
// next N seconds, to remove the user from the list.
const thisUserId = info.userId;
const thisLeaveTimer = window.setTimeout(() => {
const newExistingIndex = findExistingIndex(thisUserId);
if (newExistingIndex >= 0) {
const newUserData = otherUsersData[newExistingIndex];
if (newUserData.status === 'Disconnected' &&
newUserData.leaveTimer === thisLeaveTimer) {
otherUsersInfo.splice(newExistingIndex, 1);
otherUsersData.splice(newExistingIndex, 1);
rowManager.removeRow(newExistingIndex);
hooks.callAll('userLeave', {
userInfo: info,
});
}
}
}, 8000); // how long to wait
userData.leaveTimer = thisLeaveTimer;
}
self.updateNumberOfOnlineUsers();
},
renderMyUserInfo: () => {
if (myUserInfo.name) {
$('#myusernameedit').removeClass('editempty').val(myUserInfo.name);
} else {
$('#myusernameedit').attr('placeholder', html10n.get('pad.userlist.entername'));
}
if (colorPickerOpen) {
$('#myswatchbox').addClass('myswatchboxunhoverable').removeClass('myswatchboxhoverable');
} else {
$('#myswatchbox').addClass('myswatchboxhoverable').removeClass('myswatchboxunhoverable');
}
$('#myswatch').css({'background-color': myUserInfo.colorId});
$('li[data-key=showusers] > a').css({'box-shadow': `inset 0 0 30px ${myUserInfo.colorId}`});
},
};
return self;
})();
const getColorPickerSwatchIndex = (jnode) => $('#colorpickerswatches li').index(jnode);
const closeColorPicker = (accept) => {
if (accept) {
let newColor = $('#mycolorpickerpreview').css('background-color');
const parts = newColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
// parts now should be ["rgb(0, 70, 255", "0", "70", "255"]
if (parts) {
delete (parts[0]);
for (let i = 1; i <= 3; ++i) {
parts[i] = parseInt(parts[i]).toString(16);
if (parts[i].length === 1) parts[i] = `0${parts[i]}`;
}
newColor = `#${parts.join('')}`; // "0070ff"
}
myUserInfo.colorId = newColor;
pad.notifyChangeColor(newColor);
paduserlist.renderMyUserInfo();
} else {
// pad.notifyChangeColor(previousColorId);
// paduserlist.renderMyUserInfo();
}
colorPickerOpen = false;
$('#mycolorpicker').removeClass('popup-show');
};
const showColorPicker = () => {
$.farbtastic('#colorpicker').setColor(myUserInfo.colorId);
if (!colorPickerOpen) {
const palette = pad.getColorPalette();
if (!colorPickerSetup) {
const colorsList = $('#colorpickerswatches');
for (let i = 0; i < palette.length; i++) {
const li = $('<li>', {
style: `background: ${palette[i]};`,
});
li.appendTo(colorsList);
li.on('click', (event) => {
$('#colorpickerswatches li').removeClass('picked');
$(event.target).addClass('picked');
const newColorId = getColorPickerSwatchIndex($('#colorpickerswatches .picked'));
pad.notifyChangeColor(newColorId);
});
}
colorPickerSetup = true;
}
$('#mycolorpicker').addClass('popup-show');
colorPickerOpen = true;
$('#colorpickerswatches li').removeClass('picked');
$($('#colorpickerswatches li')[myUserInfo.colorId]).addClass('picked'); // seems weird
}
};
exports.paduserlist = paduserlist;

View file

@ -0,0 +1,661 @@
'use strict';
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {padUtils as padutils} from "./pad_utils";
const hooks = require('./pluginfw/hooks');
import html10n from './vendors/html10n';
import {UserInfo} from "./types/SocketIOMessage";
import {Pad} from "./pad";
let colorPickerOpen = false;
let colorPickerSetup = false;
type RowData = {
name: string
status: string
color: string
activity: string
id: string
}
type Row = {
data?: RowData
animationPower?: number,
animationStep?: number,
opacity?: number
domId?: string
}
type UserData = {
color? : number
name? : string
status? : string
activity? : string
id? : string
leaveTimer?: number
} & RowData
class RowManager {
// The row manager handles rendering rows of the user list and animating
// their insertion, removal, and reordering. It manipulates TD height
// and TD opacity.
nextRowIdCounter = 1;
nextRowId = () => `usertr${this.nextRowIdCounter++}`;
// objects are shared; fields are "domId","data","animationStep"
rowsFadingOut: Row[] = []; // unordered set
rowsFadingIn: Row[] = []; // unordered set
OPACITY_STEPS = 6;
ANIMATION_STEP_TIME = 20;
LOWER_FRAMERATE_FACTOR = 2;
scheduleAnimation: () => void
rowsPresent: Row[] = []; // in order
ANIMATION_START = -12; // just starting to fade in
ANIMATION_END = 12; // just finishing fading out
NUMCOLS = 4;
private padUserList: PadUserList;
constructor(p: PadUserList) {
let {scheduleAnimation} = padutils.makeAnimationScheduler(this.animateStep, this.ANIMATION_STEP_TIME, this.LOWER_FRAMERATE_FACTOR);
this.scheduleAnimation = scheduleAnimation
this.padUserList = p
}
animateStep = () => {
// animation must be symmetrical
for (let i = this.rowsFadingIn.length - 1; i >= 0; i--) { // backwards to allow removal
const row = this.rowsFadingIn[i];
const step = ++row.animationStep!;
const animHeight = this.getAnimationHeight(step, row.animationPower);
const node = this.rowNode(row);
const baseOpacity = (row.opacity === undefined ? 1 : row.opacity);
if (step <= -this.OPACITY_STEPS) {
node.find('td').height(animHeight);
} else if (step === -this.OPACITY_STEPS + 1) {
node.empty().append(this.createUserRowTds(animHeight, row.data!))
.find('td').css('opacity', baseOpacity * 1 / this.OPACITY_STEPS);
} else if (step < 0) {
node.find('td').css('opacity', baseOpacity * (this.OPACITY_STEPS - (-step)) / this.OPACITY_STEPS)
.height(animHeight);
} else if (step === 0) {
// set HTML in case modified during animation
node.empty().append(this.createUserRowTds(animHeight, row.data!))
.find('td').css('opacity', baseOpacity * 1).height(animHeight);
this.rowsFadingIn.splice(i, 1); // remove from set
}
}
for (let i = this.rowsFadingOut.length - 1; i >= 0; i--) { // backwards to allow removal
const row = this.rowsFadingOut[i];
const step = ++row.animationStep!;
const node = this.rowNode(row);
const animHeight = this.getAnimationHeight(step, row.animationPower);
const baseOpacity = (row.opacity === undefined ? 1 : row.opacity);
if (step < this.OPACITY_STEPS) {
node.find('td').css('opacity', baseOpacity * (this.OPACITY_STEPS - step) / this.OPACITY_STEPS)
.height(animHeight);
} else if (step === this.OPACITY_STEPS) {
node.empty().append(this.createEmptyRowTds(animHeight));
} else if (step <= this.ANIMATION_END) {
node.find('td').height(animHeight);
} else {
this.rowsFadingOut.splice(i, 1); // remove from set
node.remove();
}
}
this.handleOtherUserInputs();
return (this.rowsFadingIn.length > 0) || (this.rowsFadingOut.length > 0); // is more to do
}
getAnimationHeight = (step: number, power?: number) => {
let a = Math.abs(step / 12);
if (power === 2) a **= 2;
else if (power === 3) a **= 3;
else if (power === 4) a **= 4;
else if (power! >= 5) a **= 5;
return Math.round(26 * (1 - a));
}
// we do lots of manipulation of table rows and stuff that JQuery makes ok, despite
// IE's poor handling when manipulating the DOM directly.
createEmptyRowTds = (height: number) => $('<td>')
.attr('colspan', this.NUMCOLS)
.css('border', 0)
.css('height', `${height}px`);
isNameEditable = (data: RowData) => (!data.name) && (data.status !== 'Disconnected');
replaceUserRowContents = (tr: JQuery<HTMLElement>, height: number, data: RowData) => {
const tds = this.createUserRowTds(height, data);
if (this.isNameEditable(data) && tr.find('td.usertdname input:enabled').length > 0) {
// preserve input field node
tds.each((i, td) => {
// @ts-ignore
const oldTd = $(tr.find('td').get(i)) as JQuery<HTMLElement>;
if (!oldTd.hasClass('usertdname')) {
oldTd.replaceWith(td);
} else {
// Prevent leak. I'm not 100% confident that this is necessary, but it shouldn't hurt.
$(td).remove();
}
});
} else {
tr.empty().append(tds);
}
return tr;
}
createUserRowTds = (height: number, data: RowData) => {
let name;
if (data.name) {
name = document.createTextNode(data.name);
} else {
name = $('<input>')
.attr('data-l10n-id', 'pad.userlist.unnamed')
.attr('type', 'text')
.addClass('editempty')
.addClass('newinput')
.attr('value', html10n.get('pad.userlist.unnamed'));
if (this.isNameEditable(data)) name.attr('disabled', 'disabled');
}
return $()
.add($('<td>')
.css('height', `${height}px`)
.addClass('usertdswatch')
.append($('<div>')
.addClass('swatch')
.css('background', padutils.escapeHtml(data.color))
.html('&nbsp;')))
.add($('<td>')
.css('height', `${height}px`)
.addClass('usertdname')
.append(name))
.add($('<td>')
.css('height', `${height}px`)
.addClass('activity')
.text(data.activity));
}
createRow = (id: string, contents: JQuery<HTMLElement>, authorId: string) => $('<tr>')
.attr('data-authorId', authorId)
.attr('id', id)
.append(contents);
rowNode = (row: Row) => $(`#${row.domId}`);
handleRowData = (row: Row) => {
if (row.data && row.data.status === 'Disconnected') {
row.opacity = 0.5;
} else {
delete row.opacity;
}
}
handleOtherUserInputs = () => {
// handle 'INPUT' elements for naming other unnamed users
$('#otheruserstable input.newinput').each(() => {
const input = $(this);
// @ts-ignore
const tr = input.closest('tr') as JQuery<HTMLElement>
if (tr.length > 0) {
const index = tr.parent().children().index(tr);
if (index >= 0) {
const userId = this.rowsPresent[index].data!.id;
// @ts-ignore
this.padUserList.rowManagerMakeNameEditor($(this) as JQuery<HTMLElement>, userId);
}
}
}).removeClass('newinput');
}
insertRow = (position: number, data: RowData, animationPower?: number) => {
position = Math.max(0, Math.min(this.rowsPresent.length, position));
animationPower = (animationPower === undefined ? 4 : animationPower);
const domId = this.nextRowId();
const row = {
data,
animationStep: this.ANIMATION_START,
domId,
animationPower,
};
const authorId = data.id;
this.handleRowData(row);
this.rowsPresent.splice(position, 0, row);
let tr;
if (animationPower === 0) {
tr = this.createRow(domId, this.createUserRowTds(this.getAnimationHeight(0), data), authorId);
row.animationStep = 0;
} else {
this.rowsFadingIn.push(row);
tr = this.createRow(domId, this.createEmptyRowTds(this.getAnimationHeight(this.ANIMATION_START)), authorId);
}
$('table#otheruserstable').show();
if (position === 0) {
$('table#otheruserstable').prepend(tr);
} else {
this.rowNode(this.rowsPresent[position - 1]).after(tr);
}
if (animationPower !== 0) {
this.scheduleAnimation();
}
this.handleOtherUserInputs();
return row;
}
updateRow = (position: number, data: UserData) => {
const row = this.rowsPresent[position];
if (row) {
row.data = data;
this.handleRowData(row);
if (row.animationStep === 0) {
// not currently animating
const tr = this.rowNode(row);
this.replaceUserRowContents(tr, this.getAnimationHeight(0), row.data)
.find('td')
.css('opacity', (row.opacity === undefined ? 1 : row.opacity));
this.handleOtherUserInputs();
}
}
}
// animationPower is 0 to skip animation, 1 for linear, 2 for quadratic, etc.
removeRow = (position: number, animationPower?: number) => {
animationPower = (animationPower === undefined ? 4 : animationPower);
const row = this.rowsPresent[position];
if (row) {
this.rowsPresent.splice(position, 1); // remove
if (animationPower === 0) {
this.rowNode(row).remove();
} else {
row.animationStep = -row.animationStep!; // use symmetry
row.animationPower = animationPower;
this.rowsFadingOut.push(row);
this.scheduleAnimation();
}
}
if (this.rowsPresent.length === 0) {
$('table#otheruserstable').hide();
}
}
// newPosition is position after the row has been removed
moveRow = (oldPosition: number, newPosition: number, animationPower?: number) => {
animationPower = (animationPower === undefined ? 1 : animationPower); // linear is best
const row = this.rowsPresent[oldPosition];
if (row && oldPosition !== newPosition) {
const rowData = row.data;
this.removeRow(oldPosition, animationPower);
this.insertRow(newPosition, rowData!, animationPower);
}
}
}
class PadUserList {
private rowManager: RowManager;
private otherUsersInfo: UserInfo[] = [];
private otherUsersData: UserData[] = [];
private pad?: Pad = undefined;
private myUserInfo?: UserInfo
constructor() {
this.rowManager = new RowManager(this)
}
rowManagerMakeNameEditor = (jnode: JQuery<HTMLElement>, userId: string) => {
this.setUpEditable(jnode, () => {
const existingIndex = this.findExistingIndex(userId);
if (existingIndex >= 0) {
return this.otherUsersInfo[existingIndex].name || '';
} else {
return '';
}
}, (newName: string) => {
if (!newName) {
jnode.addClass('editempty');
jnode.val(html10n.get('pad.userlist.unnamed'));
} else {
jnode.attr('disabled', 'disabled');
pad.suggestUserName(userId, newName);
}
})
}
findExistingIndex = (userId: string) => {
let existingIndex = -1;
for (let i = 0; i < this.otherUsersInfo.length; i++) {
if (this.otherUsersInfo[i].userId === userId) {
existingIndex = i;
break;
}
}
return existingIndex;
}
setUpEditable = (jqueryNode: JQuery<HTMLElement>, valueGetter: () => any, valueSetter: (val: any) => void) => {
jqueryNode.on('focus', (evt) => {
const oldValue = valueGetter();
if (jqueryNode.val() !== oldValue) {
jqueryNode.val(oldValue);
}
jqueryNode.addClass('editactive').removeClass('editempty');
});
jqueryNode.on('blur', (evt) => {
const newValue = jqueryNode.removeClass('editactive').val();
valueSetter(newValue);
});
padutils.bindEnterAndEscape(jqueryNode, () => {
jqueryNode.trigger('blur');
}, () => {
jqueryNode.val(valueGetter()).trigger('blur');
});
jqueryNode.prop('disabled', false).addClass('editable');
}
init = (myInitialUserInfo: UserInfo, _pad: Pad) => {
this.pad = _pad;
this.setMyUserInfo(myInitialUserInfo);
if ($('#online_count').length === 0) {
$('#editbar [data-key=showusers] > a').append('<span id="online_count">1</span>');
}
$('#otheruserstable tr').remove();
$('#myusernameedit').addClass('myusernameedithoverable');
this.setUpEditable($('#myusernameedit'), () => this.myUserInfo!.name || '', (newValue) => {
this.myUserInfo!.name = newValue;
pad.notifyChangeName(newValue);
// wrap with setTimeout to do later because we get
// a double "blur" fire in IE...
window.setTimeout(() => {
this.renderMyUserInfo();
}, 0);
});
// color picker
$('#myswatchbox').on('click', this.showColorPicker);
$('#mycolorpicker .pickerswatchouter').on('click', function () {
$('#mycolorpicker .pickerswatchouter').removeClass('picked');
$(this).addClass('picked');
});
$('#mycolorpickersave').on('click', () => {
this.closeColorPicker(true);
});
$('#mycolorpickercancel').on('click', () => {
this.closeColorPicker(false);
});
//
}
usersOnline = () => {
// Returns an object of users who are currently online on this pad
// Make a copy of the otherUsersInfo, otherwise every call to users
// modifies the referenced array
let newConcat: UserInfo[] = []
const userList: UserInfo[] = newConcat.concat(this.otherUsersInfo);
// Now we need to add ourselves..
userList.push(this.myUserInfo!);
return userList;
}
users = () => {
// Returns an object of users who have been on this pad
const userList = this.usersOnline();
// Now we add historical authors
const historical = window.clientVars.collab_client_vars.historicalAuthorData;
for (const [key,
{
userId
}
]
of
Object.entries(historical)
) {
// Check we don't already have this author in our array
let exists = false;
userList.forEach((user) => {
if (user.userId === userId) exists = true;
});
if (exists === false) {
userList.push(historical[key]);
}
}
return userList;
}
setMyUserInfo = (info: UserInfo) => {
// translate the colorId
if (typeof info.colorId === 'number') {
info.colorId = window.clientVars.colorPalette[info.colorId];
}
this.myUserInfo = $.extend(
{}, info);
this.renderMyUserInfo();
}
userJoinOrUpdate
=
(info: UserInfo) => {
if ((!info.userId) || (info.userId === this.myUserInfo!.userId)) {
// not sure how this would happen
return;
}
hooks.callAll('userJoinOrUpdate', {
userInfo: info,
});
// @ts-ignore
const userData: UserData = {};
// @ts-ignore
userData.color = typeof info.colorId === 'number'
? window.clientVars.colorPalette[info.colorId] : info.colorId!;
userData.name = info.name;
userData.status = '';
userData.activity = '';
userData.id = info.userId;
const existingIndex = this.findExistingIndex(info.userId);
let numUsersBesides = this.otherUsersInfo.length;
if (existingIndex >= 0) {
numUsersBesides--;
}
const newIndex = padutils.binarySearch(numUsersBesides, (n: number) => {
if (existingIndex >= 0 && n >= existingIndex) {
// pretend existingIndex isn't there
n++;
}
const infoN = this.otherUsersInfo[n];
const nameN = (infoN.name || '').toLowerCase();
const nameThis = (info.name || '').toLowerCase();
const idN = infoN.userId;
const idThis = info.userId;
return (nameN > nameThis) || (nameN === nameThis && idN > idThis);
});
if (existingIndex >= 0) {
// update
if (existingIndex === newIndex) {
this.otherUsersInfo[existingIndex] = info;
this.otherUsersData[existingIndex] = userData;
this.rowManager.updateRow(existingIndex, userData!);
} else {
this.otherUsersInfo.splice(existingIndex, 1);
this.otherUsersData.splice(existingIndex, 1);
this.otherUsersInfo.splice(newIndex, 0, info);
this.otherUsersData.splice(newIndex, 0, userData);
this.rowManager.updateRow(existingIndex, userData!);
this.rowManager.moveRow(existingIndex, newIndex);
}
} else {
this.otherUsersInfo.splice(newIndex, 0, info);
this.otherUsersData.splice(newIndex, 0, userData);
this.rowManager.insertRow(newIndex, userData);
}
this.updateNumberOfOnlineUsers();
}
updateNumberOfOnlineUsers
=
() => {
let online = 1; // you are always online!
for (let i = 0; i < this.otherUsersData.length; i++) {
if (this.otherUsersData[i].status === '') {
online++;
}
}
$('#online_count').text(online);
return online;
}
userLeave
=
(info: UserInfo) => {
const existingIndex = this.findExistingIndex(info.userId);
if (existingIndex >= 0) {
const userData = this.otherUsersData[existingIndex];
userData.status = 'Disconnected';
this.rowManager.updateRow(existingIndex, userData);
if (userData.leaveTimer) {
window.clearTimeout(userData.leaveTimer);
}
// set up a timer that will only fire if no leaves,
// joins, or updates happen for this user in the
// next N seconds, to remove the user from the list.
const thisUserId = info.userId;
const thisLeaveTimer = window.setTimeout(() => {
const newExistingIndex = this.findExistingIndex(thisUserId);
if (newExistingIndex >= 0) {
const newUserData = this.otherUsersData[newExistingIndex];
if (newUserData.status === 'Disconnected' &&
newUserData.leaveTimer === thisLeaveTimer) {
this.otherUsersInfo.splice(newExistingIndex, 1);
this.otherUsersData.splice(newExistingIndex, 1);
this.rowManager.removeRow(newExistingIndex);
hooks.callAll('userLeave', {
userInfo: info,
});
}
}
}, 8000); // how long to wait
userData.leaveTimer = thisLeaveTimer;
}
this.updateNumberOfOnlineUsers();
}
renderMyUserInfo
=
() => {
if (this.myUserInfo!.name) {
$('#myusernameedit').removeClass('editempty').val(this.myUserInfo!.name);
} else {
$('#myusernameedit').attr('placeholder', html10n.get('pad.userlist.entername'));
}
if (colorPickerOpen) {
$('#myswatchbox').addClass('myswatchboxunhoverable').removeClass('myswatchboxhoverable');
} else {
$('#myswatchbox').addClass('myswatchboxhoverable').removeClass('myswatchboxunhoverable');
}
$('#myswatch').css({'background-color': this.myUserInfo!.colorId});
$('li[data-key=showusers] > a').css({'box-shadow': `inset 0 0 30px ${this.myUserInfo!.colorId}`});
}
getColorPickerSwatchIndex = (jnode: JQuery<HTMLElement>) => $('#colorpickerswatches li').index(jnode)
closeColorPicker = (accept: boolean) => {
if (accept) {
let newColor = $('#mycolorpickerpreview').css('background-color');
const parts = newColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
// parts now should be ["rgb(0, 70, 255", "0", "70", "255"]
if (parts) {
// @ts-ignore
delete (parts[0]);
for (let i = 1; i <= 3; ++i) {
parts[i] = parseInt(parts[i]).toString(16);
if (parts[i].length === 1) parts[i] = `0${parts[i]}`;
}
newColor = `#${parts.join('')}`; // "0070ff"
}
// @ts-ignore
this.myUserInfo!.colorId! = newColor;
// @ts-ignore
pad.notifyChangeColor(newColor);
paduserlist.renderMyUserInfo();
} else {
// pad.notifyChangeColor(previousColorId);
// paduserlist.renderMyUserInfo();
}
colorPickerOpen = false;
$('#mycolorpicker').removeClass('popup-show');
}
showColorPicker = () => {
// @ts-ignore
$.farbtastic('#colorpicker').setColor(this.myUserInfo!.colorId);
if (!colorPickerOpen) {
const palette = pad.getColorPalette();
if (!colorPickerSetup) {
const colorsList = $('#colorpickerswatches');
for (let i = 0; i < palette.length; i++) {
const li = $('<li>', {
style: `background: ${palette[i]};`,
});
li.appendTo(colorsList);
li.on('click', (event) => {
$('#colorpickerswatches li').removeClass('picked');
$(event.target).addClass('picked');
const newColorId = this.getColorPickerSwatchIndex($('#colorpickerswatches .picked'));
pad.notifyChangeColor(newColorId);
});
}
colorPickerSetup = true;
}
$('#mycolorpicker').addClass('popup-show');
colorPickerOpen = true;
$('#colorpickerswatches li').removeClass('picked');
$($('#colorpickerswatches li')[this.myUserInfo!.colorId]).addClass('picked'); // seems weird
}
};
}
export const paduserlist = new PadUserList()

View file

@ -10,7 +10,7 @@ import {Socket} from "socket.io";
* https://socket.io/docs/v2/client-api/#new-Manager-url-options
* @return socket.io Socket object
*/
const connect = (etherpadBaseUrl: string, namespace = '/', options = {}): Socket => {
const connect = (etherpadBaseUrl: string, namespace = '/', options = {}): any => {
// The API for socket.io's io() function is awkward. The documentation says that the first
// argument is a URL, but it is not the URL of the socket.io endpoint. The URL's path part is used
// as the name of the socket.io namespace to join, and the rest of the URL (including query
@ -28,15 +28,10 @@ const connect = (etherpadBaseUrl: string, namespace = '/', options = {}): Socke
};
socketOptions = Object.assign(options, socketOptions);
const socket = io(namespaceUrl.href, socketOptions) as unknown as Socket;
const socket = io(namespaceUrl.href, socketOptions);
socket.on('connect_error', (error) => {
console.log('Error connecting to pad', error);
/*if (socket.io.engine.transports.indexOf('polling') === -1) {
console.warn('WebSocket connection failed. Falling back to long-polling.');
socket.io.opts.transports = ['websocket','polling'];
socket.io.engine.upgrade = false;
}*/
});
return socket;

View file

@ -31,10 +31,10 @@ const hooks = require('./pluginfw/hooks');
import connect from './socketio'
import html10n from '../js/vendors/html10n'
import {Socket} from "socket.io";
import {ClientVarMessage, SocketIOMessage} from "./types/SocketIOMessage";
import {ClientVarData, ClientVarMessage, ClientVarPayload, SocketIOMessage} from "./types/SocketIOMessage";
import {Func} from "mocha";
type ChangeSetLoader = {
export type ChangeSetLoader = {
handleMessageFromServer(msg: ClientVarMessage): void
}
@ -80,7 +80,7 @@ export const init = () => {
socket.on('message', (message: ClientVarMessage) => {
if (message.type === 'CLIENT_VARS') {
handleClientVars(message);
} else if (message.accessStatus) {
} else if ("accessStatus" in message) {
$('body').html('<h2>You have no permission to access this pad</h2>');
} else if (message.type === 'CHANGESET_REQ' || message.type === 'COLLABROOM') {
changesetLoader.handleMessageFromServer(message);
@ -111,7 +111,7 @@ const sendSocketMsg = (type: string, data: Object) => {
const fireWhenAllScriptsAreLoaded: Function[] = [];
const handleClientVars = (message: ClientVarMessage) => {
const handleClientVars = (message: ClientVarData) => {
// save the client Vars
window.clientVars = message.data;

View file

@ -1,43 +1,209 @@
import {MapArrayType} from "../../../node/types/MapType";
import {AText} from "./AText";
import AttributePool from "../AttributePool";
import attributePool from "../AttributePool";
export type SocketIOMessage = {
type: string
accessStatus: string
}
export type ClientVarData = {
export type HistoricalAuthorData = MapArrayType<{
name: string;
colorId: number;
userId: string
}>
export type ServerVar = {
rev: number
historicalAuthorData: HistoricalAuthorData,
initialAttributedText: string,
apool: AttributePool
}
export type UserInfo = {
userId: string
colorId: number,
name: string
}
export type ClientVarPayload = {
readOnlyId: string
automaticReconnectionTimeout: number
sessionRefreshInterval: number,
historicalAuthorData:MapArrayType<{
name: string;
colorId: string;
}>,
historicalAuthorData: HistoricalAuthorData,
atext: AText,
apool: AttributePool,
noColors: boolean,
userName: string,
userColor:string,
userColor: number,
hideChat: boolean,
padOptions: MapArrayType<string>,
padOptions: PadOption,
padId: string,
clientIp: string,
colorPalette: MapArrayType<string>,
colorPalette: MapArrayType<number>,
accountPrivs: MapArrayType<string>,
collab_client_vars: MapArrayType<string>,
collab_client_vars: ServerVar,
chatHead: number,
readonly: boolean,
serverTimestamp: number,
initialOptions: MapArrayType<string>,
userId: string,
mode: string,
randomVersionString: string,
skinName: string
skinVariants: string,
exportAvailable: string
}
export type ClientVarMessage = {
data: ClientVarData,
type: string
accessStatus: string
export type ClientVarData = {
type: "CLIENT_VARS"
data: ClientVarPayload
}
export type ClientNewChanges = {
type : 'NEW_CHANGES'
apool: AttributePool,
author: string,
changeset: string,
newRev: number,
payload?: ClientNewChanges
}
export type ClientAcceptCommitMessage = {
type: 'ACCEPT_COMMIT'
newRev: number
}
export type ClientConnectMessage = {
type: 'CLIENT_RECONNECT',
noChanges: boolean,
headRev: number,
newRev: number,
changeset: string,
author: string
apool: AttributePool
}
export type UserNewInfoMessage = {
type: 'USER_NEWINFO',
userInfo: UserInfo
}
export type UserLeaveMessage = {
type: 'USER_LEAVE'
userInfo: UserInfo
}
export type ClientMessageMessage = {
type: 'CLIENT_MESSAGE',
payload: ClientSendMessages
}
export type ChatMessageMessage = {
type: 'CHAT_MESSAGE'
message: string
}
export type ChatMessageMessages = {
type: 'CHAT_MESSAGES'
messages: string
}
export type ClientUserChangesMessage = {
type: 'USER_CHANGES',
baseRev: number,
changeset: string,
apool: attributePool
}
export type ClientSendMessages = ClientUserChangesMessage | ClientSendUserInfoUpdate| ClientMessageMessage | GetChatMessageMessage |ClientSuggestUserName | NewRevisionListMessage | RevisionLabel | PadOptionsMessage| ClientSaveRevisionMessage
export type ClientSaveRevisionMessage = {
type: 'SAVE_REVISION'
}
export type GetChatMessageMessage = {
type: 'GET_CHAT_MESSAGES',
start: number,
end: number
}
export type ClientSendUserInfoUpdate = {
type: 'USERINFO_UPDATE',
userInfo: UserInfo
}
export type ClientSuggestUserName = {
type: 'suggestUserName',
unnamedId: string,
newName: string
}
export type NewRevisionListMessage = {
type: 'newRevisionList',
revisionList: number[]
}
export type RevisionLabel = {
type: 'revisionLabel'
revisionList: number[]
}
export type PadOptionsMessage = {
type: 'padoptions'
options: PadOption
changedBy: string
}
export type PadOption = {
"noColors"?: boolean,
"showControls"?: boolean,
"showChat"?: boolean,
"showLineNumbers"?: boolean,
"useMonospaceFont"?: boolean,
"userName"?: null|string,
"userColor"?: null|string,
"rtl"?: boolean,
"alwaysShowChat"?: boolean,
"chatAndUsers"?: boolean,
"lang"?: null|string,
view? : MapArrayType<boolean>
}
type SharedMessageType = {
payload:{
timestamp: number
}
}
export type x = {
disconnect: boolean
}
export type ClientDisconnectedMessage = {
type: "disconnected"
disconnected: boolean
}
export type ClientVarMessage = {
type: 'CHANGESET_REQ'| 'COLLABROOM'| 'CUSTOM'
data:
| ClientNewChanges
| ClientAcceptCommitMessage
|UserNewInfoMessage
| UserLeaveMessage
|ClientMessageMessage
|ChatMessageMessage
|ChatMessageMessages
|ClientConnectMessage,
} | ClientVarData | ClientDisconnectedMessage
export type SocketClientReadyMessage = {
type: string
component: string

View file

@ -1,9 +1,12 @@
import {ClientVarData} from "./SocketIOMessage";
import {ClientVarData, ClientVarPayload} from "./SocketIOMessage";
import {Pad} from "../pad";
declare global {
interface Window {
clientVars: ClientVarData;
clientVars: ClientVarPayload;
$: any,
customStart?:any
customStart?:any,
ajlog: string
}
let pad: Pad
}

37
src/static/js/vendors/BrowserType.ts vendored Normal file
View file

@ -0,0 +1,37 @@
export type BrowserType = {
webos?: boolean
name: string,
opera?: boolean,
version?: number,
yandexbrowser?: boolean,
windowsphone?: boolean,
msedge?: boolean,
msie?: boolean,
chromeos?: boolean
chromeBook?: boolean
chrome?: boolean,
sailfish?: boolean,
seamonkey?: boolean,
firefox?: boolean,
firefoxos?: boolean,
silk?: boolean,
phantom?: boolean,
blackberry?: boolean
touchpad?: boolean,
bada?: boolean,
tizen?: boolean,
safari?: boolean,
webkit?: boolean,
gecko?: boolean,
android?: boolean,
ios?: boolean,
windows?: boolean
mac?: boolean
linux?: boolean
osversion?: string
tablet?: boolean
mobile?: boolean
a?: boolean
c?: boolean
x?: boolean
}

View file

@ -1,310 +0,0 @@
// WARNING: This file may have been modified from original.
// TODO: Check requirement of this file, this afaik was to cover weird edge cases
// that have probably been fixed in browsers.
/*!
* Bowser - a browser detector
* https://github.com/ded/bowser
* MIT License | (c) Dustin Diaz 2015
*/
!function (name, definition) {
if (typeof module != 'undefined' && module.exports) module.exports = definition()
else if (typeof define == 'function' && define.amd) define(definition)
else this[name] = definition()
}('bowser', function () {
/**
* See useragents.js for examples of navigator.userAgent
*/
var t = true
function detect(ua) {
function getFirstMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[1]) || '';
}
function getSecondMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[2]) || '';
}
var iosdevice = getFirstMatch(/(ipod|iphone|ipad)/i).toLowerCase()
, likeAndroid = /like android/i.test(ua)
, android = !likeAndroid && /android/i.test(ua)
, chromeos = /CrOS/.test(ua)
, silk = /silk/i.test(ua)
, sailfish = /sailfish/i.test(ua)
, tizen = /tizen/i.test(ua)
, webos = /(web|hpw)os/i.test(ua)
, windowsphone = /windows phone/i.test(ua)
, windows = !windowsphone && /windows/i.test(ua)
, mac = !iosdevice && !silk && /macintosh/i.test(ua)
, linux = !android && !sailfish && !tizen && !webos && /linux/i.test(ua)
, edgeVersion = getFirstMatch(/edge\/(\d+(\.\d+)?)/i)
, versionIdentifier = getFirstMatch(/version\/(\d+(\.\d+)?)/i)
, tablet = /tablet/i.test(ua)
, mobile = !tablet && /[^-]mobi/i.test(ua)
, result
if (/opera|opr/i.test(ua)) {
result = {
name: 'Opera'
, opera: t
, version: versionIdentifier || getFirstMatch(/(?:opera|opr)[\s\/](\d+(\.\d+)?)/i)
}
}
else if (/yabrowser/i.test(ua)) {
result = {
name: 'Yandex Browser'
, yandexbrowser: t
, version: versionIdentifier || getFirstMatch(/(?:yabrowser)[\s\/](\d+(\.\d+)?)/i)
}
}
else if (windowsphone) {
result = {
name: 'Windows Phone'
, windowsphone: t
}
if (edgeVersion) {
result.msedge = t
result.version = edgeVersion
}
else {
result.msie = t
result.version = getFirstMatch(/iemobile\/(\d+(\.\d+)?)/i)
}
}
else if (/msie|trident/i.test(ua)) {
result = {
name: 'Internet Explorer'
, msie: t
, version: getFirstMatch(/(?:msie |rv:)(\d+(\.\d+)?)/i)
}
} else if (chromeos) {
result = {
name: 'Chrome'
, chromeos: t
, chromeBook: t
, chrome: t
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
}
} else if (/chrome.+? edge/i.test(ua)) {
result = {
name: 'Microsoft Edge'
, msedge: t
, version: edgeVersion
}
}
else if (/chrome|crios|crmo/i.test(ua)) {
result = {
name: 'Chrome'
, chrome: t
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
}
}
else if (iosdevice) {
result = {
name : iosdevice == 'iphone' ? 'iPhone' : iosdevice == 'ipad' ? 'iPad' : 'iPod'
}
// WTF: version is not part of user agent in web apps
if (versionIdentifier) {
result.version = versionIdentifier
}
}
else if (sailfish) {
result = {
name: 'Sailfish'
, sailfish: t
, version: getFirstMatch(/sailfish\s?browser\/(\d+(\.\d+)?)/i)
}
}
else if (/seamonkey\//i.test(ua)) {
result = {
name: 'SeaMonkey'
, seamonkey: t
, version: getFirstMatch(/seamonkey\/(\d+(\.\d+)?)/i)
}
}
else if (/firefox|iceweasel/i.test(ua)) {
result = {
name: 'Firefox'
, firefox: t
, version: getFirstMatch(/(?:firefox|iceweasel)[ \/](\d+(\.\d+)?)/i)
}
if (/\((mobile|tablet);[^\)]*rv:[\d\.]+\)/i.test(ua)) {
result.firefoxos = t
}
}
else if (silk) {
result = {
name: 'Amazon Silk'
, silk: t
, version : getFirstMatch(/silk\/(\d+(\.\d+)?)/i)
}
}
else if (android) {
result = {
name: 'Android'
, version: versionIdentifier
}
}
else if (/phantom/i.test(ua)) {
result = {
name: 'PhantomJS'
, phantom: t
, version: getFirstMatch(/phantomjs\/(\d+(\.\d+)?)/i)
}
}
else if (/blackberry|\bbb\d+/i.test(ua) || /rim\stablet/i.test(ua)) {
result = {
name: 'BlackBerry'
, blackberry: t
, version: versionIdentifier || getFirstMatch(/blackberry[\d]+\/(\d+(\.\d+)?)/i)
}
}
else if (webos) {
result = {
name: 'WebOS'
, webos: t
, version: versionIdentifier || getFirstMatch(/w(?:eb)?osbrowser\/(\d+(\.\d+)?)/i)
};
/touchpad\//i.test(ua) && (result.touchpad = t)
}
else if (/bada/i.test(ua)) {
result = {
name: 'Bada'
, bada: t
, version: getFirstMatch(/dolfin\/(\d+(\.\d+)?)/i)
};
}
else if (tizen) {
result = {
name: 'Tizen'
, tizen: t
, version: getFirstMatch(/(?:tizen\s?)?browser\/(\d+(\.\d+)?)/i) || versionIdentifier
};
}
else if (/safari/i.test(ua)) {
result = {
name: 'Safari'
, safari: t
, version: versionIdentifier
}
}
else {
result = {
name: getFirstMatch(/^(.*)\/(.*) /),
version: getSecondMatch(/^(.*)\/(.*) /)
};
}
// set webkit or gecko flag for browsers based on these engines
if (!result.msedge && /(apple)?webkit/i.test(ua)) {
result.name = result.name || "Webkit"
result.webkit = t
if (!result.version && versionIdentifier) {
result.version = versionIdentifier
}
} else if (!result.opera && /gecko\//i.test(ua)) {
result.name = result.name || "Gecko"
result.gecko = t
result.version = result.version || getFirstMatch(/gecko\/(\d+(\.\d+)?)/i)
}
// set OS flags for platforms that have multiple browsers
if (!result.msedge && (android || result.silk)) {
result.android = t
} else if (iosdevice) {
result[iosdevice] = t
result.ios = t
} else if (windows) {
result.windows = t
} else if (mac) {
result.mac = t
} else if (linux) {
result.linux = t
}
// OS version extraction
var osVersion = '';
if (result.windowsphone) {
osVersion = getFirstMatch(/windows phone (?:os)?\s?(\d+(\.\d+)*)/i);
} else if (iosdevice) {
osVersion = getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i);
osVersion = osVersion.replace(/[_\s]/g, '.');
} else if (android) {
osVersion = getFirstMatch(/android[ \/-](\d+(\.\d+)*)/i);
} else if (result.webos) {
osVersion = getFirstMatch(/(?:web|hpw)os\/(\d+(\.\d+)*)/i);
} else if (result.blackberry) {
osVersion = getFirstMatch(/rim\stablet\sos\s(\d+(\.\d+)*)/i);
} else if (result.bada) {
osVersion = getFirstMatch(/bada\/(\d+(\.\d+)*)/i);
} else if (result.tizen) {
osVersion = getFirstMatch(/tizen[\/\s](\d+(\.\d+)*)/i);
}
if (osVersion) {
result.osversion = osVersion;
}
// device type extraction
var osMajorVersion = osVersion.split('.')[0];
if (tablet || iosdevice == 'ipad' || (android && (osMajorVersion == 3 || (osMajorVersion == 4 && !mobile))) || result.silk) {
result.tablet = t
} else if (mobile || iosdevice == 'iphone' || iosdevice == 'ipod' || android || result.blackberry || result.webos || result.bada) {
result.mobile = t
}
// Graded Browser Support
// http://developer.yahoo.com/yui/articles/gbs
if (result.msedge ||
(result.msie && result.version >= 10) ||
(result.yandexbrowser && result.version >= 15) ||
(result.chrome && result.version >= 20) ||
(result.firefox && result.version >= 20.0) ||
(result.safari && result.version >= 6) ||
(result.opera && result.version >= 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] >= 6) ||
(result.blackberry && result.version >= 10.1)
) {
result.a = t;
}
else if ((result.msie && result.version < 10) ||
(result.chrome && result.version < 20) ||
(result.firefox && result.version < 20.0) ||
(result.safari && result.version < 6) ||
(result.opera && result.version < 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] < 6)
) {
result.c = t
} else result.x = t
return result
}
var bowser = detect(typeof navigator !== 'undefined' ? navigator.userAgent : '')
bowser.test = function (browserList) {
for (var i = 0; i < browserList.length; ++i) {
var browserItem = browserList[i];
if (typeof browserItem=== 'string') {
if (browserItem in bowser) {
return true;
}
}
}
return false;
}
/*
* Set our detect method to the main bowser object so we can
* reuse it to test other user agents.
* This is needed to implement future tests.
*/
bowser._detect = detect;
return bowser
});

216
src/static/js/vendors/browser.ts vendored Normal file
View file

@ -0,0 +1,216 @@
// WARNING: This file may have been modified from original.
// TODO: Check requirement of this file, this afaik was to cover weird edge cases
// that have probably been fixed in browsers.
/*!
* Bowser - a browser detector
* https://github.com/ded/bowser
* MIT License | (c) Dustin Diaz 2015
*/
export class BrowserDetector {
webos?: boolean
name: string = ''
opera?: boolean
version?: string
yandexbrowser?: boolean
windowsphone?: boolean
msedge?: boolean
msie?: boolean
chromeos?: boolean
chromeBook?: boolean
chrome?: boolean
sailfish?: boolean
seamonkey?: boolean
firefox?: boolean
firefoxos?: boolean
silk?: boolean
phantom?: boolean
blackberry?: boolean
touchpad?: boolean
bada?: boolean
tizen?: boolean
safari?: boolean
webkit?: boolean
gecko?: boolean
android?: boolean
ios?: boolean
windows?: boolean
mac?: boolean
linux?: boolean
osversion?: string
tablet?: boolean
mobile?: boolean
a?: boolean
c?: boolean
x?: boolean
touchepad?: boolean
constructor() {
this.detect(typeof navigator !== 'undefined' ? navigator.userAgent : '')
}
private getFirstMatch = (regex: RegExp, ua:string)=> {
const match = ua.match(regex);
return (match && match.length > 1 && match[1]) || '';
}
public detect = (ua: string)=>{
let iosdevice = this.getFirstMatch(/(ipod|iphone|ipad)/i, ua).toLowerCase()
let likeAndroid = /like android/i.test(ua)
let android = !likeAndroid && /android/i.test(ua)
let chromeos = /CrOS/.test(ua)
, silk = /silk/i.test(ua)
, sailfish = /sailfish/i.test(ua)
, tizen = /tizen/i.test(ua)
, webos = /(web|hpw)os/i.test(ua)
, windowsphone = /windows phone/i.test(ua)
, windows = !windowsphone && /windows/i.test(ua)
, mac = !iosdevice && !silk && /macintosh/i.test(ua)
, linux = !android && !sailfish && !tizen && !webos && /linux/i.test(ua)
, edgeVersion = this.getFirstMatch(/edge\/(\d+(\.\d+)?)/i, ua)
, versionIdentifier = this.getFirstMatch(/version\/(\d+(\.\d+)?)/i, ua)
, tablet = /tablet/i.test(ua)
, mobile = !tablet && /[^-]mobi/i.test(ua)
if (/opera|opr/i.test(ua)) {
this.name = 'Opera'
this.opera = true
this.version = versionIdentifier || this.getFirstMatch(/(?:opera|opr)[\s\/](\d+(\.\d+)?)/i, ua)
}
else if (/yabrowser/i.test(ua)) {
this.name = 'Yandex Browser'
this.yandexbrowser = true
this.version = versionIdentifier || this.getFirstMatch(/(?:yabrowser)[\s\/](\d+(\.\d+)?)/i, ua)
}
else if (windowsphone) {
this.name = 'Windows Phone'
this.windowsphone = true
if (edgeVersion) {
this.msedge = true
this.version = edgeVersion
}
else {
this.msie = true
this.version = this.getFirstMatch(/iemobile\/(\d+(\.\d+)?)/i, ua)
}
}
else if (/msie|trident/i.test(ua)) {
this.name = 'Internet Explorer'
this.msie = true
this.version = this.getFirstMatch(/(?:msie |rv:)(\d+(\.\d+)?)/i, ua)
} else if (chromeos) {
this.name = 'Chrome';
this.chromeos = true;
this.chromeBook = true;
this.chrome = true;
this.version = this.getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i, ua);
} else if (/chrome.+? edge/i.test(ua)) {
this.name = 'Microsoft Edge';
this.msedge = true;
this.version = edgeVersion;
} else if (/chrome|crios|crmo/i.test(ua)) {
this.name = 'Chrome';
this.chrome = true;
this.version = this.getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i, ua);
} else if (iosdevice) {
this.name = iosdevice === 'iphone' ? 'iPhone' : iosdevice === 'ipad' ? 'iPad' : 'iPod';
if (versionIdentifier) {
this.version = versionIdentifier;
}
} else if (webos) {
this.name = 'WebOS';
this.webos = true;
this.version = versionIdentifier || this.getFirstMatch(/w(?:eb)?osbrowser\/(\d+(\.\d+)?)/i, ua);
/touchpad\//i.test(ua) && (this.touchepad = true);
} else if (android) {
this.name = 'Android';
this.version = versionIdentifier;
} else if (/safari/i.test(ua)) {
this.name = 'Safari';
this.safari = true;
this.version = versionIdentifier;
} else {
this.name = this.getFirstMatch(/^(.*)\/(.*) /, ua);
this.version = this.getSecondMatch(/^(.*)\/(.*) /, ua);
}
if (!this.msedge && /(apple)?webkit/i.test(ua)) {
this.name = this.name || "Webkit";
this.webkit = true;
if (!this.version && versionIdentifier) {
this.version = versionIdentifier;
}
} else if (/gecko\//i.test(ua) && !this.webkit && !this.msedge) {
this.name = this.name || "Gecko";
this.gecko = true;
this.version = this.version || this.getFirstMatch(/gecko\/(\d+(\.\d+)?)/i, ua);
}
if (!this.msedge && (android || this.silk)) {
this.android = true;
} else if (iosdevice) {
// @ts-ignore
this[iosdevice] = true;
this.ios = true;
} else if (windows) {
this.windows = true;
} else if (mac) {
this.mac = true;
} else if (linux) {
this.linux = true;
}
let osVersion = '';
if (iosdevice) {
osVersion = this.getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i, ua).replace(/[_\s]/g, '.');
} else if (android) {
osVersion = this.getFirstMatch(/android[ \/-](\d+(\.\d+)*)/i, ua);
} else if (this.webos) {
osVersion = this.getFirstMatch(/(?:web|hpw)os\/(\d+(\.\d+)*)/i, ua);
}
osVersion && (this.osversion = osVersion);
if (tablet || iosdevice === 'ipad' || (android && (osVersion.split('.')[0] === '3' || osVersion.split('.')[0] === '4' && !mobile)) || this.silk) {
this.tablet = true;
} else if (mobile || iosdevice === 'iphone' || iosdevice === 'ipod' || android) {
this.mobile = true;
}
if (this.msedge ||
(this.chrome && this.version && parseInt(this.version) >= 20) ||
(this.firefox && this.version && parseInt(this.version) >= 20) ||
(this.safari && this.version && parseInt(this.version) >= 6) ||
(this.opera && this.version && parseInt(this.version) >= 10) ||
(this.ios && this.osversion && parseInt(this.osversion.split(".")[0]) >= 6)
) {
this.a = true;
} else if ((this.chrome && this.version && parseInt(this.version) < 20) ||
(this.firefox && this.version && parseInt(this.version) < 20) ||
(this.safari && this.version && parseInt(this.version) < 6)
) {
this.c = true;
} else {
this.x = true;
}
}
private getSecondMatch = (regex: RegExp, ua: string) => {
const match = ua.match(regex);
return (match && match.length > 1 && match[2]) || '';
}
test = (browserList: string)=> {
for (let i = 0; i < browserList.length; ++i) {
const browserItem = browserList[i];
if (typeof browserItem=== 'string') {
if (browserItem in this) {
return true;
}
}
}
return false;
}
}

View file

@ -343,7 +343,6 @@
$('#editorcontainerbox').append(this._tpl_wrap_bottom);
}
}
}
})(jQuery);

View file

@ -14,7 +14,7 @@
const basePath = new URL('..', window.location.href).pathname;
window.$ = window.jQuery = require('../../src/static/js/vendors/jquery');
window.browser = require('../../src/static/js/vendors/browser');
window.browser = require('../static/js/vendors/browser');
const pad = require('../../src/static/js/pad');
pad.baseURL = basePath;
window.plugins = require('../../src/static/js/pluginfw/client_plugins');
@ -23,8 +23,8 @@
// TODO: These globals shouldn't exist.
window.pad = pad.pad;
window.chat = require('../../src/static/js/chat').chat;
window.padeditbar = require('../../src/static/js/pad_editbar').padeditbar;
window.padimpexp = require('../../src/static/js/pad_impexp').padimpexp;
window.padeditbar = require('../static/js/pad_editbar').padeditbar;
window.padimpexp = require('../static/js/pad_impexp').padimpexp;
await import('../../src/static/js/skin_variants')
await import('../../src/static/js/basic_error_handler')

View file

@ -19,7 +19,7 @@ import * as timeSlider from 'ep_etherpad-lite/static/js/timeslider'
window.$ = window.jQuery = require('ep_etherpad-lite/static/js/vendors/jquery'); // Expose jQuery #HACK
require('ep_etherpad-lite/static/js/vendors/gritter')
window.browser = require('ep_etherpad-lite/static/js/vendors/browser');
window.browser = require('src/static/js/vendors/browser');
window.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');
const socket = timeSlider.socket;
@ -31,8 +31,8 @@ import * as timeSlider from 'ep_etherpad-lite/static/js/timeslider'
/* TODO: These globals shouldn't exist. */
});
const padeditbar = require('ep_etherpad-lite/static/js/pad_editbar').padeditbar;
const padimpexp = require('ep_etherpad-lite/static/js/pad_impexp').padimpexp;
const padeditbar = require('src/static/js/pad_editbar').padeditbar;
const padimpexp = require('src/static/js/pad_impexp').padimpexp;
setBaseURl(baseURL)
timeSlider.init();
padeditbar.init()

View file

@ -3,7 +3,7 @@
import {MapArrayType} from "../../../node/types/MapType";
import {PluginDef} from "../../../node/types/PartType";
const ChatMessage = require('../../../static/js/ChatMessage');
import ChatMessage from '../../../static/js/ChatMessage';
const {Pad} = require('../../../node/db/Pad');
const assert = require('assert').strict;
const common = require('../common');
@ -13,7 +13,7 @@ const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
const logger = common.logger;
type CheckFN = ({message, pad, padId}:{
message?: typeof ChatMessage,
message?: ChatMessage,
pad?: typeof Pad,
padId?: string,
})=>void;
@ -103,10 +103,10 @@ describe(__filename, function () {
checkHook('chatNewMessage', ({message}) => {
assert(message != null);
assert(message instanceof ChatMessage);
assert.equal(message.authorId, authorId);
assert.equal(message.text, this.test!.title);
assert(message.time >= start);
assert(message.time <= Date.now());
assert.equal(message!.authorId, authorId);
assert.equal(message!.text, this.test!.title);
assert(message!.time! >= start);
assert(message!.time! <= Date.now());
}),
sendChat(socket, {text: this.test!.title}),
]);
@ -153,8 +153,8 @@ describe(__filename, function () {
const customMetadata = {foo: this.test!.title};
await Promise.all([
checkHook('chatNewMessage', ({message}) => {
message.text = modifiedText;
message.customMetadata = customMetadata;
message!.text = modifiedText;
message!.customMetadata = customMetadata;
}),
(async () => {
const {message} = await listen('CHAT_MESSAGE');

View file

@ -16,7 +16,7 @@
<script src="../../static/js/vendors/jquery.js"></script>
<script src="lib/sendkeys.js"></script>
<script src="../../static/js/vendors/browser.js"></script>
<script src="../../static/js/vendors/browser.ts"></script>
<script src="../../static/plugins/js-cookie/dist/js.cookie.js"></script>
<script src="lib/underscore.js"></script>