Feat/bundle js (#6511)

* Added minify

* Added POC for browser

* Moved first js files to ts

* Fixed caret positioning

* Added support for plugins

* Fixed get undefined.

* Removed require of socketio, l10n, html10n and error reporter

* Added minify

* Added POC for browser

* Moved first js files to ts

* Fixed caret positioning

* Added support for plugins

* Fixed get undefined.

* Removed require of socketio, l10n, html10n and error reporter

* Fixed popup not showing

* Fixed timeslider

* Reworked paths

* Fixed loading

* Don't generate sources map in production mode

* Non working hmr

* Added live reloading.

* Fixed timeslider when hot reloading

* Removed eval

* Fixed.

* Fixed env

* Fixed frontend tests.

* Added minifying via lightningcss

* Added minify via esbuild

* Fixed diagnostic url

* Removed lightningcss

* Fixed types

* Fixed alias

* Fixed loadtest

* Fixed

* Fixed loading ep_font_color3

* Restructure windows build

* Fixed windows build

* Fixed pnpm lock

---------

Co-authored-by: SamTv12345 <samtv12345@samtv12345.com>
This commit is contained in:
SamTV12345 2024-07-18 08:51:30 +02:00 committed by GitHub
parent 33b388b14c
commit d6d636955c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 2764 additions and 1763 deletions

View file

@ -4,7 +4,7 @@ const AttributeMap = require('./AttributeMap');
const Changeset = require('./Changeset');
const ChangesetUtils = require('./ChangesetUtils');
const attributes = require('./attributes');
const _ = require('./underscore');
const underscore = require("underscore")
const lineMarkerAttribute = 'lmkr';
@ -45,7 +45,7 @@ const AttributeManager = function (rep, applyChangesetCallback) {
AttributeManager.DEFAULT_LINE_ATTRIBUTES = DEFAULT_LINE_ATTRIBUTES;
AttributeManager.lineAttributes = lineAttributes;
AttributeManager.prototype = _(AttributeManager.prototype).extend({
AttributeManager.prototype = underscore.default(AttributeManager.prototype).extend({
applyChangeset(changeset) {
if (!this.applyChangesetCallback) return changeset;
@ -335,7 +335,7 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({
ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]);
const countAttribsWithMarker = _.chain(attribs).filter((a) => !!a[1])
const countAttribsWithMarker = underscore.chain(attribs).filter((a) => !!a[1])
.map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).size().value();
// if we have marker and any of attributes don't need to have marker. we need delete it

View file

@ -27,9 +27,10 @@
const hooks = require('./pluginfw/hooks');
const makeCSSManager = require('./cssmanager').makeCSSManager;
const pluginUtils = require('./pluginfw/shared');
const ace2_inner = require('ep_etherpad-lite/static/js/ace2_inner')
const debugLog = (...args) => {};
const cl_plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins')
const rJQuery = require('ep_etherpad-lite/static/js/rjquery')
// 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.
@ -257,19 +258,19 @@ const Ace2Editor = function () {
// <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);
//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']) {
/*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);
}
}*/
const innerStyle = innerDocument.createElement('style');
innerStyle.type = 'text/css';
innerStyle.title = 'dynamicsyntax';
@ -284,7 +285,7 @@ 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');
@ -292,17 +293,16 @@ const Ace2Editor = function () {
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 = require('ep_etherpad-lite/static/js/ace2_inner');
innerWindow.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');
innerWindow.plugins.adoptPluginsFromAncestorsOf(innerWindow);
innerWindow.Ace2Inner = ace2_inner;
innerWindow.plugins = cl_plugins;
innerWindow.$ = innerWindow.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery;
innerWindow.$ = innerWindow.jQuery = rJQuery.jQuery;
debugLog('Ace2Editor.init() waiting for plugins');
await new Promise((resolve, reject) => innerWindow.plugins.ensure(
(err) => err != null ? reject(err) : resolve()));
/*await new Promise((resolve, reject) => innerWindow.plugins.ensure(
(err) => err != null ? reject(err) : resolve()));*/
debugLog('Ace2Editor.init() waiting for Ace2Inner.init()');
await innerWindow.Ace2Inner.init(info, {
inner: makeCSSManager(innerStyle.sheet),

View file

@ -30,6 +30,8 @@ const setAssoc = Ace2Common.setAssoc;
const noop = Ace2Common.noop;
const hooks = require('./pluginfw/hooks');
import Scroll from './scroll'
function Ace2Inner(editorInfo, cssManagers) {
const makeChangesetTracker = require('./changesettracker').makeChangesetTracker;
const colorutils = require('./colorutils').colorutils;
@ -42,7 +44,6 @@ function Ace2Inner(editorInfo, cssManagers) {
const SkipList = require('./skiplist');
const undoModule = require('./undomodule').undoModule;
const AttributeManager = require('./AttributeManager');
const Scroll = require('./scroll');
const DEBUG = false;
const THE_TAB = ' '; // 4
@ -54,13 +55,16 @@ function Ace2Inner(editorInfo, cssManagers) {
let thisAuthor = '';
let disposed = false;
const outerWin = document.getElementsByName("ace_outer")[0]
const targetDoc = outerWin.contentWindow.document.getElementsByName("ace_inner")[0].contentWindow.document
const targetBody = targetDoc.body
const focus = () => {
window.focus();
targetBody.focus();
};
const outerWin = window.parent;
const outerDoc = outerWin.document;
const outerDoc = outerWin.contentWindow.document;
const sideDiv = outerDoc.getElementById('sidediv');
const lineMetricsDiv = outerDoc.getElementById('linemetricsdiv');
const sideDivInner = outerDoc.getElementById('sidedivinner');
@ -74,7 +78,7 @@ function Ace2Inner(editorInfo, cssManagers) {
};
appendNewSideDivLine();
const scroll = Scroll.init(outerWin);
const scroll = new Scroll(outerWin);
let outsideKeyDown = noop;
let outsideKeyPress = (e) => true;
@ -415,7 +419,7 @@ function Ace2Inner(editorInfo, cssManagers) {
const setWraps = (newVal) => {
doesWrap = newVal;
document.body.classList.toggle('doesWrap', doesWrap);
targetBody.classList.toggle('doesWrap', doesWrap);
scheduler.setTimeout(() => {
inCallStackIfNecessary('setWraps', () => {
fastIncorp(7);
@ -445,7 +449,7 @@ function Ace2Inner(editorInfo, cssManagers) {
};
const setTextFace = (face) => {
document.body.style.fontFamily = face;
targetBody.style.fontFamily = face;
lineMetricsDiv.style.fontFamily = face;
};
@ -456,8 +460,8 @@ function Ace2Inner(editorInfo, cssManagers) {
const setEditable = (newVal) => {
isEditable = newVal;
document.body.contentEditable = isEditable ? 'true' : 'false';
document.body.classList.toggle('static', !isEditable);
targetBody.contentEditable = isEditable ? 'true' : 'false';
targetBody.classList.toggle('static', !isEditable);
};
const enforceEditability = () => setEditable(isEditable);
@ -480,6 +484,7 @@ function Ace2Inner(editorInfo, cssManagers) {
newText = `${lines.join('\n')}\n`;
}
inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => {
setDocText(newText);
});
@ -640,8 +645,8 @@ function Ace2Inner(editorInfo, cssManagers) {
// These properties are exposed
const setters = {
wraps: setWraps,
showsauthorcolors: (val) => document.body.classList.toggle('authorColors', !!val),
showsuserselections: (val) => document.body.classList.toggle('userSelections', !!val),
showsauthorcolors: (val) => targetBody.classList.toggle('authorColors', !!val),
showsuserselections: (val) => targetBody.classList.toggle('userSelections', !!val),
showslinenumbers: (value) => {
hasLineNumbers = !!value;
sideDiv.parentNode.classList.toggle('line-numbers-hidden', !hasLineNumbers);
@ -654,8 +659,8 @@ function Ace2Inner(editorInfo, cssManagers) {
styled: setStyled,
textface: setTextFace,
rtlistrue: (value) => {
document.body.classList.toggle('rtl', value);
document.body.classList.toggle('ltr', !value);
targetBody.classList.toggle('rtl', value);
targetBody.classList.toggle('ltr', !value);
document.documentElement.dir = value ? 'rtl' : 'ltr';
},
};
@ -894,11 +899,11 @@ function Ace2Inner(editorInfo, cssManagers) {
clearObservedChanges();
const getCleanNodeByKey = (key) => {
let n = document.getElementById(key);
let n = targetDoc.getElementById(key);
// copying and pasting can lead to duplicate ids
while (n && isNodeDirty(n)) {
n.id = '';
n = document.getElementById(key);
n = targetDoc.getElementById(key);
}
return n;
};
@ -980,11 +985,11 @@ function Ace2Inner(editorInfo, cssManagers) {
const observeSuspiciousNodes = () => {
// inspired by Firefox bug #473255, where pasting formatted text
// causes the cursor to jump away, making the new HTML never found.
if (document.body.getElementsByTagName) {
const elts = document.body.getElementsByTagName('style');
if (targetBody.getElementsByTagName) {
const elts = targetBody.getElementsByTagName('style');
for (const elt of elts) {
const n = topLevel(elt);
if (n && n.parentNode === document.body) {
if (n && n.parentNode === targetBody) {
observeChangesAroundNode(n);
}
}
@ -999,8 +1004,8 @@ function Ace2Inner(editorInfo, cssManagers) {
if (DEBUG && window.DONT_INCORP || window.DEBUG_DONT_INCORP) return false;
// returns true if dom changes were made
if (!document.body.firstChild) {
document.body.innerHTML = '<div><!-- --></div>';
if (!targetBody.firstChild) {
targetBody.innerHTML = '<div><!-- --></div>';
}
observeChangesAroundSelection();
@ -1022,7 +1027,7 @@ function Ace2Inner(editorInfo, cssManagers) {
j++;
}
if (!dirtyRangesCheckOut) {
for (const bodyNode of document.body.childNodes) {
for (const bodyNode of targetBody.childNodes) {
if ((bodyNode.tagName) && ((!bodyNode.id) || (!rep.lines.containsKey(bodyNode.id)))) {
observeChangesAroundNode(bodyNode);
}
@ -1044,11 +1049,11 @@ function Ace2Inner(editorInfo, cssManagers) {
const range = dirtyRanges[i];
a = range[0];
b = range[1];
let firstDirtyNode = (((a === 0) && document.body.firstChild) ||
let firstDirtyNode = (((a === 0) && targetBody.firstChild) ||
getCleanNodeByKey(rep.lines.atIndex(a - 1).key).nextSibling);
firstDirtyNode = (firstDirtyNode && isNodeDirty(firstDirtyNode) && firstDirtyNode);
let lastDirtyNode = (((b === rep.lines.length()) && document.body.lastChild) ||
let lastDirtyNode = (((b === rep.lines.length()) && targetBody.lastChild) ||
getCleanNodeByKey(rep.lines.atIndex(b).key).previousSibling);
lastDirtyNode = (lastDirtyNode && isNodeDirty(lastDirtyNode) && lastDirtyNode);
@ -1135,7 +1140,7 @@ function Ace2Inner(editorInfo, cssManagers) {
callstack: currentCallStack,
editorInfo,
rep,
root: document.body,
root: targetBody,
point: selection.startPoint,
documentAttributeManager,
});
@ -1147,7 +1152,7 @@ function Ace2Inner(editorInfo, cssManagers) {
callstack: currentCallStack,
editorInfo,
rep,
root: document.body,
root: targetBody,
point: selection.endPoint,
documentAttributeManager,
});
@ -1227,9 +1232,9 @@ function Ace2Inner(editorInfo, cssManagers) {
info.prepareForAdd();
entry.lineMarker = info.lineMarker;
if (!nodeToAddAfter) {
document.body.insertBefore(node, document.body.firstChild);
targetBody.insertBefore(node, targetBody.firstChild);
} else {
document.body.insertBefore(node, nodeToAddAfter.nextSibling);
targetBody.insertBefore(node, nodeToAddAfter.nextSibling);
}
nodeToAddAfter = node;
info.notifyAdded();
@ -1326,7 +1331,7 @@ function Ace2Inner(editorInfo, cssManagers) {
// Turn DOM node selection into [line,char] selection.
// This method has to work when the DOM is not pristine,
// assuming the point is not in a dirty node.
if (point.node === document.body) {
if (point.node === targetBody) {
if (point.index === 0) {
return [0, 0];
} else {
@ -1345,7 +1350,7 @@ function Ace2Inner(editorInfo, cssManagers) {
col = nodeText(n).length;
}
let parNode, prevSib;
while ((parNode = n.parentNode) !== document.body) {
while ((parNode = n.parentNode) !== targetBody) {
if ((prevSib = n.previousSibling)) {
n = prevSib;
col += nodeText(n).length;
@ -1398,7 +1403,7 @@ function Ace2Inner(editorInfo, cssManagers) {
insertDomLines(nodeToAddAfter, lineEntries.map((entry) => entry.domInfo));
for (const k of keysToDelete) {
const n = document.getElementById(k);
const n = targetDoc.getElementById(k);
n.parentNode.removeChild(n);
}
@ -2087,7 +2092,7 @@ function Ace2Inner(editorInfo, cssManagers) {
const a = cleanNodeForIndex(i - 1);
const b = cleanNodeForIndex(i);
if ((!a) || (!b)) return false; // violates precondition
if ((a === true) && (b === true)) return !document.body.firstChild;
if ((a === true) && (b === true)) return !targetBody.firstChild;
if ((a === true) && b.previousSibling) return false;
if ((b === true) && a.nextSibling) return false;
if ((a === true) || (b === true)) return true;
@ -2232,7 +2237,7 @@ function Ace2Inner(editorInfo, cssManagers) {
};
const isNodeDirty = (n) => {
if (n.parentNode !== document.body) return true;
if (n.parentNode !== targetBody) return true;
const data = getAssoc(n, 'dirtiness');
if (!data) return true;
if (n.id !== data.nodeId) return true;
@ -2856,7 +2861,7 @@ function Ace2Inner(editorInfo, cssManagers) {
updateBrowserSelectionFromRep();
// get the current caret selection, can't use rep. here because that only gives
// us the start position not the current
const myselection = document.getSelection();
const myselection = targetDoc.getSelection();
// get the carets selection offset in px IE 214
let caretOffsetTop = myselection.focusNode.parentNode.offsetTop ||
myselection.focusNode.offsetTop;
@ -2970,13 +2975,13 @@ function Ace2Inner(editorInfo, cssManagers) {
// with background doesn't seem to show up...
if (isNodeText(p.node) && p.index === p.maxIndex) {
let n = p.node;
while (!n.nextSibling && n !== document.body && n.parentNode !== document.body) {
while (!n.nextSibling && n !== targetBody && n.parentNode !== targetBody) {
n = n.parentNode;
}
if (n.nextSibling &&
!(typeof n.nextSibling.tagName === 'string' &&
n.nextSibling.tagName.toLowerCase() === 'br') &&
n !== p.node && n !== document.body && n.parentNode !== document.body) {
n !== p.node && n !== targetBody && n.parentNode !== targetBody) {
// found a parent, go to next node and dive in
p.node = n.nextSibling;
p.maxIndex = nodeMaxIndex(p.node);
@ -3003,7 +3008,7 @@ function Ace2Inner(editorInfo, cssManagers) {
};
}
};
const browserSelection = window.getSelection();
const browserSelection = targetDoc.getSelection();
if (browserSelection) {
browserSelection.removeAllRanges();
if (selection) {
@ -3078,7 +3083,7 @@ function Ace2Inner(editorInfo, cssManagers) {
// each of which has node (a magicdom node), index, and maxIndex. If the node
// is a text node, maxIndex is the length of the text; else maxIndex is 1.
// index is between 0 and maxIndex, inclusive.
const browserSelection = window.getSelection();
const browserSelection = targetDoc.getSelection();
if (!browserSelection || browserSelection.type === 'None' ||
browserSelection.rangeCount === 0) {
return null;
@ -3096,7 +3101,7 @@ function Ace2Inner(editorInfo, cssManagers) {
if (!isInBody(container)) {
// command-click in Firefox selects whole document, HEAD and BODY!
return {
node: document.body,
node: targetBody,
index: 0,
maxIndex: 1,
};
@ -3146,7 +3151,7 @@ function Ace2Inner(editorInfo, cssManagers) {
browserSelection.anchorOffset === range.endOffset,
};
if (selection.startPoint.node.ownerDocument !== window.document) {
if (selection.startPoint.node.ownerDocument !== targetDoc) {
return null;
}
@ -3181,17 +3186,17 @@ function Ace2Inner(editorInfo, cssManagers) {
editorInfo.ace_getInInternationalComposition = () => inInternationalComposition;
const bindTheEventHandlers = () => {
$(document).on('keydown', handleKeyEvent);
$(document).on('keypress', handleKeyEvent);
$(document).on('keyup', handleKeyEvent);
$(document).on('click', handleClick);
$(targetDoc).on('keydown', handleKeyEvent);
$(targetDoc).on('keypress', handleKeyEvent);
$(targetDoc).on('keyup', handleKeyEvent);
$(targetDoc).on('click', handleClick);
// dropdowns on edit bar need to be closed on clicks on both pad inner and pad outer
$(outerDoc).on('click', hideEditBarDropdowns);
// If non-nullish, pasting on a link should be suppressed.
let suppressPasteOnLink = null;
$(document.body).on('auxclick', (e) => {
$(targetBody).on('auxclick', (e) => {
if (e.originalEvent.button === 1 && (e.target.a || e.target.localName === 'a')) {
// The user middle-clicked on a link. Usually users do this to open a link in a new tab, but
// in X11 (Linux) this will instead paste the contents of the primary selection at the mouse
@ -3213,7 +3218,7 @@ function Ace2Inner(editorInfo, cssManagers) {
}
});
$(document.body).on('paste', (e) => {
$(targetBody).on('paste', (e) => {
if (suppressPasteOnLink != null && (e.target.a || e.target.localName === 'a')) {
scheduler.clearTimeout(suppressPasteOnLink);
suppressPasteOnLink = null;
@ -3233,7 +3238,7 @@ function Ace2Inner(editorInfo, cssManagers) {
// We reference document here, this is because if we don't this will expose a bug
// in Google Chrome. This bug will cause the last character on the last line to
// not fire an event when dropped into..
$(document).on('drop', (e) => {
$(targetBody).on('drop', (e) => {
if (e.target.a || e.target.localName === 'a') {
e.preventDefault();
}
@ -3251,7 +3256,7 @@ function Ace2Inner(editorInfo, cssManagers) {
const lineAfterSelection = lastLineSelected.nextSibling;
const neighbor = lineBeforeSelection || lineAfterSelection;
neighbor.appendChild(document.createElement('style'));
neighbor.appendChild(targetDoc.createElement('style'));
}
// Call drop hook
@ -3263,10 +3268,10 @@ function Ace2Inner(editorInfo, cssManagers) {
});
});
$(document.documentElement).on('compositionstart', () => {
$(targetDoc.documentElement).on('compositionstart', () => {
if (inInternationalComposition) return;
inInternationalComposition = new Promise((resolve) => {
$(document.documentElement).one('compositionend', () => {
$(targetDoc.documentElement).one('compositionend', () => {
inInternationalComposition = null;
resolve();
});
@ -3275,8 +3280,8 @@ function Ace2Inner(editorInfo, cssManagers) {
};
const topLevel = (n) => {
if ((!n) || n === document.body) return null;
while (n.parentNode !== document.body) {
if ((!n) || n === targetBody) return null;
while (n.parentNode !== targetBody) {
n = n.parentNode;
}
return n;
@ -3436,10 +3441,10 @@ function Ace2Inner(editorInfo, cssManagers) {
// but as it's non-text type the line-height/margins might not be present and it
// could be that this breaks a theme that has a different default line height..
// So instead of using an integer here we get the value from the Editor CSS.
const innerdocbodyStyles = getComputedStyle(document.body);
const innerdocbodyStyles = getComputedStyle(targetBody);
const defaultLineHeight = parseInt(innerdocbodyStyles['line-height']);
for (const docLine of document.body.children) {
for (const docLine of targetBody.children) {
let h;
const nextDocLine = docLine.nextElementSibling;
if (nextDocLine) {
@ -3450,7 +3455,7 @@ function Ace2Inner(editorInfo, cssManagers) {
// included on the first line. The default stylesheet doesn't add
// extra margins/padding, but plugins might.
h = nextDocLine.offsetTop - parseInt(
window.getComputedStyle(document.body)
window.getComputedStyle(targetBody)
.getPropertyValue('padding-top').split('px')[0]);
} else {
h = nextDocLine.offsetTop - docLine.offsetTop;
@ -3496,15 +3501,15 @@ function Ace2Inner(editorInfo, cssManagers) {
this.init = async () => {
await $.ready;
inCallStack('setup', () => {
if (browser.firefox) $(document.body).addClass('mozilla');
if (browser.safari) $(document.body).addClass('safari');
document.body.classList.toggle('authorColors', true);
document.body.classList.toggle('doesWrap', doesWrap);
if (browser.firefox) $(targetBody).addClass('mozilla');
if (browser.safari) $(targetBody).addClass('safari');
targetBody.classList.toggle('authorColors', true);
targetBody.classList.toggle('doesWrap', doesWrap);
enforceEditability();
// set up dom and rep
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
while (targetBody.firstChild) targetBody.removeChild(targetBody.firstChild);
const oneEntry = createDomLineEntry('');
doRepLineSplice(0, rep.lines.length(), [oneEntry]);
insertDomLines(null, [oneEntry.domInfo]);

View file

@ -32,6 +32,9 @@ const colorutils = require('./colorutils').colorutils;
const _ = require('./underscore');
const hooks = require('./pluginfw/hooks');
import html10n from './vendors/html10n';
// These parameters were global, now they are injected. A reference to the
// Timeslider controller would probably be more appropriate.
const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider) => {

View file

@ -26,6 +26,7 @@
const _ = require('./underscore');
const padmodals = require('./pad_modals').padmodals;
const colorutils = require('./colorutils').colorutils;
import html10n from './vendors/html10n';
const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
let BroadcastSlider;

View file

@ -3,8 +3,11 @@
// One rep.line(div) can be broken in more than one line in the browser.
// This function is useful to get the caret position of the line as
// is represented by the browser
exports.getPosition = () => {
import {Position, RepModel, RepNode} from "./types/RepModel";
export const getPosition = () => {
const range = getSelectionRange();
// @ts-ignore
if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody') return null;
// When there's a <br> or any element that has no height, we can't get the dimension of the
// element where the caret is. As we can't get the element height, we create a text node to get
@ -18,7 +21,7 @@ exports.getPosition = () => {
return line;
};
const createSelectionRange = (range) => {
const createSelectionRange = (range: Range) => {
const clonedRange = range.cloneRange();
// we set the selection start and end to avoid error when user selects a text bigger than
@ -30,14 +33,14 @@ const createSelectionRange = (range) => {
return clonedRange;
};
const getPositionOfRepLineAtOffset = (node, offset) => {
const getPositionOfRepLineAtOffset = (node: any, offset: number) => {
// it is not a text node, so we cannot make a selection
if (node.tagName === 'BR' || node.tagName === 'EMPTY') {
return getPositionOfElementOrSelection(node);
}
while (node.length === 0 && node.nextSibling) {
node = node.nextSibling;
node = node.nextSibling as any;
}
const newRange = new Range();
@ -48,14 +51,13 @@ const getPositionOfRepLineAtOffset = (node, offset) => {
return linePosition;
};
const getPositionOfElementOrSelection = (element) => {
const getPositionOfElementOrSelection = (element: Range):Position => {
const rect = element.getBoundingClientRect();
const linePosition = {
return {
bottom: rect.bottom,
height: rect.height,
top: rect.top,
};
return linePosition;
} satisfies Position;
};
// here we have two possibilities:
@ -64,7 +66,7 @@ const getPositionOfElementOrSelection = (element) => {
// where is the top of the previous line
// [2] the line before is part of another rep line. It's possible this line has different margins
// height. So we have to get the exactly position of the line
exports.getPositionTopOfPreviousBrowserLine = (caretLinePosition, rep) => {
export const getPositionTopOfPreviousBrowserLine = (caretLinePosition: Position, rep: RepModel) => {
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
const isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep);
@ -80,7 +82,7 @@ exports.getPositionTopOfPreviousBrowserLine = (caretLinePosition, rep) => {
return previousLineTop;
};
const caretLineIsFirstBrowserLine = (caretLineTop, rep) => {
const caretLineIsFirstBrowserLine = (caretLineTop: number, rep: RepModel) => {
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const firstRootNode = getFirstRootChildNode(lineNode);
@ -91,7 +93,7 @@ const caretLineIsFirstBrowserLine = (caretLineTop, rep) => {
};
// find the first root node, usually it is a text node
const getFirstRootChildNode = (node) => {
const getFirstRootChildNode = (node: RepNode) => {
if (!node.firstChild) {
return node;
} else {
@ -99,7 +101,7 @@ const getFirstRootChildNode = (node) => {
}
};
const getDimensionOfLastBrowserLineOfRepLine = (line, rep) => {
const getDimensionOfLastBrowserLineOfRepLine = (line: number, rep: RepModel) => {
const lineNode = rep.lines.atIndex(line).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);
@ -109,7 +111,7 @@ const getDimensionOfLastBrowserLineOfRepLine = (line, rep) => {
return lastRootChildNodePosition;
};
const getLastRootChildNode = (node) => {
const getLastRootChildNode = (node: RepNode) => {
if (!node.lastChild) {
return {
node,
@ -125,7 +127,7 @@ const getLastRootChildNode = (node) => {
// So, we can use the caret line to calculate the bottom of the line.
// [2] the next line is part of another rep line.
// It's possible this line has different dimensions, so we have to get the exactly dimension of it
exports.getBottomOfNextBrowserLine = (caretLinePosition, rep) => {
export const getBottomOfNextBrowserLine = (caretLinePosition: Position, rep: RepModel) => {
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
const isCaretLineLastBrowserLine =
caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep);
@ -142,7 +144,7 @@ exports.getBottomOfNextBrowserLine = (caretLinePosition, rep) => {
return nextLineBottom;
};
const caretLineIsLastBrowserLineOfRepLine = (caretLineTop, rep) => {
const caretLineIsLastBrowserLineOfRepLine = (caretLineTop: number, rep: RepModel) => {
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);
@ -153,7 +155,7 @@ const caretLineIsLastBrowserLineOfRepLine = (caretLineTop, rep) => {
return lastRootChildNodePosition.top === caretLineTop;
};
const getPreviousVisibleLine = (line, rep) => {
export const getPreviousVisibleLine = (line: number, rep: RepModel): number => {
const firstLineOfPad = 0;
if (line <= firstLineOfPad) {
return firstLineOfPad;
@ -165,9 +167,8 @@ const getPreviousVisibleLine = (line, rep) => {
};
exports.getPreviousVisibleLine = getPreviousVisibleLine;
const getNextVisibleLine = (line, rep) => {
export const getNextVisibleLine = (line: number, rep: RepModel): number => {
const lastLineOfThePad = rep.lines.length() - 1;
if (line >= lastLineOfThePad) {
return lastLineOfThePad;
@ -177,11 +178,10 @@ const getNextVisibleLine = (line, rep) => {
return getNextVisibleLine(line + 1, rep);
}
};
exports.getNextVisibleLine = getNextVisibleLine;
const isLineVisible = (line, rep) => rep.lines.atIndex(line).lineNode.offsetHeight > 0;
const isLineVisible = (line: number, rep: RepModel) => rep.lines.atIndex(line).lineNode.offsetHeight > 0;
const getDimensionOfFirstBrowserLineOfRepLine = (line, rep) => {
const getDimensionOfFirstBrowserLineOfRepLine = (line: number, rep: RepModel) => {
const lineNode = rep.lines.atIndex(line).lineNode;
const firstRootChildNode = getFirstRootChildNode(lineNode);

View file

@ -21,10 +21,12 @@ const padcookie = require('./pad_cookie').padcookie;
const Tinycon = require('tinycon/tinycon');
const hooks = require('./pluginfw/hooks');
const padeditor = require('./pad_editor').padeditor;
import html10n from './vendors/html10n';
// Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463
const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
exports.chat = (() => {
let isStuck = false;
let userAndChat = false;

View file

@ -1,16 +0,0 @@
'use strict';
((document) => {
// Set language for l10n
let language = document.cookie.match(/language=((\w{2,3})(-\w+)?)/);
if (language) language = language[1];
html10n.bind('indexed', () => {
html10n.localize([language, navigator.language, navigator.userLanguage, 'en']);
});
html10n.bind('localized', () => {
document.documentElement.lang = html10n.getLanguage();
document.documentElement.dir = html10n.getDirection();
});
})(document);

18
src/static/js/l10n.ts Normal file
View file

@ -0,0 +1,18 @@
import html10n from '../js/vendors/html10n';
// Set language for l10n
let regexpLang: string | undefined;
let language = document.cookie.match(/language=((\w{2,3})(-\w+)?)/);
if (language) regexpLang = language[1];
html10n.mt.bind('indexed', () => {
console.log('Navigator language', navigator.language)
console.log('Localizing things', [regexpLang, navigator.language, 'en'])
html10n.localize([regexpLang, navigator.language, 'en']);
});
html10n.mt.bind('localized', () => {
document.documentElement.lang = html10n.getLanguage()!;
document.documentElement.dir = html10n.getDirection()!;
});

View file

@ -24,12 +24,15 @@
let socket;
// These jQuery things should create local references, but for now `require()`
// assigns to the global `$` and augments it with plugins.
require('./vendors/jquery');
require('./vendors/farbtastic');
require('./vendors/gritter');
import html10n from './vendors/html10n'
const Cookies = require('./pad_utils').Cookies;
const chat = require('./chat').chat;
const getCollabClient = require('./collab_client').getCollabClient;
@ -136,7 +139,8 @@ const getParameters = [
name: 'lang',
checkVal: null,
callback: (val) => {
window.html10n.localize([val, 'en']);
console.log('Val is', val)
html10n.localize([val, 'en']);
Cookies.set('language', val);
},
},
@ -281,6 +285,7 @@ const handshake = async () => {
}
});
socket.on('error', (error) => {
// pad.collabClient might be null if the error occurred before the hanshake completed.
if (pad.collabClient != null) {
@ -313,6 +318,15 @@ const handshake = async () => {
() => $.ajax('../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {});
setInterval(ping, window.clientVars.sessionRefreshInterval);
}
if(window.clientVars.mode === "development") {
console.warn('Enabling development mode with live update')
socket.on('liveupdate', ()=>{
console.log('Live reload update received')
location.reload()
})
}
} else if (obj.disconnect) {
padconnectionstatus.disconnected(obj.disconnect);
socket.disconnect();
@ -713,7 +727,7 @@ const pad = {
$.ajax(
{
type: 'post',
url: 'ep/pad/connection-diagnostic-info',
url: '../ep/pad/connection-diagnostic-info',
data: {
diagnosticInfo: JSON.stringify(pad.diagnosticInfo),
},

View file

@ -1,4 +1,5 @@
'use strict';
import html10n from './vendors/html10n';
exports.showCountDownTimerToReconnectOnModal = ($modal, pad) => {
if (clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) {

View file

@ -24,9 +24,10 @@
const Cookies = require('./pad_utils').Cookies;
const padcookie = require('./pad_cookie').padcookie;
const padutils = require('./pad_utils').padutils;
const Ace2Editor = require('./ace').Ace2Editor;
import html10n from '../js/vendors/html10n'
const padeditor = (() => {
let Ace2Editor = undefined;
let pad = undefined;
let settings = undefined;
@ -35,7 +36,6 @@ const padeditor = (() => {
// this is accessed directly from other files
viewZoom: 100,
init: async (initialViewOptions, _pad) => {
Ace2Editor = require('./ace').Ace2Editor;
pad = _pad;
settings = pad.settings;
self.ace = new Ace2Editor();
@ -99,7 +99,7 @@ const padeditor = (() => {
$('#languagemenu').val(html10n.getLanguage());
$('#languagemenu').on('change', () => {
Cookies.set('language', $('#languagemenu').val());
window.html10n.localize([$('#languagemenu').val(), 'en']);
html10n.localize([$('#languagemenu').val(), 'en']);
if ($('select').niceSelect) {
$('select').niceSelect('update');
}

View file

@ -22,6 +22,9 @@
* limitations under the License.
*/
import html10n from './vendors/html10n';
const padimpexp = (() => {
let pad;

View file

@ -20,7 +20,7 @@ let pad;
exports.saveNow = () => {
pad.collabClient.sendMessage({type: 'SAVE_REVISION'});
$.gritter.add({
window.$.gritter.add({
// (string | mandatory) the heading of the notification
title: html10n.get('pad.savedrevs.marked'),
// (string | mandatory) the text inside the notification

View file

@ -18,7 +18,7 @@
const padutils = require('./pad_utils').padutils;
const hooks = require('./pluginfw/hooks');
import html10n from './vendors/html10n';
let myUserInfo = {};
let colorPickerOpen = false;

View file

@ -356,7 +356,6 @@ const padutils = {
let globalExceptionHandler = null;
padutils.setupGlobalExceptionHandler = () => {
if (globalExceptionHandler == null) {
require('./vendors/gritter');
globalExceptionHandler = (e) => {
let type;
let err;
@ -443,7 +442,7 @@ const inThirdPartyIframe = () => {
// This file is included from Node so that it can reuse randomString, but Node doesn't have a global
// window object.
if (typeof window !== 'undefined') {
exports.Cookies = require('js-cookie/dist/js.cookie').withAttributes({
exports.Cookies = require('js-cookie').withAttributes({
// Use `SameSite=Lax`, unless Etherpad is embedded in an iframe from another site in which case
// use `SameSite=None`. For iframes from another site, only `None` has a chance of working
// because the cookies are third-party (not same-site). Many browsers/users block third-party

View file

@ -7,24 +7,13 @@ exports.baseURL = '';
exports.ensure = (cb) => !defs.loaded ? exports.update(cb) : cb();
exports.update = (cb) => {
// It appears that this response (see #620) may interrupt the current thread
// of execution on Firefox. This schedules the response in the run-loop,
// which appears to fix the issue.
const callback = () => setTimeout(cb, 0);
jQuery.getJSON(
`${exports.baseURL}pluginfw/plugin-definitions.json?v=${clientVars.randomVersionString}`
).done((data) => {
defs.plugins = data.plugins;
defs.parts = data.parts;
defs.hooks = pluginUtils.extractHooks(defs.parts, 'client_hooks');
defs.loaded = true;
callback();
}).fail((err) => {
console.error(`Failed to load plugin-definitions: ${err}`);
callback();
});
exports.update = async (modules) => {
const data = await jQuery.getJSON(
`${exports.baseURL}pluginfw/plugin-definitions.json?v=${clientVars.randomVersionString}`);
defs.plugins = data.plugins;
defs.parts = data.parts;
defs.hooks = pluginUtils.extractHooks(defs.parts, 'client_hooks', null, modules);
defs.loaded = true;
};
const adoptPluginsFromAncestorsOf = (frame) => {

View file

@ -9,7 +9,7 @@ const disabledHookReasons = {
},
};
const loadFn = (path, hookName) => {
const loadFn = (path, hookName, modules) => {
let functionName;
const parts = path.split(':');
@ -24,7 +24,13 @@ const loadFn = (path, hookName) => {
functionName = parts[1];
}
let fn = require(path);
let fn
if (modules === undefined || !("get" in modules)) {
fn = require(/* webpackIgnore: true */ path);
} else {
fn = modules.get(path);
}
functionName = functionName ? functionName : hookName;
for (const name of functionName.split('.')) {
@ -33,7 +39,7 @@ const loadFn = (path, hookName) => {
return fn;
};
const extractHooks = (parts, hookSetName, normalizer) => {
const extractHooks = (parts, hookSetName, normalizer, modules) => {
const hooks = {};
for (const part of parts) {
for (const [hookName, regHookFnName] of Object.entries(part[hookSetName] || {})) {
@ -53,7 +59,7 @@ const extractHooks = (parts, hookSetName, normalizer) => {
}
let hookFn;
try {
hookFn = loadFn(hookFnName, hookName);
hookFn = loadFn(hookFnName, hookName, modules);
if (!hookFn) throw new Error('Not a function');
} catch (err) {
console.error(`Failed to load hook function "${hookFnName}" for plugin "${part.plugin}" ` +

View file

@ -1,351 +0,0 @@
'use strict';
/*
This file handles scroll on edition or when user presses arrow keys.
In this file we have two representations of line (browser and rep line).
Rep Line = a line in the way is represented by Etherpad(rep) (each <div> is a line)
Browser Line = each vertical line. A <div> can be break into more than one
browser line.
*/
const caretPosition = require('./caretPosition');
function Scroll(outerWin) {
// scroll settings
this.scrollSettings = parent.parent.clientVars.scrollWhenFocusLineIsOutOfViewport;
// DOM reference
this.outerWin = outerWin;
this.doc = this.outerWin.document;
this.rootDocument = parent.parent.document;
}
Scroll.prototype.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary =
function (rep, isScrollableEvent, innerHeight) {
// are we placing the caret on the line at the bottom of viewport?
// And if so, do we need to scroll the editor, as defined on the settings.json?
const shouldScrollWhenCaretIsAtBottomOfViewport =
this.scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport;
if (shouldScrollWhenCaretIsAtBottomOfViewport) {
// avoid scrolling when selection includes multiple lines --
// user can potentially be selecting more lines
// than it fits on viewport
const multipleLinesSelected = rep.selStart[0] !== rep.selEnd[0];
// avoid scrolling when pad loads
if (isScrollableEvent && !multipleLinesSelected && this._isCaretAtTheBottomOfViewport(rep)) {
// when scrollWhenFocusLineIsOutOfViewport.percentage is 0, pixelsToScroll is 0
const pixelsToScroll = this._getPixelsRelativeToPercentageOfViewport(innerHeight);
this._scrollYPage(pixelsToScroll);
}
}
};
Scroll.prototype.scrollWhenPressArrowKeys = function (arrowUp, rep, innerHeight) {
// if percentageScrollArrowUp is 0, let the scroll to be handled as default, put the previous
// rep line on the top of the viewport
if (this._arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep)) {
const pixelsToScroll = this._getPixelsToScrollWhenUserPressesArrowUp(innerHeight);
// by default, the browser scrolls to the middle of the viewport. To avoid the twist made
// when we apply a second scroll, we made it immediately (without animation)
this._scrollYPageWithoutAnimation(-pixelsToScroll);
} else {
this.scrollNodeVerticallyIntoView(rep, innerHeight);
}
};
// Some plugins might set a minimum height to the editor (ex: ep_page_view), so checking
// if (caretLine() === rep.lines.length() - 1) is not enough. We need to check if there are
// other lines after caretLine(), and all of them are out of viewport.
Scroll.prototype._isCaretAtTheBottomOfViewport = function (rep) {
// computing a line position using getBoundingClientRect() is expensive.
// (obs: getBoundingClientRect() is called on caretPosition.getPosition())
// To avoid that, we only call this function when it is possible that the
// caret is in the bottom of viewport
const caretLine = rep.selStart[0];
const lineAfterCaretLine = caretLine + 1;
const firstLineVisibleAfterCaretLine = caretPosition.getNextVisibleLine(lineAfterCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineAfterCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {
// check if the caret is in the bottom of the viewport
const caretLinePosition = caretPosition.getPosition();
const viewportBottom = this._getViewPortTopBottom().bottom;
const nextLineBottom = caretPosition.getBottomOfNextBrowserLine(caretLinePosition, rep);
const nextLineIsBelowViewportBottom = nextLineBottom > viewportBottom;
return nextLineIsBelowViewportBottom;
}
return false;
};
Scroll.prototype._isLinePartiallyVisibleOnViewport = function (lineNumber, rep) {
const lineNode = rep.lines.atIndex(lineNumber);
const linePosition = this._getLineEntryTopBottom(lineNode);
const lineTop = linePosition.top;
const lineBottom = linePosition.bottom;
const viewport = this._getViewPortTopBottom();
const viewportBottom = viewport.bottom;
const viewportTop = viewport.top;
const topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom;
const bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom;
const topOfLineIsBelowViewportTop = lineTop >= viewportTop;
const topOfLineIsAboveViewportBottom = lineTop <= viewportBottom;
const bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom;
const bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop;
return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) ||
(topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) ||
(bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop);
};
Scroll.prototype._getViewPortTopBottom = function () {
const theTop = this.getScrollY();
const doc = this.doc;
const height = doc.documentElement.clientHeight; // includes padding
// we have to get the exactly height of the viewport.
// So it has to subtract all the values which changes
// the viewport height (E.g. padding, position top)
const viewportExtraSpacesAndPosition =
this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable();
return {
top: theTop,
bottom: (theTop + height - viewportExtraSpacesAndPosition),
};
};
Scroll.prototype._getEditorPositionTop = function () {
const editor = parent.document.getElementsByTagName('iframe');
const editorPositionTop = editor[0].offsetTop;
return editorPositionTop;
};
// ep_page_view adds padding-top, which makes the viewport smaller
Scroll.prototype._getPaddingTopAddedWhenPageViewIsEnable = function () {
const aceOuter = this.rootDocument.getElementsByName('ace_outer');
const aceOuterPaddingTop = parseInt($(aceOuter).css('padding-top'));
return aceOuterPaddingTop;
};
Scroll.prototype._getScrollXY = function () {
const win = this.outerWin;
const odoc = this.doc;
if (typeof (win.pageYOffset) === 'number') {
return {
x: win.pageXOffset,
y: win.pageYOffset,
};
}
const docel = odoc.documentElement;
if (docel && typeof (docel.scrollTop) === 'number') {
return {
x: docel.scrollLeft,
y: docel.scrollTop,
};
}
};
Scroll.prototype.getScrollX = function () {
return this._getScrollXY().x;
};
Scroll.prototype.getScrollY = function () {
return this._getScrollXY().y;
};
Scroll.prototype.setScrollX = function (x) {
this.outerWin.scrollTo(x, this.getScrollY());
};
Scroll.prototype.setScrollY = function (y) {
this.outerWin.scrollTo(this.getScrollX(), y);
};
Scroll.prototype.setScrollXY = function (x, y) {
this.outerWin.scrollTo(x, y);
};
Scroll.prototype._isCaretAtTheTopOfViewport = function (rep) {
const caretLine = rep.selStart[0];
const linePrevCaretLine = caretLine - 1;
const firstLineVisibleBeforeCaretLine =
caretPosition.getPreviousVisibleLine(linePrevCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineBeforeCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
const caretLinePosition = caretPosition.getPosition(); // get the position of the browser line
const viewportPosition = this._getViewPortTopBottom();
const viewportTop = viewportPosition.top;
const viewportBottom = viewportPosition.bottom;
const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop;
const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom;
const caretLineIsInsideOfViewport =
caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
if (caretLineIsInsideOfViewport) {
const prevLineTop = caretPosition.getPositionTopOfPreviousBrowserLine(caretLinePosition, rep);
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
return previousLineIsAboveViewportTop;
}
}
return false;
};
// By default, when user makes an edition in a line out of viewport, this line goes
// to the edge of viewport. This function gets the extra pixels necessary to get the
// caret line in a position X relative to Y% viewport.
Scroll.prototype._getPixelsRelativeToPercentageOfViewport =
function (innerHeight, aboveOfViewport) {
let pixels = 0;
const scrollPercentageRelativeToViewport = this._getPercentageToScroll(aboveOfViewport);
if (scrollPercentageRelativeToViewport > 0 && scrollPercentageRelativeToViewport <= 1) {
pixels = parseInt(innerHeight * scrollPercentageRelativeToViewport);
}
return pixels;
};
// we use different percentages when change selection. It depends on if it is
// either above the top or below the bottom of the page
Scroll.prototype._getPercentageToScroll = function (aboveOfViewport) {
let percentageToScroll = this.scrollSettings.percentage.editionBelowViewport;
if (aboveOfViewport) {
percentageToScroll = this.scrollSettings.percentage.editionAboveViewport;
}
return percentageToScroll;
};
Scroll.prototype._getPixelsToScrollWhenUserPressesArrowUp = function (innerHeight) {
let pixels = 0;
const percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
if (percentageToScrollUp > 0 && percentageToScrollUp <= 1) {
pixels = parseInt(innerHeight * percentageToScrollUp);
}
return pixels;
};
Scroll.prototype._scrollYPage = function (pixelsToScroll) {
const durationOfAnimationToShowFocusline = this.scrollSettings.duration;
if (durationOfAnimationToShowFocusline) {
this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline);
} else {
this._scrollYPageWithoutAnimation(pixelsToScroll);
}
};
Scroll.prototype._scrollYPageWithoutAnimation = function (pixelsToScroll) {
this.outerWin.scrollBy(0, pixelsToScroll);
};
Scroll.prototype._scrollYPageWithAnimation =
function (pixelsToScroll, durationOfAnimationToShowFocusline) {
const outerDocBody = this.doc.getElementById('outerdocbody');
// it works on later versions of Chrome
const $outerDocBody = $(outerDocBody);
this._triggerScrollWithAnimation(
$outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline);
// it works on Firefox and earlier versions of Chrome
const $outerDocBodyParent = $outerDocBody.parent();
this._triggerScrollWithAnimation(
$outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline);
};
// using a custom queue and clearing it, we avoid creating a queue of scroll animations.
// So if this function is called twice quickly, only the last one runs.
Scroll.prototype._triggerScrollWithAnimation =
function ($elem, pixelsToScroll, durationOfAnimationToShowFocusline) {
// clear the queue of animation
$elem.stop('scrollanimation');
$elem.animate({
scrollTop: `+=${pixelsToScroll}`,
}, {
duration: durationOfAnimationToShowFocusline,
queue: 'scrollanimation',
}).dequeue('scrollanimation');
};
// scrollAmountWhenFocusLineIsOutOfViewport is set to 0 (default), scroll it the minimum distance
// needed to be completely in view. If the value is greater than 0 and less than or equal to 1,
// besides of scrolling the minimum needed to be visible, it scrolls additionally
// (viewport height * scrollAmountWhenFocusLineIsOutOfViewport) pixels
Scroll.prototype.scrollNodeVerticallyIntoView = function (rep, innerHeight) {
const viewport = this._getViewPortTopBottom();
// when the selection changes outside of the viewport the browser automatically scrolls the line
// to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now
// So, when the line scrolled gets outside of the viewport we let the browser handle it.
const linePosition = caretPosition.getPosition();
if (linePosition) {
const distanceOfTopOfViewport = linePosition.top - viewport.top;
const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height;
const caretIsAboveOfViewport = distanceOfTopOfViewport < 0;
const caretIsBelowOfViewport = distanceOfBottomOfViewport < 0;
if (caretIsAboveOfViewport) {
const pixelsToScroll =
distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true);
this._scrollYPage(pixelsToScroll);
} else if (caretIsBelowOfViewport) {
// setTimeout is required here as line might not be fully rendered onto the pad
setTimeout(() => {
const outer = window.parent;
// scroll to the very end of the pad outer
outer.scrollTo(0, outer[0].innerHeight);
}, 150);
// if the above setTimeout and functionality is removed then hitting an enter
// key while on the last line wont be an optimal user experience
// Details at: https://github.com/ether/etherpad-lite/pull/4639/files
}
}
};
Scroll.prototype._partOfRepLineIsOutOfViewport = function (viewportPosition, rep) {
const focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]);
const line = rep.lines.atIndex(focusLine);
const linePosition = this._getLineEntryTopBottom(line);
const lineIsAboveOfViewport = linePosition.top < viewportPosition.top;
const lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom;
return lineIsBelowOfViewport || lineIsAboveOfViewport;
};
Scroll.prototype._getLineEntryTopBottom = function (entry, destObj) {
const dom = entry.lineNode;
const top = dom.offsetTop;
const height = dom.offsetHeight;
const obj = (destObj || {});
obj.top = top;
obj.bottom = (top + height);
return obj;
};
Scroll.prototype._arrowUpWasPressedInTheFirstLineOfTheViewport = function (arrowUp, rep) {
const percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep);
};
Scroll.prototype.getVisibleLineRange = function (rep) {
const viewport = this._getViewPortTopBottom();
// console.log("viewport top/bottom: %o", viewport);
const obj = {};
const self = this;
const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).bottom > viewport.top);
// return the first line that the top position is greater or equal than
// the viewport. That is the first line that is below the viewport bottom.
// So the line that is in the bottom of the viewport is the very previous one.
let end = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top >= viewport.bottom);
if (end < start) end = start; // unlikely
// top.console.log(start+","+(end -1));
return [start, end - 1];
};
Scroll.prototype.getVisibleCharRange = function (rep) {
const lineRange = this.getVisibleLineRange(rep);
return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])];
};
exports.init = (outerWin) => new Scroll(outerWin);

338
src/static/js/scroll.ts Normal file
View file

@ -0,0 +1,338 @@
import {getBottomOfNextBrowserLine, getNextVisibleLine, getPosition, getPositionTopOfPreviousBrowserLine, getPreviousVisibleLine} from './caretPosition';
import {Position, RepModel, RepNode, WindowElementWithScrolling} from "./types/RepModel";
class Scroll {
private readonly outerWin: HTMLIFrameElement;
private readonly doc: Document;
private rootDocument: Document;
private scrollSettings: any;
constructor(outerWin: HTMLIFrameElement) {
// @ts-ignore
this.scrollSettings = window.clientVars.scrollWhenFocusLineIsOutOfViewport;
// DOM reference
this.outerWin = outerWin;
this.doc = this.outerWin.contentDocument!;
this.rootDocument = parent.parent.document;
}
scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep: RepModel, isScrollableEvent: boolean, innerHeight: number) {
// are we placing the caret on the line at the bottom of viewport?
// And if so, do we need to scroll the editor, as defined on the settings.json?
const shouldScrollWhenCaretIsAtBottomOfViewport =
this.scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport;
if (shouldScrollWhenCaretIsAtBottomOfViewport) {
// avoid scrolling when selection includes multiple lines --
// user can potentially be selecting more lines
// than it fits on viewport
const multipleLinesSelected = rep.selStart[0] !== rep.selEnd[0];
// avoid scrolling when pad loads
if (isScrollableEvent && !multipleLinesSelected && this._isCaretAtTheBottomOfViewport(rep)) {
// when scrollWhenFocusLineIsOutOfViewport.percentage is 0, pixelsToScroll is 0
const pixelsToScroll = this._getPixelsRelativeToPercentageOfViewport(innerHeight);
this._scrollYPage(pixelsToScroll);
}
}
}
scrollWhenPressArrowKeys(arrowUp: boolean, rep: RepModel, innerHeight: number) {
// if percentageScrollArrowUp is 0, let the scroll to be handled as default, put the previous
// rep line on the top of the viewport
if (this._arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep)) {
const pixelsToScroll = this._getPixelsToScrollWhenUserPressesArrowUp(innerHeight);
// by default, the browser scrolls to the middle of the viewport. To avoid the twist made
// when we apply a second scroll, we made it immediately (without animation)
this._scrollYPageWithoutAnimation(-pixelsToScroll);
} else {
this.scrollNodeVerticallyIntoView(rep, innerHeight);
}
}
_isCaretAtTheBottomOfViewport(rep: RepModel) {
// computing a line position using getBoundingClientRect() is expensive.
// (obs: getBoundingClientRect() is called on caretPosition.getPosition())
// To avoid that, we only call this function when it is possible that the
// caret is in the bottom of viewport
const caretLine = rep.selStart[0];
const lineAfterCaretLine = caretLine + 1;
const firstLineVisibleAfterCaretLine = getNextVisibleLine(lineAfterCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineAfterCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {
// check if the caret is in the bottom of the viewport
const caretLinePosition = getPosition()!;
const viewportBottom = this._getViewPortTopBottom().bottom;
const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep);
return nextLineBottom > viewportBottom;
}
return false;
};
_isLinePartiallyVisibleOnViewport(lineNumber: number, rep: RepModel){
const lineNode = rep.lines.atIndex(lineNumber);
const linePosition = this._getLineEntryTopBottom(lineNode);
const lineTop = linePosition.top;
const lineBottom = linePosition.bottom;
const viewport = this._getViewPortTopBottom();
const viewportBottom = viewport.bottom;
const viewportTop = viewport.top;
const topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom;
const bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom;
const topOfLineIsBelowViewportTop = lineTop >= viewportTop;
const topOfLineIsAboveViewportBottom = lineTop <= viewportBottom;
const bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom;
const bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop;
return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) ||
(topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) ||
(bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop);
};
_getViewPortTopBottom() {
const theTop = this.getScrollY();
const doc = this.doc;
const height = doc.documentElement.clientHeight; // includes padding
// we have to get the exactly height of the viewport.
// So it has to subtract all the values which changes
// the viewport height (E.g. padding, position top)
const viewportExtraSpacesAndPosition =
this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable();
return {
top: theTop,
bottom: (theTop + height - viewportExtraSpacesAndPosition),
};
};
_getEditorPositionTop() {
const editor = parent.document.getElementsByTagName('iframe');
const editorPositionTop = editor[0].offsetTop;
return editorPositionTop;
};
_getPaddingTopAddedWhenPageViewIsEnable() {
const aceOuter = this.rootDocument.getElementsByName('ace_outer');
const aceOuterPaddingTop = parseInt($(aceOuter).css('padding-top'));
return aceOuterPaddingTop;
};
_getScrollXY() {
const win = this.outerWin as WindowElementWithScrolling;
const odoc = this.doc;
if (typeof (win.pageYOffset) === 'number') {
return {
x: win.pageXOffset,
y: win.pageYOffset,
};
}
const docel = odoc.documentElement;
if (docel && typeof (docel.scrollTop) === 'number') {
return {
x: docel.scrollLeft,
y: docel.scrollTop,
};
}
};
getScrollX() {
return this._getScrollXY()!.x;
};
getScrollY () {
return this._getScrollXY()!.y;
};
setScrollX(x: number) {
this.outerWin.scrollTo(x, this.getScrollY());
};
setScrollY(y: number) {
this.outerWin.scrollTo(this.getScrollX(), y);
};
setScrollXY(x: number, y: number) {
this.outerWin.scrollTo(x, y);
};
_isCaretAtTheTopOfViewport(rep: RepModel) {
const caretLine = rep.selStart[0];
const linePrevCaretLine = caretLine - 1;
const firstLineVisibleBeforeCaretLine =
getPreviousVisibleLine(linePrevCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineBeforeCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
const caretLinePosition = getPosition(); // get the position of the browser line
const viewportPosition = this._getViewPortTopBottom();
const viewportTop = viewportPosition.top;
const viewportBottom = viewportPosition.bottom;
const caretLineIsBelowViewportTop = caretLinePosition!.bottom >= viewportTop;
const caretLineIsAboveViewportBottom = caretLinePosition!.top < viewportBottom;
const caretLineIsInsideOfViewport =
caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
if (caretLineIsInsideOfViewport) {
const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition!, rep);
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
return previousLineIsAboveViewportTop;
}
}
return false;
};
// By default, when user makes an edition in a line out of viewport, this line goes
// to the edge of viewport. This function gets the extra pixels necessary to get the
// caret line in a position X relative to Y% viewport.
_getPixelsRelativeToPercentageOfViewport(innerHeight: number, aboveOfViewport?: boolean) {
let pixels = 0;
const scrollPercentageRelativeToViewport = this._getPercentageToScroll(aboveOfViewport);
if (scrollPercentageRelativeToViewport > 0 && scrollPercentageRelativeToViewport <= 1) {
pixels = parseInt(String(innerHeight * scrollPercentageRelativeToViewport));
}
return pixels;
};
// we use different percentages when change selection. It depends on if it is
// either above the top or below the bottom of the page
_getPercentageToScroll(aboveOfViewport: boolean|undefined) {
let percentageToScroll = this.scrollSettings.percentage.editionBelowViewport;
if (aboveOfViewport) {
percentageToScroll = this.scrollSettings.percentage.editionAboveViewport;
}
return percentageToScroll;
};
_getPixelsToScrollWhenUserPressesArrowUp(innerHeight: number) {
let pixels = 0;
const percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
if (percentageToScrollUp > 0 && percentageToScrollUp <= 1) {
pixels = parseInt(String(innerHeight * percentageToScrollUp));
}
return pixels;
};
_scrollYPage(pixelsToScroll: number) {
const durationOfAnimationToShowFocusline = this.scrollSettings.duration;
if (durationOfAnimationToShowFocusline) {
this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline);
} else {
this._scrollYPageWithoutAnimation(pixelsToScroll);
}
};
_scrollYPageWithoutAnimation(pixelsToScroll: number) {
this.outerWin.scrollBy(0, pixelsToScroll);
};
_scrollYPageWithAnimation(pixelsToScroll: number, durationOfAnimationToShowFocusline: number) {
const outerDocBody = this.doc.getElementById('outerdocbody');
// it works on later versions of Chrome
const $outerDocBody = $(outerDocBody!);
this._triggerScrollWithAnimation(
$outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline);
// it works on Firefox and earlier versions of Chrome
const $outerDocBodyParent = $outerDocBody.parent();
this._triggerScrollWithAnimation(
$outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline);
};
_triggerScrollWithAnimation($elem:any, pixelsToScroll: number, durationOfAnimationToShowFocusline: number) {
// clear the queue of animation
$elem.stop('scrollanimation');
$elem.animate({
scrollTop: `+=${pixelsToScroll}`,
}, {
duration: durationOfAnimationToShowFocusline,
queue: 'scrollanimation',
}).dequeue('scrollanimation');
};
scrollNodeVerticallyIntoView(rep: RepModel, innerHeight: number) {
const viewport = this._getViewPortTopBottom();
// when the selection changes outside of the viewport the browser automatically scrolls the line
// to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now
// So, when the line scrolled gets outside of the viewport we let the browser handle it.
const linePosition = getPosition();
if (linePosition) {
const distanceOfTopOfViewport = linePosition.top - viewport.top;
const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height;
const caretIsAboveOfViewport = distanceOfTopOfViewport < 0;
const caretIsBelowOfViewport = distanceOfBottomOfViewport < 0;
if (caretIsAboveOfViewport) {
const pixelsToScroll =
distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true);
this._scrollYPage(pixelsToScroll);
} else if (caretIsBelowOfViewport) {
// setTimeout is required here as line might not be fully rendered onto the pad
setTimeout(() => {
const outer = window.parent;
// scroll to the very end of the pad outer
outer.scrollTo(0, outer[0].innerHeight);
}, 150);
// if the above setTimeout and functionality is removed then hitting an enter
// key while on the last line wont be an optimal user experience
// Details at: https://github.com/ether/etherpad-lite/pull/4639/files
}
}
};
_partOfRepLineIsOutOfViewport(viewportPosition: Position, rep: RepModel) {
const focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]);
const line = rep.lines.atIndex(focusLine);
const linePosition = this._getLineEntryTopBottom(line);
const lineIsAboveOfViewport = linePosition.top < viewportPosition.top;
const lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom;
return lineIsBelowOfViewport || lineIsAboveOfViewport;
};
_getLineEntryTopBottom(entry: RepNode, destObj?: Position) {
const dom = entry.lineNode;
const top = dom.offsetTop;
const height = dom.offsetHeight;
const obj = (destObj || {}) as Position;
obj.top = top;
obj.bottom = (top + height);
return obj;
};
_arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp: boolean, rep: RepModel) {
const percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep);
};
getVisibleLineRange(rep: RepModel) {
const viewport = this._getViewPortTopBottom();
// console.log("viewport top/bottom: %o", viewport);
const obj = {} as Position;
const self = this;
const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).bottom > viewport.top);
// return the first line that the top position is greater or equal than
// the viewport. That is the first line that is below the viewport bottom.
// So the line that is in the bottom of the viewport is the very previous one.
let end = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top >= viewport.bottom);
if (end < start) end = start; // unlikely
// top.console.log(start+","+(end -1));
return [start, end - 1];
};
getVisibleCharRange(rep: RepModel) {
const lineRange = this.getVisibleLineRange(rep);
return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])];
};
}
export default Scroll

View file

@ -1,4 +1,4 @@
'use strict';
import io from 'socket.io-client';
/**
* Creates a socket.io connection.

View file

@ -31,7 +31,7 @@ const randomString = require('./pad_utils').randomString;
const hooks = require('./pluginfw/hooks');
const padutils = require('./pad_utils').padutils;
const socketio = require('./socketio');
import html10n from '../js/vendors/html10n'
let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider;
const init = () => {
@ -117,6 +117,14 @@ const handleClientVars = (message) => {
setInterval(ping, window.clientVars.sessionRefreshInterval);
}
if(window.clientVars.mode === "development") {
console.warn('Enabling development mode with live update')
socket.on('liveupdate', ()=>{
console.log('Doing live reload')
location.reload()
})
}
// load all script that doesn't work without the clientVars
BroadcastSlider = require('./broadcast_slider')
.loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded);

View file

@ -0,0 +1,31 @@
export type RepModel = {
lines: {
atIndex: (num: number)=>RepNode,
offsetOfIndex: (range: number)=>number,
search: (filter: (e: RepNode)=>boolean)=>number,
length: ()=>number
}
selStart: number[],
selEnd: number[],
selFocusAtStart: boolean
}
export type Position = {
bottom: number,
height: number,
top: number
}
export type RepNode = {
firstChild: RepNode,
lineNode: RepNode
length: number,
lastChild: RepNode,
offsetHeight: number,
offsetTop: number
}
export type WindowElementWithScrolling = HTMLIFrameElement & {
pageYOffset: number|string,
pageXOffset: number
}

View file

@ -7,6 +7,7 @@
// Licensed under the terms of the GNU General Public License v2.0:
// https://github.com/mattfarina/farbtastic/blob/71ca15f4a09c8e5a08a1b0d1cf37ef028adf22f0/LICENSE.txt
// edited by Sebastian Castro <sebastian.castro@protonmail.com> on 2020-04-06
(function ($) {
var __debug = false;
@ -172,7 +173,7 @@ $._farbtastic = function (container, options) {
angle2 = d2 * Math.PI * 2,
// Endpoints
x1 = Math.sin(angle1), y1 = -Math.cos(angle1);
x2 = Math.sin(angle2), y2 = -Math.cos(angle2),
let x2 = Math.sin(angle2), y2 = -Math.cos(angle2),
// Midpoint chosen so that the endpoints are tangent to the circle.
am = (angle1 + angle2) / 2,
tan = 1 / Math.cos((angle2 - angle1) / 2),
@ -329,8 +330,8 @@ $._farbtastic = function (container, options) {
// Update the overlay canvas.
fb.ctxOverlay.clearRect(-fb.mid, -fb.mid, sz, sz);
for (i in circles) {
var c = circles[i];
for (let i in circles) {
const c = circles[i];
fb.ctxOverlay.lineWidth = c.lw;
fb.ctxOverlay.strokeStyle = c.c;
fb.ctxOverlay.beginPath();

View file

@ -42,8 +42,8 @@
return Gritter.add(params || {});
} catch(e) {
var err = 'Gritter Error: ' + e;
(typeof(console) != 'undefined' && console.error) ?
const err = 'Gritter Error: ' + e;
(typeof(console) != 'undefined' && console.error) ?
console.error(err, params) :
alert(err);
@ -289,7 +289,7 @@
*/
_runSetup: function(){
for(opt in $.gritter.options){
for(let opt in $.gritter.options){
this[opt] = $.gritter.options[opt];
}
this._is_setup = 1;

File diff suppressed because it is too large Load diff

993
src/static/js/vendors/html10n.ts vendored Normal file
View file

@ -0,0 +1,993 @@
import {Func} from "mocha";
type PluralFunc = (n: number) => string
export class Html10n {
public language?: string
private rtl: string[]
private _pluralRules?: PluralFunc
public mt: MicroEvent
private loader: Loader | undefined
public translations: Map<string, any>
private macros: Map<string, Function>
constructor() {
this.language = undefined
this.rtl = ["ar","dv","fa","ha","he","ks","ku","ps","ur","yi"]
this.mt = new MicroEvent()
this.translations = new Map()
this.macros = new Map()
this.macros.set('plural', (_key: string, param:string, opts: any)=>{
let str
, n = parseFloat(param);
if (isNaN(n))
return;
// initialize _pluralRules
if (this._pluralRules === undefined) {
this._pluralRules = this.getPluralRules(this.language!);
}
let index = this._pluralRules!(n);
// try to find a [zero|one|two] key if it's defined
if (n === 0 && ('zero') in opts) {
str = opts['zero'];
} else if (n == 1 && ('one') in opts) {
str = opts['one'];
} else if (n == 2 && ('two') in opts) {
str = opts['two'];
} else if (index in opts) {
str = opts[index];
}
return str;
})
document.addEventListener('DOMContentLoaded', ()=> {
this.index()
}, false)
}
bind(event: string, fct: Func) {
this.mt.bind(event, fct)
}
/**
* Get rules for plural forms (shared with JetPack), see:
* http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html
* https://github.com/mozilla/addon-sdk/blob/master/python-lib/plural-rules-generator.p
*
* @param {string} lang
* locale (language) used.
*
* @return {PluralFunc}
* returns a function that gives the plural form name for a given integer:
* var fun = getPluralRules('en');
* fun(1) -> 'one'
* fun(0) -> 'other'
* fun(1000) -> 'other'.
*/
getPluralRules(lang: string): PluralFunc {
const locales2rules = new Map([
['af', 3],
['ak', 4],
['am', 4],
['ar', 1],
['asa', 3],
['az', 0],
['be', 11],
['bem', 3],
['bez', 3],
['bg', 3],
['bh', 4],
['bm', 0],
['bn', 3],
['bo', 0],
['br', 20],
['brx', 3],
['bs', 11],
['ca', 3],
['cgg', 3],
['chr', 3],
['cs', 12],
['cy', 17],
['da', 3],
['de', 3],
['dv', 3],
['dz', 0],
['ee', 3],
['el', 3],
['en', 3],
['eo', 3],
['es', 3],
['et', 3],
['eu', 3],
['fa', 0],
['ff', 5],
['fi', 3],
['fil', 4],
['fo', 3],
['fr', 5],
['fur', 3],
['fy', 3],
['ga', 8],
['gd', 24],
['gl', 3],
['gsw', 3],
['gu', 3],
['guw', 4],
['gv', 23],
['ha', 3],
['haw', 3],
['he', 2],
['hi', 4],
['hr', 11],
['hu', 0],
['id', 0],
['ig', 0],
['ii', 0],
['is', 3],
['it', 3],
['iu', 7],
['ja', 0],
['jmc', 3],
['jv', 0],
['ka', 0],
['kab', 5],
['kaj', 3],
['kcg', 3],
['kde', 0],
['kea', 0],
['kk', 3],
['kl', 3],
['km', 0],
['kn', 0],
['ko', 0],
['ksb', 3],
['ksh', 21],
['ku', 3],
['kw', 7],
['lag', 18],
['lb', 3],
['lg', 3],
['ln', 4],
['lo', 0],
['lt', 10],
['lv', 6],
['mas', 3],
['mg', 4],
['mk', 16],
['ml', 3],
['mn', 3],
['mo', 9],
['mr', 3],
['ms', 0],
['mt', 15],
['my', 0],
['nah', 3],
['naq', 7],
['nb', 3],
['nd', 3],
['ne', 3],
['nl', 3],
['nn', 3],
['no', 3],
['nr', 3],
['nso', 4],
['ny', 3],
['nyn', 3],
['om', 3],
['or', 3],
['pa', 3],
['pap', 3],
['pl', 13],
['ps', 3],
['pt', 3],
['rm', 3],
['ro', 9],
['rof', 3],
['ru', 11],
['rwk', 3],
['sah', 0],
['saq', 3],
['se', 7],
['seh', 3],
['ses', 0],
['sg', 0],
['sh', 11],
['shi', 19],
['sk', 12],
['sl', 14],
['sma', 7],
['smi', 7],
['smj', 7],
['smn', 7],
['sms', 7],
['sn', 3],
['so', 3],
['sq', 3],
['sr', 11],
['ss', 3],
['ssy', 3],
['st', 3],
['sv', 3],
['sw', 3],
['syr', 3],
['ta', 3],
['te', 3],
['teo', 3],
['th', 0],
['ti', 4],
['tig', 3],
['tk', 3],
['tl', 4],
['tn', 3],
['to', 0],
['tr', 0],
['ts', 3],
['tzm', 22],
['uk', 11],
['ur', 3],
['ve', 3],
['vi', 0],
['vun', 3],
['wa', 4],
['wae', 3],
['wo', 0],
['xh', 3],
['xog', 3],
['yo', 0],
['zh', 0],
['zu', 3]
])
function isIn(n: number, list: number[]) {
return list.indexOf(n) !== -1;
}
function isBetween(n: number, start: number, end: number) {
return start <= n && n <= end;
}
type PluralFunc = (n: number) => string
const pluralRules: {
[key: string]: PluralFunc
} = {
'0': function() {
return 'other';
},
'1': function(n: number) {
if ((isBetween((n % 100), 3, 10)))
return 'few';
if (n === 0)
return 'zero';
if ((isBetween((n % 100), 11, 99)))
return 'many';
if (n == 2)
return 'two';
if (n == 1)
return 'one';
return 'other';
},
'2': function(n: number) {
if (n !== 0 && (n % 10) === 0)
return 'many';
if (n == 2)
return 'two';
if (n == 1)
return 'one';
return 'other';
},
'3': function(n: number) {
if (n == 1)
return 'one';
return 'other';
},
'4': function(n: number) {
if ((isBetween(n, 0, 1)))
return 'one';
return 'other';
},
'5': function(n: number) {
if ((isBetween(n, 0, 2)) && n != 2)
return 'one';
return 'other';
},
'6': function(n: number) {
if (n === 0)
return 'zero';
if ((n % 10) == 1 && (n % 100) != 11)
return 'one';
return 'other';
},
'7': function(n: number) {
if (n == 2)
return 'two';
if (n == 1)
return 'one';
return 'other';
},
'8': function(n: number) {
if ((isBetween(n, 3, 6)))
return 'few';
if ((isBetween(n, 7, 10)))
return 'many';
if (n == 2)
return 'two';
if (n == 1)
return 'one';
return 'other';
},
'9': function(n: number) {
if (n === 0 || n != 1 && (isBetween((n % 100), 1, 19)))
return 'few';
if (n == 1)
return 'one';
return 'other';
},
'10': function(n: number) {
if ((isBetween((n % 10), 2, 9)) && !(isBetween((n % 100), 11, 19)))
return 'few';
if ((n % 10) == 1 && !(isBetween((n % 100), 11, 19)))
return 'one';
return 'other';
},
'11': function(n: number) {
if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14)))
return 'few';
if ((n % 10) === 0 ||
(isBetween((n % 10), 5, 9)) ||
(isBetween((n % 100), 11, 14)))
return 'many';
if ((n % 10) == 1 && (n % 100) != 11)
return 'one';
return 'other';
},
'12': function(n: number) {
if ((isBetween(n, 2, 4)))
return 'few';
if (n == 1)
return 'one';
return 'other';
},
'13': function(n: number) {
if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14)))
return 'few';
if (n != 1 && (isBetween((n % 10), 0, 1)) ||
(isBetween((n % 10), 5, 9)) ||
(isBetween((n % 100), 12, 14)))
return 'many';
if (n == 1)
return 'one';
return 'other';
},
'14': function(n: number) {
if ((isBetween((n % 100), 3, 4)))
return 'few';
if ((n % 100) == 2)
return 'two';
if ((n % 100) == 1)
return 'one';
return 'other';
},
'15': function(n: number) {
if (n === 0 || (isBetween((n % 100), 2, 10)))
return 'few';
if ((isBetween((n % 100), 11, 19)))
return 'many';
if (n == 1)
return 'one';
return 'other';
},
'16': function(n: number) {
if ((n % 10) == 1 && n != 11)
return 'one';
return 'other';
},
'17': function(n: number) {
if (n == 3)
return 'few';
if (n === 0)
return 'zero';
if (n == 6)
return 'many';
if (n == 2)
return 'two';
if (n == 1)
return 'one';
return 'other';
},
'18': function(n: number) {
if (n === 0)
return 'zero';
if ((isBetween(n, 0, 2)) && n !== 0 && n != 2)
return 'one';
return 'other';
},
'19': function(n: number) {
if ((isBetween(n, 2, 10)))
return 'few';
if ((isBetween(n, 0, 1)))
return 'one';
return 'other';
},
'20': function(n: number) {
if ((isBetween((n % 10), 3, 4) || ((n % 10) == 9)) && !(
isBetween((n % 100), 10, 19) ||
isBetween((n % 100), 70, 79) ||
isBetween((n % 100), 90, 99)
))
return 'few';
if ((n % 1000000) === 0 && n !== 0)
return 'many';
if ((n % 10) == 2 && !isIn((n % 100), [12, 72, 92]))
return 'two';
if ((n % 10) == 1 && !isIn((n % 100), [11, 71, 91]))
return 'one';
return 'other';
},
'21': function(n: number) {
if (n === 0)
return 'zero';
if (n == 1)
return 'one';
return 'other';
},
'22': function(n: number) {
if ((isBetween(n, 0, 1)) || (isBetween(n, 11, 99)))
return 'one';
return 'other';
},
'23': function(n: number) {
if ((isBetween((n % 10), 1, 2)) || (n % 20) === 0)
return 'one';
return 'other';
},
'24': function(n: number) {
if ((isBetween(n, 3, 10) || isBetween(n, 13, 19)))
return 'few';
if (isIn(n, [2, 12]))
return 'two';
if (isIn(n, [1, 11]))
return 'one';
return 'other';
}
};
const index = locales2rules.get(lang.replace(/-.*$/, ''));
// @ts-ignore
if (!(index in pluralRules)) {
console.warn('plural form unknown for [' + lang + ']');
return function() { return 'other'; };
}
// @ts-ignore
return pluralRules[index];
}
getTranslatableChildren(element: HTMLElement) {
return element.querySelectorAll('*[data-l10n-id]')
}
localize(langs: (string|undefined)[]|string) {
console.log('Available langs ', langs)
if ('string' === typeof langs) {
langs = [langs];
}
let i = 0
langs.forEach((lang) => {
if(!lang) return;
langs[i++] = lang;
if(~lang.indexOf('-')) langs[i++] = lang.substring(0, lang.indexOf('-'));
})
this.build(langs, (er: null, translations: Map<string, any>) =>{
this.translations = translations
this.translateElement(translations)
this.mt.trigger('localized')
})
}
/**
* Triggers the translation process
* for an element
* @param translations A hash of all translation strings
* @param element A DOM element, if omitted, the document element will be used
*/
translateElement(translations: Map<string, any>, element?: HTMLElement) {
element = element || document.documentElement
const children = element ? this.getTranslatableChildren(element): document.childNodes
for (let child of children) {
this.translateNode(translations, child as HTMLElement)
}
// translate element itself if necessary
this.translateNode(translations, element)
}
asyncForEach(list: (string|undefined)[], iterator: any, cb: Function) {
let i = 0
, n = list.length
iterator(list[i], i, function each(err?: string) {
if(err) console.error(err)
i++
if (i < n) return iterator(list[i],i, each);
cb()
})
}
/**
* Builds a translation object from a list of langs (loads the necessary translations)
* @param langs Array - a list of langs sorted by priority (default langs should go last)
* @param cb Function - a callback that will be called once all langs have been loaded
*/
build(langs: (string|undefined)[], cb: Function) {
const build = new Map<string, any>()
this.asyncForEach(langs, (lang: string, _i: number, next:LoaderFunc)=> {
if(!lang) return next();
this.loader!.load(lang, next)
}, () =>{
let lang;
langs.reverse()
// loop through the priority array...
for (let i=0, n=langs.length; i < n; i++) {
lang = langs[i]
if(!lang) continue;
if(!(lang in langs)) {// uh, we don't have this lang availbable..
// then check for related langs
if(~lang.indexOf('-') != -1) {
lang = lang.split('-')[0];
}
let l: string|undefined = ''
for(l of langs) {
if(l && lang != l && l.indexOf(lang) === 0) {
lang = l
break;
}
}
// @ts-ignore
if(lang != l) continue;
}
// ... and apply all strings of the current lang in the list
// to our build object
//lang = "de"
if (this.loader!.langs.has(lang)) {
for (let string in this.loader!.langs.get(lang)) {
build.set(string,this.loader!.langs.get(lang)[string])
}
this.language = lang
} else {
const loaderLang = lang.split('-')[0]
for (let string in this.loader!.langs.get(loaderLang)) {
build.set(string,this.loader!.langs.get(loaderLang)[string])
}
this.language = loaderLang
}
// the last applied lang will be exposed as the
// lang the page was translated to
}
cb(null, build)
})
}
/**
* Returns the language that was last applied to the translations hash
* thus overriding most of the formerly applied langs
*/
getLanguage() {
return this.language
}
/**
* Returns the direction of the language returned be html10n#getLanguage
*/
getDirection() {
if(!this.language) return
const langCode = this.language.indexOf('-') == -1? this.language : this.language.substring(0, this.language.indexOf('-'))
return this.rtl.indexOf(langCode) == -1? 'ltr' : 'rtl'
}
/**
* Index all <link>s
*/
index() {
// Find all <link>s
const links = document.getElementsByTagName('link')
, resources = []
for (let i=0, n=links.length; i < n; i++) {
if (links[i].type != 'application/l10n+json')
continue;
resources.push(links[i].href)
}
this.loader = new Loader(resources)
this.mt.trigger('indexed')
}
translateNode(translations: Map<string, any>, node: HTMLElement) {
const str: {
id?: string,
args?: any,
str?: string
} = {}
// get id
str.id = node.getAttribute('data-l10n-id') as string
if (!str.id) return
if(!translations.get(str.id)) return console.warn('Couldn\'t find translation key '+str.id)
// get args
if(window.JSON) {
str.args = JSON.parse(node.getAttribute('data-l10n-args') as string)
}else{
try{
//str.args = eval(node.getAttribute('data-l10n-args') as string)
console.error("Old eval method invoked!!")
}catch(e) {
console.warn('Couldn\'t parse args for '+str.id)
}
}
str.str = this.get(str.id, str.args)
// get attribute name to apply str to
let prop
, index = str.id.lastIndexOf('.')
, attrList = // allowed attributes
{ "title": 1
, "innerHTML": 1
, "alt": 1
, "textContent": 1
, "value": 1
, "placeholder": 1
}
if (index > 0 && str.id.substring(index + 1) in attrList) {
// an attribute has been specified (example: "my_translation_key.placeholder")
prop = str.id.substring(index + 1)
} else { // no attribute: assuming text content by default
prop = document.body.textContent ? 'textContent' : 'innerText'
}
// Apply translation
if (node.children.length === 0 || prop != 'textContent') {
// @ts-ignore
node[prop] = str.str!
node.setAttribute("aria-label", str.str!); // Sets the aria-label
// The idea of the above is that we always have an aria value
// This might be a bit of an abrupt solution but let's see how it goes
} else {
let children = node.childNodes,
found = false
let i = 0, n = children.length;
for (; i < n; i++) {
if (children[i].nodeType === 3 && /\S/.test(children[i].textContent!)) {
if (!found) {
children[i].nodeValue = str.str!
found = true
} else {
children[i].nodeValue = ''
}
}
}
if (!found) {
console.warn('Unexpected error: could not translate element content for key '+str.id, node)
}
}
}
get(id: string, args?:any) {
let translations = this.translations
if(!translations) return console.warn('No translations available (yet)')
if(!translations.get(id)) return console.warn('Could not find string '+id)
// apply macros
let str = translations.get(id)
str = this.substMacros(id, str, args)
// apply args
str = this.substArguments(str, args)
return str
}
substMacros(key: string, str:string, args:any) {
let regex = /\{\[\s*([a-zA-Z]+)\(([a-zA-Z]+)\)((\s*([a-zA-Z]+)\: ?([ a-zA-Z{}]+),?)+)*\s*\]\}/ //.exec('{[ plural(n) other: are {{n}}, one: is ]}')
, match
while(match = regex.exec(str)) {
// a macro has been found
// Note: at the moment, only one parameter is supported
let macroName = match[1]
, paramName = match[2]
, optv = match[3]
, opts: {[key:string]:any} = {}
if (!(this.macros.has(macroName))) continue
if(optv) {
optv.match(/(?=\s*)([a-zA-Z]+)\: ?([ a-zA-Z{}]+)(?=,?)/g)!.forEach(function(arg) {
const parts = arg.split(':')
, name = parts[0];
opts[name] = parts[1].trim()
})
}
let param
if (args && paramName in args) {
param = args[paramName]
} else if (paramName in this.translations) {
param = this.translations.get(paramName)
}
// there's no macro parser: it has to be defined in html10n.macros
let macro = this.macros.get(macroName)!
str = str.substring(0, match.index) + macro(key, param, opts) + str.substring(match.index+match[0].length)
}
return str
}
substArguments(str: string, args:any) {
let reArgs = /\{\{\s*([a-zA-Z\.]+)\s*\}\}/
, match
let translations = this.translations;
while (match = reArgs.exec(str)) {
if (!match || match.length < 2)
return str // argument key not found
let arg = match[1]
, sub = ''
if (args && arg in args) {
sub = args[arg]
} else if (translations && arg in translations) {
sub = translations.get(arg)
} else {
console.warn('Could not find argument {{' + arg + '}}')
return str
}
str = str.substring(0, match.index) + sub + str.substring(match.index + match[0].length)
}
return str
}
}
class MicroEvent {
private events: Map<string, Function[]>
constructor() {
this.events = new Map();
}
bind(event: string, fct: Func) {
if (this.events.get(event) === undefined) {
this.events.set(event, []);
}
this.events.get(event)!.push(fct);
}
unbind(event: string, fct: Func) {
if (this.events.get(event) === undefined) {
return;
}
const index = this.events.get(event)!.indexOf(fct);
if (index !== -1) {
this.events.get(event)!.splice(index, 1);
}
}
trigger(event: string, ...args: any[]) {
if (this.events.get(event) === undefined) {
return;
}
for (const fct of this.events.get(event)!) {
fct(...args);
}
}
mixin(destObject: any) {
const props = ['bind', 'unbind', 'trigger'];
if (destObject !== undefined) {
for (const prop of props) {
// @ts-ignore
destObject[prop] = this[prop];
}
}
}
}
type LoaderFunc = () => void
type ErrorFunc = (data?:any)=>void
class Loader {
private resources: any
private cache: Map<string, any>
langs: Map<string, any>
constructor(resources: any) {
this.resources = resources;
this.cache = new Map();
this.langs = new Map();
}
load(lang: string, callback: LoaderFunc) {
if (this.langs.get(lang) !== undefined) {
callback();
return;
}
if (this.resources.length > 0) {
let reqs = 0
for (const resource of this.resources) {
this.fetch(resource, lang, (e)=> {
reqs++;
if (e) console.warn(e)
if (reqs < this.resources.length) return;// Call back once all reqs are completed
callback && callback()
})
}
}
}
fetch(href: string, lang: string, callback: ErrorFunc) {
if (this.cache.get(href)) {
this.parse(lang, href, this.cache.get(href), callback)
return;
}
const xhr = new XMLHttpRequest();
xhr.open('GET', href, /*async: */true)
if (xhr.overrideMimeType) {
xhr.overrideMimeType('application/json; charset=utf-8');
}
xhr.onreadystatechange = ()=> {
if (xhr.readyState == 4) {
if (xhr.status == 200 || xhr.status === 0) {
const data = JSON.parse(xhr.responseText);
this.cache.set(href, data)
// Pass on the contents for parsing
this.parse(lang, href, data, callback)
} else {
callback(new Error('Failed to load '+href))
}
}
};
xhr.send(null);
}
parse(lang: string, href: string, data: {
[key: string]: string
}, callback: ErrorFunc) {
if ('object' !== typeof data) {
callback(new Error('A file couldn\'t be parsed as json.'))
return
}
function getBcp47LangCode(browserLang: string) {
const bcp47Lang = browserLang.toLowerCase();
// Browser => BCP 47
const langCodeMap = new Map([
['zh-cn', 'zh-hans-cn'],
['zh-hk', 'zh-hant-hk'],
['zh-mo', 'zh-hant-mo'],
['zh-my', 'zh-hans-my'],
['zh-sg', 'zh-hans-sg'],
['zh-tw', 'zh-hant-tw'],
])
return langCodeMap.get(bcp47Lang) ?? bcp47Lang;
}
// Issue #6129: Fix exceptions
// NOTE: translatewiki.net use all lowercase form by default ('en-gb' insted of 'en-GB')
function getJsonLangCode(bcp47Lang: string) {
const jsonLang = bcp47Lang.toLowerCase();
// BCP 47 => JSON
const langCodeMap = new Map([
['sr-ec', 'sr-cyrl'],
['sr-el', 'sr-latn'],
['zh-hk', 'zh-hant-hk'],
])
return langCodeMap.get(jsonLang) ?? jsonLang;
}
let bcp47LangCode = getBcp47LangCode(lang);
let jsonLangCode = getJsonLangCode(bcp47LangCode);
if (!data[jsonLangCode]) {
// lang not found
// This may be due to formatting (expected 'ru' but browser sent 'ru-RU')
// Set err msg before mutating lang (we may need this later)
const msg = 'Couldn\'t find translations for ' + lang +
'(lowercase BCP 47 lang tag ' + bcp47LangCode +
', JSON lang code ' + jsonLangCode + ')';
// Check for '-' (BCP 47 'ROOT-SCRIPT-REGION-VARIANT') and fallback until found data or ROOT
// - 'ROOT-SCRIPT-REGION': 'zh-Hans-CN'
// - 'ROOT-SCRIPT': 'zh-Hans'
// - 'ROOT-REGION': 'en-GB'
// - 'ROOT-VARIANT': 'be-tarask'
while (!data[jsonLangCode] && bcp47LangCode.lastIndexOf('-') > -1) {
// ROOT-SCRIPT-REGION-VARIANT formatting detected
bcp47LangCode = bcp47LangCode.substring(0, bcp47LangCode.lastIndexOf('-')); // set lang to ROOT lang
jsonLangCode = getJsonLangCode(bcp47LangCode);
}
if (!data[jsonLangCode]) {
// ROOT lang not found. (e.g 'zh')
// Loop through langs data. Maybe we have a variant? e.g (zh-hans)
let l; // langs item. Declare outside of loop
for (l in data) {
// Is not ROOT?
// And is variant of ROOT?
// (NOTE: index of ROOT equals 0 would cause unexpected ISO 639-1 vs. 639-3 issues,
// so append dash into query string)
// And is known lang?
if (bcp47LangCode != l && l.indexOf(lang + '-') === 0 && data[l]) {
bcp47LangCode = l; // set lang to ROOT-SCRIPT (e.g 'zh-hans')
jsonLangCode = getJsonLangCode(bcp47LangCode);
break;
}
}
// Did we find a variant? If not, return err.
if (bcp47LangCode != l) {
return callback(new Error(msg));
}
}
}
lang = jsonLangCode
if('string' === typeof data[lang]) {
// Import rule
// absolute path
let importUrl = data[lang];
// relative path
if(data[lang].indexOf("http") != 0 && data[lang].indexOf("/") != 0) {
importUrl = href+"/../"+data[lang]
}
this.fetch(importUrl, lang, callback)
return
}
if ('object' != typeof data[lang]) {
callback(new Error('Translations should be specified as JSON objects!'))
return
}
this.langs.set(lang,data[lang])
// TODO: Also store accompanying langs
callback()
}
}
export default new Html10n()

View file

@ -110,10 +110,10 @@
$dropdown.find('.list').css('min-width', $dropdown.outerWidth() + 'px');
}
$listHeight = $dropdown.find('.list').outerHeight();
$top = $dropdown.parent().offset().top;
$bottom = $('body').height() - $top;
$maxListHeight = $bottom - $dropdown.outerHeight() - 20;
let $listHeight = $dropdown.find('.list').outerHeight();
let $top = $dropdown.parent().offset().top;
let $bottom = $('body').height() - $top;
let $maxListHeight = $bottom - $dropdown.outerHeight() - 20;
if ($maxListHeight < 200) {
$dropdown.addClass('reverse');
$maxListHeight = 250;