Continued writing

This commit is contained in:
SamTV12345 2024-07-18 22:13:33 +02:00
parent d1ffd5d02f
commit cef2af15b9
18 changed files with 1455 additions and 1230 deletions

View file

@ -19,6 +19,7 @@ export type PadType = {
getRevisionDate: (rev: number)=>Promise<number>,
getRevisionChangeset: (rev: number)=>Promise<AChangeSet>,
appendRevision: (changeset: AChangeSet, author: string)=>Promise<void>,
settings:any
}

View file

@ -31,7 +31,7 @@ class AttributeMap extends Map {
* @param {AttributePool} pool - Attribute pool.
* @returns {AttributeMap}
*/
public static fromString(str: string, pool: AttributePool): AttributeMap {
public static fromString(str: string, pool?: AttributePool|null): AttributeMap {
return new AttributeMap(pool).updateFromString(str);
}

View file

@ -0,0 +1,132 @@
import {TextLinesMutator} from "./TextLinesMutator";
import AttributePool from "./AttributePool";
import {assert, copyOp, deserializeOps, slicerZipperFunc, unpack} from "./Changeset";
import Op from "./Op";
import {MergingOpAssembler} from "./MergingOpAssembler";
/**
* Applies a changeset to an array of attribute lines.
*
* @param {string} cs - The encoded changeset.
* @param {Array<string>} lines - Attribute lines. Modified in place.
* @param {AttributePool.ts} pool - Attribute pool.
*/
export class AttributionLinesMutator {
private unpacked
private csOps: Generator<Op>|Op
private csOpsNext: IteratorResult<Op>
private csBank: string
private csBankIndex: number
private mut: TextLinesMutator
private lineOps: Generator<Op>|null
private lineOpsNext: IteratorResult<Op>|null
private lineAssem: null|MergingOpAssembler
private attOp: Op
private csOp: Op
constructor(cs: string, lines:string[], pool: AttributePool) {
this.unpacked = unpack(cs);
this.csOps = deserializeOps(this.unpacked.ops);
this.csOpsNext = this.csOps.next();
this.csBank = this.unpacked.charBank;
this.csBankIndex = 0;
// treat the attribution lines as text lines, mutating a line at a time
this.mut = new TextLinesMutator(lines);
/**
* The Ops in the current line from `lines`.
*
* @type {?Generator<Op>}
*/
this.lineOps = null;
this.lineOpsNext = null;
this.lineAssem = null
this.csOp = new Op()
this.attOp = new Op()
while (this.csOp.opcode || !this.csOpsNext.done || this.attOp.opcode || this.isNextMutOp()) {
if (!this.csOp.opcode && !this.csOpsNext.done) {
// coOp done, but more ops in cs.
this.csOp = this.csOpsNext.value;
this.csOpsNext = this.csOps.next();
}
if (!this.csOp.opcode && !this.attOp.opcode && !this.lineAssem && !this.lineOpsHasNext()) {
break; // done
} else if (this.csOp.opcode === '=' && this.csOp.lines > 0 && !this.csOp.attribs && !this.attOp.opcode &&
!this.lineAssem && !this.lineOpsHasNext()) {
// Skip multiple lines without attributes; this is what makes small changes not order of the
// document size.
this.mut.skipLines(this.csOp.lines);
this.csOp.opcode = '';
} else if (this.csOp.opcode === '+') {
const opOut = copyOp(this.csOp);
if (this.csOp.lines > 1) {
// Copy the first line from `csOp` to `opOut`.
const firstLineLen = this.csBank.indexOf('\n', this.csBankIndex) + 1 - this.csBankIndex;
this.csOp.chars -= firstLineLen;
this.csOp.lines--;
opOut.lines = 1;
opOut.chars = firstLineLen;
} else {
// Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`.
this.csOp.opcode = '';
}
this.outputMutOp(opOut);
this.csBankIndex += opOut.chars;
} else {
if (!this.attOp.opcode && this.isNextMutOp()) {
this.attOp = this.nextMutOp();
}
const opOut = slicerZipperFunc(this.attOp, this.csOp, pool);
if (opOut.opcode) {
this.outputMutOp(opOut);
}
}
}
assert(!this.lineAssem, `line assembler not finished:${cs}`);
this.mut.close();
}
lineOpsHasNext = () => this.lineOpsNext && !this.lineOpsNext.done;
/**
* Returns false if we are on the last attribute line in `lines` and there is no additional op in
* that line.
*
* @returns {boolean} True if there are more ops to go through.
*/
isNextMutOp = () => this.lineOpsHasNext() || this.mut.hasMore();
/**
* @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to
* iterate over the next line, which is consumed from `mut`. If there are no more lines,
* returns a null Op.
*/
nextMutOp = () => {
if (!this.lineOpsHasNext() && this.mut.hasMore()) {
// There are more attribute lines in `lines` to do AND either we just started so `lineIter` is
// still null or there are no more ops in current `lineIter`.
const line = this.mut.removeLines(1);
this.lineOps = deserializeOps(line);
this.lineOpsNext = this.lineOps.next();
}
if (!this.lineOpsHasNext()) return new Op(); // No more ops and no more lines.
const op = this.lineOpsNext!.value;
this.lineOpsNext = this.lineOps!.next();
return op;
}
/**
* Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the
* `lines` mutator.
*/
outputMutOp = (op: Op) => {
if (!this.lineAssem) {
this.lineAssem = new MergingOpAssembler()
}
this.lineAssem.append(op);
if (op.lines <= 0) return;
assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`);
// ship it to the mut
this.mut.insert(this.lineAssem.toString(), 1);
this.lineAssem = null;
};
}

109
src/static/js/Builder.ts Normal file
View file

@ -0,0 +1,109 @@
/**
* Incrementally builds a Changeset.
*
* @typedef {object} Builder
* @property {Function} insert -
* @property {Function} keep -
* @property {Function} keepText -
* @property {Function} remove -
* @property {Function} toString -
*/
import {SmartOpAssembler} from "./SmartOpAssembler";
import Op from "./Op";
import {StringAssembler} from "./StringAssembler";
import AttributeMap from "./AttributeMap";
import {Attribute} from "./types/Attribute";
import AttributePool from "./AttributePool";
import {opsFromText} from "./Changeset";
/**
* @param {number} oldLen - Old length
* @returns {Builder}
*/
export class Builder {
private readonly oldLen: number;
private assem: SmartOpAssembler;
private readonly o: Op;
private charBank: StringAssembler;
constructor(oldLen: number) {
this.oldLen = oldLen
this.assem = new SmartOpAssembler()
this.o = new Op()
this.charBank = new StringAssembler()
}
/**
* @param {number} N - Number of characters to keep.
* @param {number} L - Number of newlines among the `N` characters. If positive, the last
* character must be a newline.
* @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'
* (no pool needed in latter case).
* @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of
* attribute key, value pairs.
* @returns {Builder} this
*/
keep = (N: number, L: number, attribs?: string|Attribute[], pool?: AttributePool): Builder => {
this.o.opcode = '=';
this.o.attribs = typeof attribs === 'string'
? attribs : new AttributeMap(pool).update(attribs || []).toString();
this.o.chars = N;
this.o.lines = (L || 0);
this.assem.append(this.o);
return this;
}
/**
* @param {string} text - Text to keep.
* @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'
* (no pool needed in latter case).
* @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of
* attribute key, value pairs.
* @returns {Builder} this
*/
keepText= (text: string, attribs: string|Attribute[], pool?: AttributePool): Builder=> {
for (const op of opsFromText('=', text, attribs, pool)) this.assem.append(op);
return this;
}
/**
* @param {string} text - Text to insert.
* @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'
* (no pool needed in latter case).
* @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of
* attribute key, value pairs.
* @returns {Builder} this
*/
insert= (text: string, attribs: string | Attribute[] | undefined, pool?: AttributePool | null | undefined): Builder => {
for (const op of opsFromText('+', text, attribs, pool)) this.assem.append(op);
this.charBank.append(text);
return this;
}
/**
* @param {number} N - Number of characters to remove.
* @param {number} L - Number of newlines among the `N` characters. If positive, the last
* character must be a newline.
* @returns {Builder} this
*/
remove= (N: number, L: number): Builder => {
this.o.opcode = '-';
this.o.attribs = '';
this.o.chars = N;
this.o.lines = (L || 0);
this.assem.append(this.o);
return this;
}
toString= () => {
this.assem.endDocument();
const newLen = this.oldLen + this.assem.getLengthChange();
return exports.pack(this.oldLen, newLen, this.assem.toString(), this.charBank.toString());
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,6 @@
export type OpCode = ''|'='|'+'|'-';
/**
* An operation to apply to a shared document.
*/

View file

@ -1,4 +1,5 @@
import Op from "./Op";
import {clearOp, copyOp, deserializeOps} from "./Changeset";
/**
* Iterator over a changeset's operations.
@ -9,19 +10,20 @@ import Op from "./Op";
*/
export class OpIter {
private gen
private _next: IteratorResult<Op, void>
/**
* @param {string} ops - String encoding the change operations to iterate over.
*/
constructor(ops: string) {
this.gen = exports.deserializeOps(ops);
this.next = this.gen.next();
this.gen = deserializeOps(ops);
this._next = this.gen.next();
}
/**
* @returns {boolean} Whether there are any remaining operations.
*/
hasNext() {
return !this.next.done;
hasNext(): boolean {
return !this._next.done;
}
/**
@ -33,10 +35,10 @@ export class OpIter {
* @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are
* no more operations.
*/
next(opOut = new Op()) {
next(opOut: Op = new Op()): Op {
if (this.hasNext()) {
copyOp(this._next.value, opOut);
this._next = this._gen.next();
copyOp(this._next.value!, opOut);
this._next = this.gen.next();
} else {
clearOp(opOut);
}

View file

@ -0,0 +1,335 @@
/**
* Class to iterate and modify texts which have several lines. It is used for applying Changesets on
* arrays of lines.
*
* Mutation operations have the same constraints as exports operations with respect to newlines, but
* not the other additional constraints (i.e. ins/del ordering, forbidden no-ops, non-mergeability,
* final newline). Can be used to mutate lists of strings where the last char of each string is not
* actually a newline, but for the purposes of N and L values, the caller should pretend it is, and
* for things to work right in that case, the input to the `insert` method should be a single line
* with no newlines.
*/
export class TextLinesMutator {
private readonly lines: string[]
private readonly curSplice: [number, number]
private inSplice: boolean
private curLine: number
private curCol: number
/**
* @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place).
*/
constructor(lines: string[]) {
this.lines = lines;
/**
* this._curSplice holds values that will be passed as arguments to this._lines.splice() to
* insert, delete, or change lines:
* - this._curSplice[0] is an index into the this._lines array.
* - this._curSplice[1] is the number of lines that will be removed from the this._lines array
* starting at the index.
* - The other elements represent mutated (changed by ops) lines or new lines (added by ops)
* to insert at the index.
*
* @type {[number, number?, ...string[]?]}
*/
this.curSplice = [0, 0];
this.inSplice = false;
// position in lines after curSplice is applied:
this.curLine = 0;
this.curCol = 0;
// invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) &&
// curLine >= curSplice[0]
// invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then
// curCol == 0
}
/**
* Get a line from `lines` at given index.
*
* @param {number} idx - an index
* @returns {string}
*/
private linesGet(idx: number) {
if ('get' in this.lines) {
// @ts-ignore
return this.lines.get(idx) as string;
} else {
return this.lines[idx];
}
}
/**
* Return a slice from `lines`.
*
* @param {number} start - the start index
* @param {number} end - the end index
* @returns {string[]}
*/
private linesSlice(start: number, end: number): string[] {
// can be unimplemented if removeLines's return value not needed
if (this.lines.slice) {
return this.lines.slice(start, end);
} else {
return [];
}
}
/**
* Return the length of `lines`.
*
* @returns {number}
*/
private linesLength() {
if (typeof this.lines.length === 'number') {
return this.lines.length;
} else {
// @ts-ignore
return this.lines.length();
}
}
/**
* Starts a new splice.
*/
enterSplice() {
this.curSplice[0] = this.curLine;
this.curSplice[1] = 0;
// TODO(doc) when is this the case?
// check all enterSplice calls and changes to curCol
if (this.curCol > 0) this.putCurLineInSplice();
this.inSplice = true;
}
/**
* Changes the lines array according to the values in curSplice and resets curSplice. Called via
* close or TODO(doc).
*/
private leaveSplice() {
this.lines.splice(...this.curSplice);
this.curSplice.length = 2;
this.curSplice[0] = this.curSplice[1] = 0;
this.inSplice = false;
}
/**
* Indicates if curLine is already in the splice. This is necessary because the last element in
* curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting).
*
* @returns {boolean} true if curLine is in splice
*/
private isCurLineInSplice() {
// The value of `this._curSplice[1]` does not matter when determining the return value because
// `this._curLine` refers to the line number *after* the splice is applied (so after those lines
// are deleted).
return this.curLine - this.curSplice[0] < this.curSplice.length - 2;
}
/**
* Incorporates current line into the splice and marks its old position to be deleted.
*
* @returns {number} the index of the added line in curSplice
*/
private putCurLineInSplice() {
if (!this.isCurLineInSplice()) {
this.curSplice.push(Number(this.linesGet(this.curSplice[0] + this.curSplice[1]!)));
this.curSplice[1]!++;
}
// TODO should be the same as this._curSplice.length - 1
return 2 + this.curLine - this.curSplice[0];
}
/**
* It will skip some newlines by putting them into the splice.
*
* @param {number} L -
* @param {boolean} includeInSplice - Indicates that attributes are present.
*/
public skipLines(L: number, includeInSplice?: boolean) {
if (!L) return;
if (includeInSplice) {
if (!this.inSplice) this.enterSplice();
// TODO(doc) should this count the number of characters that are skipped to check?
for (let i = 0; i < L; i++) {
this.curCol = 0;
this.putCurLineInSplice();
this.curLine++;
}
} else {
if (this.inSplice) {
if (L > 1) {
// TODO(doc) figure out why single lines are incorporated into splice instead of ignored
this.leaveSplice();
} else {
this.putCurLineInSplice();
}
}
this.curLine += L;
this.curCol = 0;
}
// tests case foo in remove(), which isn't otherwise covered in current impl
}
/**
* Skip some characters. Can contain newlines.
*
* @param {number} N - number of characters to skip
* @param {number} L - number of newlines to skip
* @param {boolean} includeInSplice - indicates if attributes are present
*/
skip(N: number, L: number, includeInSplice: boolean) {
if (!N) return;
if (L) {
this.skipLines(L, includeInSplice);
} else {
if (includeInSplice && !this.inSplice) this.enterSplice();
if (this.inSplice) {
// although the line is put into splice curLine is not increased, because
// only some chars are skipped, not the whole line
this.putCurLineInSplice();
}
this.curCol += N;
}
}
/**
* Remove whole lines from lines array.
*
* @param {number} L - number of lines to remove
* @returns {string}
*/
removeLines(L: number):string {
if (!L) return '';
if (!this.inSplice) this.enterSplice();
/**
* Gets a string of joined lines after the end of the splice.
*
* @param {number} k - number of lines
* @returns {string} joined lines
*/
const nextKLinesText = (k: number): string => {
const m = this.curSplice[0] + this.curSplice[1]!;
return this.linesSlice(m, m + k).join('');
};
let removed: any = '';
if (this.isCurLineInSplice()) {
if (this.curCol === 0) {
removed = this.curSplice[this.curSplice.length - 1];
this.curSplice.length--;
removed += nextKLinesText(L - 1);
this.curSplice[1]! += L - 1;
} else {
removed = nextKLinesText(L - 1);
this.curSplice[1]! += L - 1;
const sline = this.curSplice.length - 1;
// @ts-ignore
removed = this.curSplice[sline]!.substring(this.curCol) + removed;
// @ts-ignore
this.curSplice[sline] = this.curSplice[sline]!.substring(0, this.curCol) +
this.linesGet(this.curSplice[0] + this.curSplice[1]!);
// @ts-ignore
this.curSplice[1] += 1;
}
} else {
removed = nextKLinesText(L);
this.curSplice[1]! += L;
}
return removed;
}
/**
* Remove text from lines array.
*
* @param {number} N - characters to delete
* @param {number} L - lines to delete
* @returns {string}
*/
remove(N: number, L: number) {
if (!N) return '';
if (L) return this.removeLines(L);
if (!this.inSplice) this.enterSplice();
// although the line is put into splice, curLine is not increased, because
// only some chars are removed not the whole line
const sline = this.putCurLineInSplice();
// @ts-ignore
const removed = this.curSplice[sline].substring(this.curCol, this.curCol + N);
// @ts-ignore
this.curSplice[sline] = this.curSplice[sline]!.substring(0, this.curCol) +
// @ts-ignore
this.curSplice[sline].substring(this.curCol + N);
return removed;
}
/**
* Inserts text into lines array.
*
* @param {string} text - the text to insert
* @param {number} L - number of newlines in text
*/
insert(text: string, L: number) {
if (!text) return;
if (!this.inSplice) this.enterSplice();
if (L) {
const newLines = exports.splitTextLines(text);
if (this.isCurLineInSplice()) {
const sline = this.curSplice.length - 1;
/** @type {string} */
const theLine = this.curSplice[sline];
const lineCol = this.curCol;
// Insert the chars up to `curCol` and the first new line.
// @ts-ignore
this.curSplice[sline] = theLine.substring(0, lineCol) + newLines[0];
this.curLine++;
newLines.splice(0, 1);
// insert the remaining new lines
this.curSplice.push(...newLines);
this.curLine += newLines.length;
// insert the remaining chars from the "old" line (e.g. the line we were in
// when we started to insert new lines)
// @ts-ignore
this.curSplice.push(theLine.substring(lineCol));
this.curCol = 0; // TODO(doc) why is this not set to the length of last line?
} else {
this.curSplice.push(...newLines);
this.curLine += newLines.length;
}
} else {
// There are no additional lines. Although the line is put into splice, curLine is not
// increased because there may be more chars in the line (newline is not reached).
const sline = this.putCurLineInSplice();
if (!this.curSplice[sline]) {
const err = new Error(
'curSplice[sline] not populated, actual curSplice contents is ' +
`${JSON.stringify(this.curSplice)}. Possibly related to ` +
'https://github.com/ether/etherpad-lite/issues/2802');
console.error(err.stack || err.toString());
}
// @ts-ignore
this.curSplice[sline] = this.curSplice[sline].substring(0, this.curCol) + text +
// @ts-ignore
this.curSplice[sline].substring(this.curCol);
this.curCol += text.length;
}
}
/**
* Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`.
*
* @returns {boolean} indicates if there are lines left
*/
hasMore(): boolean {
let docLines = this.linesLength();
if (this.inSplice) {
docLines += this.curSplice.length - 2 - this.curSplice[1];
}
return this.curLine < docLines;
}
/**
* Closes the splice
*/
close() {
if (this.inSplice) this.leaveSplice();
}
}

View file

@ -5,6 +5,8 @@
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
import {InnerWindow} from "./types/InnerWindow";
/**
* Copyright 2009 Google Inc.
*
@ -28,21 +30,21 @@ const hooks = require('./pluginfw/hooks');
const pluginUtils = require('./pluginfw/shared');
const ace2_inner = require('ep_etherpad-lite/static/js/ace2_inner')
const debugLog = (...args) => {};
const debugLog = (...args: string[]|Object[]|null[]) => {};
const cl_plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins')
const {Cssmanager} = require("./cssmanager");
// The inner and outer iframe's locations are about:blank, so relative URLs are relative to that.
// Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari
// errors out unless given an absolute URL for a JavaScript-created element.
const absUrl = (url) => new URL(url, window.location.href).href;
const absUrl = (url: string) => new URL(url, window.location.href).href;
const eventFired = async (obj, event, cleanups = [], predicate = () => true) => {
const eventFired = async (obj: any, event: string, cleanups: Function[] = [], predicate = () => true) => {
if (typeof cleanups === 'function') {
predicate = cleanups;
cleanups = [];
}
await new Promise((resolve, reject) => {
let cleanup;
await new Promise((resolve: Function, reject: Function) => {
let cleanup: Function;
const successCb = () => {
if (!predicate()) return;
debugLog(`Ace2Editor.init() ${event} event on`, obj);
@ -57,23 +59,23 @@ const eventFired = async (obj, event, cleanups = [], predicate = () => true) =>
};
cleanup = () => {
cleanup = () => {};
obj.removeEventListener(event, successCb);
obj.removeEventListener('error', errorCb);
obj!.removeEventListener(event, successCb);
obj!.removeEventListener('error', errorCb);
};
cleanups.push(cleanup);
obj.addEventListener(event, successCb);
obj.addEventListener('error', errorCb);
obj!.addEventListener(event, successCb);
obj!.addEventListener('error', errorCb);
});
};
// Resolves when the frame's document is ready to be mutated. Browsers seem to be quirky about
// iframe ready events so this function throws the kitchen sink at the problem. Maybe one day we'll
// find a concise general solution.
const frameReady = async (frame) => {
const frameReady = async (frame: HTMLIFrameElement) => {
// Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace
// the document object after the frame is first created for some reason. ¯\_(ツ)_/¯
const doc = () => frame.contentDocument;
const cleanups = [];
const doc: any = () => frame.contentDocument;
const cleanups: Function[] = [];
try {
await Promise.race([
eventFired(frame, 'load', cleanups),
@ -87,26 +89,39 @@ const frameReady = async (frame) => {
}
};
const Ace2Editor = function () {
let info = {editor: this};
let loaded = false;
export class Ace2Editor {
info = {editor: this};
loaded = false;
actionsPendingInit: Function[] = [];
let actionsPendingInit = [];
const pendingInit = (func) => function (...args) {
constructor() {
for (const fnName of this.aceFunctionsPendingInit) {
// Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to
// pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until
// method invocation.
// @ts-ignore
this[fnName] = this.pendingInit(function (...args) {
// @ts-ignore
this.info[`ace_${fnName}`].apply(this, args);
});
}
}
pendingInit = (func: Function) => (...args: any[])=> {
const action = () => func.apply(this, args);
if (loaded) return action();
actionsPendingInit.push(action);
};
if (this.loaded) return action();
this.actionsPendingInit.push(action);
}
const doActionsPendingInit = () => {
for (const fn of actionsPendingInit) fn();
actionsPendingInit = [];
};
doActionsPendingInit = () => {
for (const fn of this.actionsPendingInit) fn();
this.actionsPendingInit = [];
}
// The following functions (prefixed by 'ace_') are exposed by editor, but
// execution is delayed until init is complete
const aceFunctionsPendingInit = [
aceFunctionsPendingInit = [
'importText',
'importAText',
'focus',
@ -124,21 +139,13 @@ const Ace2Editor = function () {
'callWithAce',
'execCommand',
'replaceRange',
];
]
for (const fnName of aceFunctionsPendingInit) {
// Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to
// pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until
// method invocation.
this[fnName] = pendingInit(function (...args) {
info[`ace_${fnName}`].apply(this, args);
});
}
// @ts-ignore
exportText = () => this.loaded ? this.info.ace_exportText() : '(awaiting init)\n';
// @ts-ignore
getInInternationalComposition = () => this.loaded ? this.info.ace_getInInternationalComposition() : null;
this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\n';
this.getInInternationalComposition =
() => loaded ? info.ace_getInInternationalComposition() : null;
// prepareUserChangeset:
// Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes
@ -148,9 +155,9 @@ const Ace2Editor = function () {
// be called again before applyPreparedChangesetToBase. Multiple consecutive calls to
// prepareUserChangeset will return an updated changeset that takes into account the latest user
// changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly.
this.prepareUserChangeset = () => loaded ? info.ace_prepareUserChangeset() : null;
const addStyleTagsFor = (doc, files) => {
// @ts-ignore
prepareUserChangeset = () => this.loaded ? this.info.ace_prepareUserChangeset() : null;
addStyleTagsFor = (doc: Document, files: string[]) => {
for (const file of files) {
const link = doc.createElement('link');
link.rel = 'stylesheet';
@ -158,32 +165,36 @@ const Ace2Editor = function () {
link.href = absUrl(encodeURI(file));
doc.head.appendChild(link);
}
};
}
this.destroy = pendingInit(() => {
info.ace_dispose();
info.frame.parentNode.removeChild(info.frame);
info = null; // prevent IE 6 closure memory leaks
destroy = this.pendingInit(() => {
// @ts-ignore
this.info.ace_dispose();
// @ts-ignore
this.info.frame.parentNode.removeChild(this.info.frame);
// @ts-ignore
this.info = null; // prevent IE 6 closure memory leaks
});
this.init = async function (containerId, initialCode) {
init = async (containerId: string, initialCode: string)=> {
debugLog('Ace2Editor.init()');
// @ts-ignore
this.importText(initialCode);
const includedCSS = [
`../static/css/iframe_editor.css?v=${clientVars.randomVersionString}`,
`../static/css/pad.css?v=${clientVars.randomVersionString}`,
`../static/css/iframe_editor.css?v=${window.clientVars.randomVersionString}`,
`../static/css/pad.css?v=${window.clientVars.randomVersionString}`,
...hooks.callAll('aceEditorCSS').map(
// Allow urls to external CSS - http(s):// and //some/path.css
(p) => /\/\//.test(p) ? p : `../static/plugins/${p}`),
`../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`,
(p: string) => /\/\//.test(p) ? p : `../static/plugins/${p}`),
`../static/skins/${window.clientVars.skinName}/pad.css?v=${window.clientVars.randomVersionString}`,
];
const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== '');
const skinVariants = window.clientVars.skinVariants.split(' ').filter((x: string) => x !== '');
const outerFrame = document.createElement('iframe');
outerFrame.name = 'ace_outer';
outerFrame.frameBorder = 0; // for IE
outerFrame.frameBorder = String(0); // for IE
outerFrame.title = 'Ether';
// Some browsers do strange things unless the iframe has a src or srcdoc property:
// - Firefox replaces the frame's contentWindow.document object with a different object after
@ -195,8 +206,9 @@ const Ace2Editor = function () {
// srcdoc is avoided because Firefox's Content Security Policy engine does not properly handle
// 'self' with nested srcdoc iframes: https://bugzilla.mozilla.org/show_bug.cgi?id=1721296
outerFrame.src = '../static/empty.html';
info.frame = outerFrame;
document.getElementById(containerId).appendChild(outerFrame);
// @ts-ignore
this.info.frame = outerFrame;
document.getElementById(containerId)!.appendChild(outerFrame);
const outerWindow = outerFrame.contentWindow;
debugLog('Ace2Editor.init() waiting for outer frame');
@ -205,13 +217,13 @@ const Ace2Editor = function () {
// Firefox might replace the outerWindow.document object after iframe creation so this variable
// is assigned after the Window's load event.
const outerDocument = outerWindow.document;
const outerDocument = outerWindow!.document;
// <html> tag
outerDocument.documentElement.classList.add('outer-editor', 'outerdoc', ...skinVariants);
// <head> tag
addStyleTagsFor(outerDocument, includedCSS);
this.addStyleTagsFor(outerDocument, includedCSS);
const outerStyle = outerDocument.createElement('style');
outerStyle.type = 'text/css';
outerStyle.title = 'dynamicsyntax';
@ -236,14 +248,11 @@ const Ace2Editor = function () {
const innerFrame = outerDocument.createElement('iframe');
innerFrame.name = 'ace_inner';
innerFrame.title = 'pad';
innerFrame.scrolling = 'no';
innerFrame.frameBorder = 0;
innerFrame.allowTransparency = true; // for IE
// The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above
// outerFrame.srcdoc.
innerFrame.src = 'empty.html';
outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild);
const innerWindow = innerFrame.contentWindow;
const innerWindow: InnerWindow = innerFrame.contentWindow!;
debugLog('Ace2Editor.init() waiting for inner frame');
await frameReady(innerFrame);
@ -251,31 +260,19 @@ const Ace2Editor = function () {
// Firefox might replace the innerWindow.document object after iframe creation so this variable
// is assigned after the Window's load event.
const innerDocument = innerWindow.document;
const innerDocument = innerWindow!.document;
// <html> tag
innerDocument.documentElement.classList.add('inner-editor', ...skinVariants);
// <head> tag
addStyleTagsFor(innerDocument, includedCSS);
//const requireKernel = innerDocument.createElement('script');
//requireKernel.type = 'text/javascript';
//requireKernel.src =
// absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`);
//innerDocument.head.appendChild(requireKernel);
// Pre-fetch modules to improve load performance.
/*for (const module of ['ace2_inner', 'ace2_common']) {
const script = innerDocument.createElement('script');
script.type = 'text/javascript';
script.src = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` +
`?callback=require.define&v=${clientVars.randomVersionString}`);
innerDocument.head.appendChild(script);
}*/
this.addStyleTagsFor(innerDocument, includedCSS);
const innerStyle = innerDocument.createElement('style');
innerStyle.type = 'text/css';
innerStyle.title = 'dynamicsyntax';
innerDocument.head.appendChild(innerStyle);
const headLines = [];
const headLines: string[] = [];
hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines});
innerDocument.head.appendChild(
innerDocument.createRange().createContextualFragment(headLines.join('\n')));
@ -285,35 +282,25 @@ const Ace2Editor = function () {
innerDocument.body.classList.add('innerdocbody');
innerDocument.body.setAttribute('spellcheck', 'false');
innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); // &nbsp;
/*
debugLog('Ace2Editor.init() waiting for require kernel load');
await eventFired(requireKernel, 'load');
debugLog('Ace2Editor.init() require kernel loaded');
const require = innerWindow.require;
require.setRootURI(absUrl('../javascripts/src'));
require.setLibraryURI(absUrl('../javascripts/lib'));
require.setGlobalKeyPath('require');
*/
// intentially moved before requiring client_plugins to save a 307
innerWindow.Ace2Inner = ace2_inner;
innerWindow.plugins = cl_plugins;
innerWindow.$ = innerWindow.jQuery = window.$;
// intentially moved before requiring client_plugins to save a 307
innerWindow!.Ace2Inner = ace2_inner;
innerWindow!.plugins = cl_plugins;
innerWindow!.$ = innerWindow.jQuery = window.$;
debugLog('Ace2Editor.init() waiting for plugins');
/*await new Promise((resolve, reject) => innerWindow.plugins.ensure(
(err) => err != null ? reject(err) : resolve()));*/
debugLog('Ace2Editor.init() waiting for Ace2Inner.init()');
await innerWindow.Ace2Inner.init(info, {
await innerWindow.Ace2Inner.init(this.info, {
inner: new Cssmanager(innerStyle.sheet),
outer: new Cssmanager(outerStyle.sheet),
parent: new Cssmanager(document.querySelector('style[title="dynamicsyntax"]').sheet),
parent: new Cssmanager((document.querySelector('style[title="dynamicsyntax"]') as HTMLStyleElement)!.sheet),
});
debugLog('Ace2Editor.init() Ace2Inner.init() returned');
loaded = true;
doActionsPendingInit();
this.loaded = true;
this.doActionsPendingInit();
debugLog('Ace2Editor.init() done');
};
};
exports.Ace2Editor = Ace2Editor;
}
}

View file

@ -6,6 +6,8 @@
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
import {Socket} from "socket.io";
/**
* Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
*
@ -22,7 +24,7 @@
* limitations under the License.
*/
let socket;
let socket: null | Socket;
// These jQuery things should create local references, but for now `require()`
@ -38,6 +40,7 @@ const chat = require('./chat').chat;
const getCollabClient = require('./collab_client').getCollabClient;
const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus;
import padcookie from "./pad_cookie";
const padeditbar = require('./pad_editbar').padeditbar;
const padeditor = require('./pad_editor').padeditor;
const padimpexp = require('./pad_impexp').padimpexp;
@ -45,9 +48,12 @@ const padmodals = require('./pad_modals').padmodals;
const padsavedrevs = require('./pad_savedrevs');
const paduserlist = require('./pad_userlist').paduserlist;
import {padUtils as padutils} from "./pad_utils";
const colorutils = require('./colorutils').colorutils;
const randomString = require('./pad_utils').randomString;
import connect from './socketio'
import {SocketClientReadyMessage} from "./types/SocketIOMessage";
import {MapArrayType} from "../../node/types/MapType";
const hooks = require('./pluginfw/hooks');
@ -61,22 +67,22 @@ const getParameters = [
{
name: 'noColors',
checkVal: 'true',
callback: (val) => {
settings.noColors = true;
callback: (val: any) => {
pad.settings.noColors = true;
$('#clearAuthorship').hide();
},
},
{
name: 'showControls',
checkVal: 'true',
callback: (val) => {
callback: (val: any) => {
$('#editbar').css('display', 'flex');
},
},
{
name: 'showChat',
checkVal: null,
callback: (val) => {
callback: (val: any) => {
if (val === 'false') {
settings.hideChat = true;
chat.hide();
@ -103,7 +109,7 @@ const getParameters = [
checkVal: null,
callback: (val) => {
settings.globalUserName = val;
clientVars.userName = val;
window.clientVars.userName = val;
},
},
{
@ -111,7 +117,7 @@ const getParameters = [
checkVal: null,
callback: (val) => {
settings.globalUserColor = val;
clientVars.userColor = val;
window.clientVars.userColor = val;
},
},
{
@ -149,7 +155,7 @@ const getParameters = [
const getParams = () => {
// Tries server enforced options first..
for (const setting of getParameters) {
let value = clientVars.padOptions[setting.name];
let value = window.clientVars.padOptions[setting.name];
if (value == null) continue;
value = value.toString();
if (value === setting.checkVal || setting.checkVal == null) {
@ -169,7 +175,7 @@ const getParams = () => {
const getUrlVars = () => new URL(window.location.href).searchParams;
const sendClientReady = (isReconnect) => {
const sendClientReady = (isReconnect: boolean) => {
let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);
// unescape necessary due to Safari and Opera interpretation of spaces
padId = decodeURIComponent(padId);
@ -196,7 +202,7 @@ const sendClientReady = (isReconnect) => {
name: params.get('userName'),
};
const msg = {
const msg: SocketClientReadyMessage = {
component: 'pad',
type: 'CLIENT_READY',
padId,
@ -207,11 +213,11 @@ const sendClientReady = (isReconnect) => {
// this is a reconnect, lets tell the server our revisionnumber
if (isReconnect) {
msg.client_rev = pad.collabClient.getCurrentRevisionNumber();
msg.client_rev = this.collabClient!.getCurrentRevisionNumber();
msg.reconnect = true;
}
socket.emit("message", msg);
socket!.emit("message", msg);
};
const handshake = async () => {
@ -315,7 +321,8 @@ const handshake = async () => {
window.clientVars = obj.data;
if (window.clientVars.sessionRefreshInterval) {
const ping =
() => $.ajax('../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {});
() => $.ajax('../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {
});
setInterval(ping, window.clientVars.sessionRefreshInterval);
}
if (window.clientVars.mode === "development") {
@ -381,30 +388,52 @@ class MessageQueue {
}
}
const pad = {
export class Pad {
private collabClient: null;
private myUserInfo: null | {
userId: string,
name: string,
ip: string,
colorId: string,
};
private diagnosticInfo: {};
private initTime: number;
private clientTimeOffset: null | number;
private _messageQ: MessageQueue;
private padOptions: MapArrayType<MapArrayType<string>>;
settings: PadSettings = {
LineNumbersDisabled: false,
noColors: false,
useMonospaceFontGlobal: false,
globalUserName: false,
globalUserColor: false,
rtlIsTrue: false,
}
constructor() {
// don't access these directly from outside this file, except
// for debugging
collabClient: null,
myUserInfo: null,
diagnosticInfo: {},
initTime: 0,
clientTimeOffset: null,
padOptions: {},
_messageQ: new MessageQueue(),
this.collabClient = null
this.myUserInfo = null
this.diagnosticInfo = {}
this.initTime = 0
this.clientTimeOffset = null
this.padOptions = {}
this._messageQ = new MessageQueue()
}
// these don't require init; clientVars should all go through here
getPadId: () => clientVars.padId,
getClientIp: () => clientVars.clientIp,
getColorPalette: () => clientVars.colorPalette,
getPrivilege: (name) => clientVars.accountPrivs[name],
getUserId: () => pad.myUserInfo.userId,
getUserName: () => pad.myUserInfo.name,
userList: () => paduserlist.users(),
sendClientMessage: (msg) => {
pad.collabClient.sendClientMessage(msg);
},
init() {
getPadId = () => window.clientVars.padId
getClientIp = () => window.clientVars.clientIp
getColorPalette = () => window.clientVars.colorPalette
getPrivilege = (name: string) => window.clientVars.accountPrivs[name]
getUserId = () => this.myUserInfo!.userId
getUserName = () => this.myUserInfo!.name
userList = () => paduserlist.users()
sendClientMessage = (msg: string) => {
this.collabClient.sendClientMessage(msg);
}
init = () => {
padutils.setupGlobalExceptionHandler();
// $(handler), $().ready(handler), $.wait($.ready).then(handler), etc. don't work if handler is
@ -412,28 +441,32 @@ const pad = {
// function.
$(() => (async () => {
if (window.customStart != null) window.customStart();
// @ts-ignore
$('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220});
$('#readonlyinput').on('click', () => { padeditbar.setEmbedLinks(); });
$('#readonlyinput').on('click', () => {
padeditbar.setEmbedLinks();
});
padcookie.init();
await handshake();
this._afterHandshake();
})());
},
}
_afterHandshake() {
pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp;
this.clientTimeOffset = Date.now() - window.clientVars.serverTimestamp;
// initialize the chat
chat.init(this);
getParams();
padcookie.init(); // initialize the cookies
pad.initTime = +(new Date());
pad.padOptions = clientVars.initialOptions;
this.initTime = +(new Date());
this.padOptions = window.clientVars.initialOptions;
pad.myUserInfo = {
userId: clientVars.userId,
name: clientVars.userName,
ip: pad.getClientIp(),
colorId: clientVars.userColor,
this.myUserInfo = {
userId: window.clientVars.userId,
name: window.clientVars.userName,
ip: this.getClientIp(),
colorId: window.clientVars.userColor,
};
const postAceInit = () => {
@ -442,7 +475,9 @@ const pad = {
padeditor.ace.focus();
}, 0);
const optionsStickyChat = $('#options-stickychat');
optionsStickyChat.on('click', () => { chat.stickToScreen(); });
optionsStickyChat.on('click', () => {
chat.stickToScreen();
});
// if we have a cookie for always showing chat then show it
if (padcookie.getPref('chatAlwaysVisible')) {
chat.stickToScreen(true); // stick it to the screen
@ -454,15 +489,16 @@ const pad = {
$('#options-chatandusers').prop('checked', true); // set the checkbox to on
}
if (padcookie.getPref('showAuthorshipColors') === false) {
pad.changeViewOption('showAuthorColors', false);
this.changeViewOption('showAuthorColors', false);
}
if (padcookie.getPref('showLineNumbers') === false) {
pad.changeViewOption('showLineNumbers', false);
this.changeViewOption('showLineNumbers', false);
}
if (padcookie.getPref('rtlIsTrue') === true) {
pad.changeViewOption('rtlIsTrue', true);
this.changeViewOption('rtlIsTrue', true);
}
pad.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily'));
this.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily'));
// @ts-ignore
$('#viewfontmenu').val(padcookie.getPref('padFontFamily')).niceSelect('update');
// Prevent sticky chat or chat and users to be checked for mobiles
@ -474,37 +510,39 @@ const pad = {
};
const mobileMatch = window.matchMedia('(max-width: 800px)');
mobileMatch.addListener(checkChatAndUsersVisibility); // check if window resized
setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0); // check now after load
setTimeout(() => {
checkChatAndUsersVisibility(mobileMatch);
}, 0); // check now after load
$('#editorcontainer').addClass('initialized');
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars: window.clientVars, pad});
};
// order of inits is important here:
padimpexp.init(this);
padsavedrevs.init(this);
padeditor.init(pad.padOptions.view || {}, this).then(postAceInit);
paduserlist.init(pad.myUserInfo, this);
padeditor.init(this.padOptions.view || {}, this).then(postAceInit);
paduserlist.init(this.myUserInfo, this);
padconnectionstatus.init();
padmodals.init(this);
pad.collabClient = getCollabClient(
padeditor.ace, clientVars.collab_client_vars, pad.myUserInfo,
{colorPalette: pad.getColorPalette()}, pad);
this.collabClient = getCollabClient(
padeditor.ace, window.clientVars.collab_client_vars, this.myUserInfo,
{colorPalette: this.getColorPalette()}, pad);
this._messageQ.setCollabClient(this.collabClient);
pad.collabClient.setOnUserJoin(pad.handleUserJoin);
pad.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate);
pad.collabClient.setOnUserLeave(pad.handleUserLeave);
pad.collabClient.setOnClientMessage(pad.handleClientMessage);
pad.collabClient.setOnChannelStateChange(pad.handleChannelStateChange);
pad.collabClient.setOnInternalAction(pad.handleCollabAction);
this.collabClient.setOnUserJoin(this.handleUserJoin);
this.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate);
this.collabClient.setOnUserLeave(pad.handleUserLeave);
this.collabClient.setOnClientMessage(pad.handleClientMessage);
this.collabClient.setOnChannelStateChange(pad.handleChannelStateChange);
this.collabClient.setOnInternalAction(pad.handleCollabAction);
// load initial chat-messages
if (clientVars.chatHead !== -1) {
const chatHead = clientVars.chatHead;
if (window.clientVars.chatHead !== -1) {
const chatHead = window.clientVars.chatHead;
const start = Math.max(chatHead - 100, 0);
pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead});
this.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead});
} else {
// there are no messages
$('#chatloadmessagesbutton').css('display', 'none');
@ -517,7 +555,9 @@ const pad = {
$('#chaticon').hide();
$('#options-chatandusers').parent().hide();
$('#options-stickychat').parent().hide();
} else if (!settings.hideChat) { $('#chaticon').show(); }
} else if (!settings.hideChat) {
$('#chaticon').show();
}
$('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite');
@ -558,71 +598,80 @@ const pad = {
this.notifyChangeColor(settings.globalUserColor); // Updates this.myUserInfo.colorId
paduserlist.setMyUserInfo(this.myUserInfo);
}
},
}
dispose: () => {
dispose = () => {
padeditor.dispose();
},
notifyChangeName: (newName) => {
pad.myUserInfo.name = newName;
pad.collabClient.updateUserInfo(pad.myUserInfo);
},
notifyChangeColor: (newColorId) => {
pad.myUserInfo.colorId = newColorId;
pad.collabClient.updateUserInfo(pad.myUserInfo);
},
changePadOption: (key, value) => {
const options = {};
}
notifyChangeName = (newName) => {
this.myUserInfo.name = newName;
this.collabClient.updateUserInfo(this.myUserInfo);
}
notifyChangeColor = (newColorId) => {
this.myUserInfo.colorId = newColorId;
this.collabClient.updateUserInfo(this.myUserInfo);
}
changePadOption = (key: string, value: string) => {
const options: MapArrayType<string> = {};
options[key] = value;
pad.handleOptionsChange(options);
pad.collabClient.sendClientMessage(
this.handleOptionsChange(options);
this.collabClient.sendClientMessage(
{
type: 'padoptions',
options,
changedBy: pad.myUserInfo.name || 'unnamed',
});
},
changeViewOption: (key, value) => {
const options = {
view: {},
};
changedBy: this.myUserInfo.name || 'unnamed',
})
}
changeViewOption = (key: string, value: string) => {
const options: MapArrayType<MapArrayType<any>> =
{
view: {}
,
}
;
options.view[key] = value;
pad.handleOptionsChange(options);
},
handleOptionsChange: (opts) => {
this.handleOptionsChange(options);
}
handleOptionsChange = (opts: MapArrayType<MapArrayType<string>>) => {
// opts object is a full set of options or just
// some options to change
if (opts.view) {
if (!pad.padOptions.view) {
pad.padOptions.view = {};
if (!this.padOptions.view) {
this.padOptions.view = {};
}
for (const [k, v] of Object.entries(opts.view)) {
pad.padOptions.view[k] = v;
this.padOptions.view[k] = v;
padcookie.setPref(k, v);
}
padeditor.setViewOptions(pad.padOptions.view);
padeditor.setViewOptions(this.padOptions.view);
}
},
// caller shouldn't mutate the object
getPadOptions: () => pad.padOptions,
suggestUserName: (userId, name) => {
pad.collabClient.sendClientMessage(
}
getPadOptions = () => this.padOptions
suggestUserName =
(userId: string, name: string) => {
this.collabClient.sendClientMessage(
{
type: 'suggestUserName',
unnamedId: userId,
newName: name,
});
},
handleUserJoin: (userInfo) => {
}
handleUserJoin = (userInfo) => {
paduserlist.userJoinOrUpdate(userInfo);
},
handleUserUpdate: (userInfo) => {
}
handleUserUpdate = (userInfo) => {
paduserlist.userJoinOrUpdate(userInfo);
},
handleUserLeave: (userInfo) => {
}
handleUserLeave =
(userInfo) => {
paduserlist.userLeave(userInfo);
},
handleClientMessage: (msg) => {
}
// caller shouldn't mutate the object
handleClientMessage =
(msg) => {
if (msg.type === 'suggestUserName') {
if (msg.unnamedId === pad.myUserInfo.userId && msg.newName && !pad.myUserInfo.name) {
pad.notifyChangeName(msg.newName);
@ -636,8 +685,11 @@ const pad = {
const opts = msg.options;
pad.handleOptionsChange(opts);
}
},
handleChannelStateChange: (newState, message) => {
}
handleChannelStateChange
=
(newState, message) => {
const oldFullyConnected = !!padconnectionstatus.isFullyConnected();
const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting');
if (newState === 'CONNECTED') {
@ -679,16 +731,20 @@ const pad = {
if (newFullyConnected !== oldFullyConnected) {
pad.handleIsFullyConnected(newFullyConnected, wasConnecting);
}
},
handleIsFullyConnected: (isConnected, isInitialConnect) => {
}
handleIsFullyConnected
=
(isConnected, isInitialConnect) => {
pad.determineChatVisibility(isConnected && !isInitialConnect);
pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect);
pad.determineAuthorshipColorsVisibility();
setTimeout(() => {
padeditbar.toggleDropDown('none');
}, 1000);
},
determineChatVisibility: (asNowConnectedFeedback) => {
}
determineChatVisibility
=
(asNowConnectedFeedback) => {
const chatVisCookie = padcookie.getPref('chatAlwaysVisible');
if (chatVisCookie) { // if the cookie is set for chat always visible
chat.stickToScreen(true); // stick it to the screen
@ -696,8 +752,10 @@ const pad = {
} else {
$('#options-stickychat').prop('checked', false); // set the checkbox for off
}
},
determineChatAndUsersVisibility: (asNowConnectedFeedback) => {
}
determineChatAndUsersVisibility
=
(asNowConnectedFeedback) => {
const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible');
if (chatAUVisCookie) { // if the cookie is set for chat always visible
chat.chatAndUsers(true); // stick it to the screen
@ -705,8 +763,10 @@ const pad = {
} else {
$('#options-chatandusers').prop('checked', false); // set the checkbox for off
}
},
determineAuthorshipColorsVisibility: () => {
}
determineAuthorshipColorsVisibility
=
() => {
const authColCookie = padcookie.getPref('showAuthorshipColors');
if (authColCookie) {
pad.changeViewOption('showAuthorColors', true);
@ -714,15 +774,19 @@ const pad = {
} else {
$('#options-colorscheck').prop('checked', false);
}
},
handleCollabAction: (action) => {
}
handleCollabAction
=
(action) => {
if (action === 'commitPerformed') {
padeditbar.setSyncStatus('syncing');
} else if (action === 'newlyIdle') {
padeditbar.setSyncStatus('done');
}
},
asyncSendDiagnosticInfo: () => {
}
asyncSendDiagnosticInfo
=
() => {
window.setTimeout(() => {
$.ajax(
{
@ -731,25 +795,37 @@ const pad = {
data: {
diagnosticInfo: JSON.stringify(pad.diagnosticInfo),
},
success: () => {},
error: () => {},
success: () => {
},
error: () => {
},
});
}, 0);
},
forceReconnect: () => {
}
forceReconnect
=
() => {
$('form#reconnectform input.padId').val(pad.getPadId());
pad.diagnosticInfo.collabDiagnosticInfo = pad.collabClient.getDiagnosticInfo();
$('form#reconnectform input.diagnosticInfo').val(JSON.stringify(pad.diagnosticInfo));
$('form#reconnectform input.missedChanges')
.val(JSON.stringify(pad.collabClient.getMissedChanges()));
$('form#reconnectform').trigger('submit');
},
callWhenNotCommitting: (f) => {
}
callWhenNotCommitting
=
(f) => {
pad.collabClient.callWhenNotCommitting(f);
},
getCollabRevisionNumber: () => pad.collabClient.getCurrentRevisionNumber(),
isFullyConnected: () => padconnectionstatus.isFullyConnected(),
addHistoricalAuthors: (data) => {
}
getCollabRevisionNumber
=
() => pad.collabClient.getCurrentRevisionNumber()
isFullyConnected
=
() => padconnectionstatus.isFullyConnected()
addHistoricalAuthors
=
(data) => {
if (!pad.collabClient) {
window.setTimeout(() => {
pad.addHistoricalAuthors(data);
@ -757,24 +833,24 @@ const pad = {
} else {
pad.collabClient.addHistoricalAuthors(data);
}
},
};
}
}
const init = () => pad.init();
const settings = {
LineNumbersDisabled: false,
noColors: false,
useMonospaceFontGlobal: false,
globalUserName: false,
globalUserColor: false,
rtlIsTrue: false,
};
export type PadSettings = {
LineNumbersDisabled: boolean,
noColors: boolean,
useMonospaceFontGlobal: boolean,
globalUserName: string | boolean,
globalUserColor: string | boolean,
rtlIsTrue: boolean,
hideChat?: boolean,
}
export const pad = new Pad()
pad.settings = settings;
exports.baseURL = '';
exports.settings = settings;
exports.randomString = randomString;
exports.getParams = getParams;
exports.pad = pad;

View file

@ -1,211 +0,0 @@
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const Cookies = require('./pad_utils').Cookies;
import padcookie from "./pad_cookie";
import {padUtils as padutils} from "./pad_utils";
const Ace2Editor = require('./ace').Ace2Editor;
import html10n from '../js/vendors/html10n'
const padeditor = (() => {
let pad = undefined;
let settings = undefined;
const self = {
ace: null,
// this is accessed directly from other files
viewZoom: 100,
init: async (initialViewOptions, _pad) => {
pad = _pad;
settings = pad.settings;
self.ace = new Ace2Editor();
await self.ace.init('editorcontainer', '');
$('#editorloadingbox').hide();
// Listen for clicks on sidediv items
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
$outerdoc.find('#sidedivinner').on('click', 'div', function () {
const targetLineNumber = $(this).index() + 1;
window.location.hash = `L${targetLineNumber}`;
});
exports.focusOnLine(self.ace);
self.ace.setProperty('wraps', true);
self.initViewOptions();
self.setViewOptions(initialViewOptions);
// view bar
$('#viewbarcontents').show();
},
initViewOptions: () => {
// Line numbers
padutils.bindCheckboxChange($('#options-linenoscheck'), () => {
pad.changeViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck')));
});
// Author colors
padutils.bindCheckboxChange($('#options-colorscheck'), () => {
padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck'));
pad.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck'));
});
// Right to left
padutils.bindCheckboxChange($('#options-rtlcheck'), () => {
pad.changeViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck')));
});
html10n.bind('localized', () => {
pad.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection()));
});
// font family change
$('#viewfontmenu').on('change', () => {
pad.changeViewOption('padFontFamily', $('#viewfontmenu').val());
});
// Language
html10n.bind('localized', () => {
$('#languagemenu').val(html10n.getLanguage());
// translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist
// this does not interfere with html10n's normal value-setting because
// html10n just ingores <input>s
// also, a value which has been set by the user will be not overwritten
// since a user-edited <input> does *not* have the editempty-class
$('input[data-l10n-id]').each((key, input) => {
input = $(input);
if (input.hasClass('editempty')) {
input.val(html10n.get(input.attr('data-l10n-id')));
}
});
});
$('#languagemenu').val(html10n.getLanguage());
$('#languagemenu').on('change', () => {
Cookies.set('language', $('#languagemenu').val());
html10n.localize([$('#languagemenu').val(), 'en']);
if ($('select').niceSelect) {
$('select').niceSelect('update');
}
});
},
setViewOptions: (newOptions) => {
const getOption = (key, defaultValue) => {
const value = String(newOptions[key]);
if (value === 'true') return true;
if (value === 'false') return false;
return defaultValue;
};
let v;
v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
self.ace.setProperty('rtlIsTrue', v);
padutils.setCheckbox($('#options-rtlcheck'), v);
v = getOption('showLineNumbers', true);
self.ace.setProperty('showslinenumbers', v);
padutils.setCheckbox($('#options-linenoscheck'), v);
v = getOption('showAuthorColors', true);
self.ace.setProperty('showsauthorcolors', v);
$('#chattext').toggleClass('authorColors', v);
$('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v);
padutils.setCheckbox($('#options-colorscheck'), v);
// Override from parameters if true
if (settings.noColors !== false) {
self.ace.setProperty('showsauthorcolors', !settings.noColors);
}
self.ace.setProperty('textface', newOptions.padFontFamily || '');
},
dispose: () => {
if (self.ace) {
self.ace.destroy();
self.ace = null;
}
},
enable: () => {
if (self.ace) {
self.ace.setEditable(true);
}
},
disable: () => {
if (self.ace) {
self.ace.setEditable(false);
}
},
restoreRevisionText: (dataFromServer) => {
pad.addHistoricalAuthors(dataFromServer.historicalAuthorData);
self.ace.importAText(dataFromServer.atext, dataFromServer.apool, true);
},
};
return self;
})();
exports.padeditor = padeditor;
exports.focusOnLine = (ace) => {
// If a number is in the URI IE #L124 go to that line number
const lineNumber = window.location.hash.substr(1);
if (lineNumber) {
if (lineNumber[0] === 'L') {
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
const lineNumberInt = parseInt(lineNumber.substr(1));
if (lineNumberInt) {
const $inner = $('iframe[name="ace_outer"]').contents().find('iframe')
.contents().find('#innerdocbody');
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
if (line.length !== 0) {
let offsetTop = line.offset().top;
offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));
const hasMobileLayout = $('body').hasClass('mobile-layout');
if (!hasMobileLayout) {
offsetTop += parseInt($inner.css('padding-top').replace('px', ''));
}
const $outerdocHTML = $('iframe[name="ace_outer"]').contents()
.find('#outerdocbody').parent();
$outerdoc.css({top: `${offsetTop}px`}); // Chrome
$outerdocHTML.animate({scrollTop: offsetTop}); // needed for FF
const node = line[0];
ace.callWithAce((ace) => {
const selection = {
startPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
endPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
};
ace.ace_setSelection(selection);
});
}
}
}
}
// End of setSelection / set Y position of editor
};

229
src/static/js/pad_editor.ts Normal file
View file

@ -0,0 +1,229 @@
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
import {PadType} from "../../node/types/PadType";
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Cookies} from "./pad_utils";
import padcookie from "./pad_cookie";
import {padUtils as padutils} from "./pad_utils";
import {Ace2Editor} from "./ace";
import html10n from '../js/vendors/html10n'
import {MapArrayType} from "../../node/types/MapType";
import {ClientVarData, ClientVarMessage} from "./types/SocketIOMessage";
export class PadEditor {
private pad?: PadType
private settings: undefined| ClientVarData
private ace: any
private viewZoom: number
constructor() {
this.pad = undefined;
this.settings = undefined;
this.ace = null
// this is accessed directly from other files
this.viewZoom = 100
}
init = async (initialViewOptions: MapArrayType<string>, _pad: PadType) => {
this.pad = _pad;
this.settings = this.pad.settings;
this.ace = new Ace2Editor();
await this.ace.init('editorcontainer', '');
$('#editorloadingbox').hide();
// Listen for clicks on sidediv items
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
$outerdoc.find('#sidedivinner').on('click', 'div', function () {
const targetLineNumber = $(this).index() + 1;
window.location.hash = `L${targetLineNumber}`;
});
this.focusOnLine(this.ace);
this.ace.setProperty('wraps', true);
this.initViewOptions();
this.setViewOptions(initialViewOptions);
// view bar
$('#viewbarcontents').show();
}
initViewOptions = () => {
// Line numbers
padutils.bindCheckboxChange($('#options-linenoscheck'), () => {
this.pad!.changeViewOption('showLineNumbers', padutils.getCheckbox('#options-linenoscheck'));
});
// Author colors
padutils.bindCheckboxChange($('#options-colorscheck'), () => {
padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck') as any);
this.pad!.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck'));
});
// Right to left
padutils.bindCheckboxChange($('#options-rtlcheck'), () => {
this.pad!.changeViewOption('rtlIsTrue', padutils.getCheckbox('#options-rtlcheck'));
});
html10n.bind('localized', () => {
this.pad!.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection()));
});
// font family change
$('#viewfontmenu').on('change', () => {
this.pad!.changeViewOption('padFontFamily', $('#viewfontmenu').val());
});
// Language
html10n.bind('localized', () => {
$('#languagemenu').val(html10n.getLanguage()!);
// translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist
// this does not interfere with html10n's normal value-setting because
// html10n just ingores <input>s
// also, a value which has been set by the user will be not overwritten
// since a user-edited <input> does *not* have the editempty-class
$('input[data-l10n-id]').each((key, input) => {
// @ts-ignore
input = $(input);
// @ts-ignore
if (input.hasClass('editempty')) {
// @ts-ignore
input.val(html10n.get(input.attr('data-l10n-id')));
}
});
});
$('#languagemenu').val(html10n.getLanguage()!);
$('#languagemenu').on('change', () => {
Cookies.set('language', $('#languagemenu').val() as string);
html10n.localize([$('#languagemenu').val() as string, 'en']);
// @ts-ignore
if ($('select').niceSelect) {
// @ts-ignore
$('select').niceSelect('update');
}
});
}
setViewOptions = (newOptions: MapArrayType<string>) => {
const getOption = (key: string, defaultValue: boolean) => {
const value = String(newOptions[key]);
if (value === 'true') return true;
if (value === 'false') return false;
return defaultValue;
};
let v;
v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
this.ace.setProperty('rtlIsTrue', v);
padutils.setCheckbox($('#options-rtlcheck'), v);
v = getOption('showLineNumbers', true);
this.ace.setProperty('showslinenumbers', v);
padutils.setCheckbox($('#options-linenoscheck'), v);
v = getOption('showAuthorColors', true);
this.ace.setProperty('showsauthorcolors', v);
$('#chattext').toggleClass('authorColors', v);
$('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v);
padutils.setCheckbox($('#options-colorscheck'), v);
// Override from parameters if true
if (this.settings!.noColors !== false) {
this.ace.setProperty('showsauthorcolors', !settings.noColors);
}
this.ace.setProperty('textface', newOptions.padFontFamily || '');
}
dispose = () => {
if (this.ace) {
this.ace.destroy();
this.ace = null;
}
}
enable = () => {
if (this.ace) {
this.ace.setEditable(true);
}
}
disable = () => {
if (this.ace) {
this.ace.setEditable(false);
}
}
restoreRevisionText= (dataFromServer: ClientVarData) => {
this.pad!.addHistoricalAuthors(dataFromServer.historicalAuthorData);
this.ace.importAText(dataFromServer.atext, dataFromServer.apool, true);
}
focusOnLine = (ace) => {
// If a number is in the URI IE #L124 go to that line number
const lineNumber = window.location.hash.substr(1);
if (lineNumber) {
if (lineNumber[0] === 'L') {
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
const lineNumberInt = parseInt(lineNumber.substr(1));
if (lineNumberInt) {
const $inner = $('iframe[name="ace_outer"]').contents().find('iframe')
.contents().find('#innerdocbody');
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
if (line.length !== 0) {
let offsetTop = line.offset()!.top;
offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));
const hasMobileLayout = $('body').hasClass('mobile-layout');
if (!hasMobileLayout) {
offsetTop += parseInt($inner.css('padding-top').replace('px', ''));
}
const $outerdocHTML = $('iframe[name="ace_outer"]').contents()
.find('#outerdocbody').parent();
$outerdoc.css({top: `${offsetTop}px`}); // Chrome
$outerdocHTML.animate({scrollTop: offsetTop}); // needed for FF
const node = line[0];
ace.callWithAce((ace) => {
const selection = {
startPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
endPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
};
ace.ace_setSelection(selection);
});
}
}
}
}
// End of setSelection / set Y position of editor
}
}
export const padEditor = new PadEditor();

View file

@ -343,9 +343,9 @@ class PadUtils {
clear,
};
}
getCheckbox = (node: JQueryNode) => $(node).is(':checked')
getCheckbox = (node: string) => $(node).is(':checked')
setCheckbox =
(node: JQueryNode, value: string) => {
(node: JQueryNode, value: boolean) => {
if (value) {
$(node).attr('checked', 'checked');
} else {

View file

@ -0,0 +1,4 @@
export type AText = {
text: string,
attribs: string,
}

View file

@ -0,0 +1,6 @@
export type ChangeSet = {
oldLen: number;
newLen: number;
ops: string;
charBank: string;
}

View file

@ -0,0 +1,5 @@
export type InnerWindow = Window & {
Ace2Inner?: any,
plugins?: any,
jQuery?: any
}

View file

@ -1,13 +1,55 @@
import {MapArrayType} from "../../../node/types/MapType";
import {AText} from "./AText";
import AttributePool from "../AttributePool";
export type SocketIOMessage = {
type: string
accessStatus: string
}
export type ClientVarData = {
sessionRefreshInterval: number,
historicalAuthorData:MapArrayType<{
name: string;
colorId: string;
}>,
atext: AText,
apool: AttributePool,
noColors: boolean,
userName: string,
userColor:string,
hideChat: boolean,
padOptions: MapArrayType<string>,
padId: string,
clientIp: string,
colorPalette: MapArrayType<string>,
accountPrivs: MapArrayType<string>,
collab_client_vars: MapArrayType<string>,
chatHead: number,
readonly: boolean,
serverTimestamp: number,
initialOptions: MapArrayType<string>,
userId: string,
}
export type ClientVarMessage = {
data: {
sessionRefreshInterval: number
}
data: ClientVarData,
type: string
accessStatus: string
}
export type SocketClientReadyMessage = {
type: string
component: string
padId: string
sessionID: string
token: string
userInfo: {
colorId: string|null
name: string|null
},
reconnect?: boolean
client_rev?: number
}

View file

@ -1,6 +1,9 @@
import {ClientVarData} from "./SocketIOMessage";
declare global {
interface Window {
clientVars: any;
$: any
clientVars: ClientVarData;
$: any,
customStart?:any
}
}