diff --git a/.github/workflows/frontend-admin-tests.yml b/.github/workflows/frontend-admin-tests.yml
index 68db8409e..f6fc8c9c2 100644
--- a/.github/workflows/frontend-admin-tests.yml
+++ b/.github/workflows/frontend-admin-tests.yml
@@ -68,7 +68,7 @@ jobs:
# rules.
-
name: Install all dependencies and symlink for ep_etherpad-lite
- run: bin/installDeps.sh
+ run: pnpm i
#-
# name: Install etherpad plugins
# run: rm -Rf node_modules/ep_align/static/tests/*
@@ -92,7 +92,6 @@ jobs:
- name: Build admin frontend
working-directory: admin
run: |
- pnpm install
pnpm run build
# name: Run the frontend admin tests
# shell: bash
@@ -124,7 +123,7 @@ jobs:
- name: Run the frontend admin tests
shell: bash
run: |
- pnpm run dev &
+ pnpm run prod &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml
index bc8c8f768..12df40fb5 100644
--- a/.github/workflows/frontend-tests.yml
+++ b/.github/workflows/frontend-tests.yml
@@ -59,7 +59,7 @@ jobs:
- name: Run the frontend tests
shell: bash
run: |
- pnpm run dev &
+ pnpm run prod &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
@@ -122,7 +122,7 @@ jobs:
- name: Run the frontend tests
shell: bash
run: |
- pnpm run dev &
+ pnpm run prod &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
@@ -192,7 +192,7 @@ jobs:
- name: Run the frontend tests
shell: bash
run: |
- pnpm run dev &
+ pnpm run prod &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
index 4d673dba0..d64a4b9b2 100644
--- a/.github/workflows/windows.yml
+++ b/.github/workflows/windows.yml
@@ -154,11 +154,12 @@ jobs:
name: Run Etherpad
working-directory: etherpad/src
run: |
- pnpm install cypress
- .\node_modules\.bin\cypress.cmd install --force
+ pnpm i
+ pnpm exec playwright install --with-deps
pnpm run prod &
curl --connect-timeout 10 --max-time 20 --retry 5 --retry-delay 10 --retry-max-time 60 --retry-connrefused http://127.0.0.1:9001/p/test
- pnpm exec cypress run --config-file ./tests/frontend/cypress/cypress.config.js
+ pnpm exec playwright install chromium --with-deps
+ pnpm run test-ui --project=chromium
# On release, upload windows zip to GitHub release tab
-
name: Rename to etherpad-lite-win.zip
diff --git a/bin/buildForWindows.sh b/bin/buildForWindows.sh
index 67f4eeae1..99a9fcf2f 100755
--- a/bin/buildForWindows.sh
+++ b/bin/buildForWindows.sh
@@ -50,21 +50,13 @@ rm -rf src/node_modules || true
#$(try cd ./bin/installDeps.sh)
# Install admin frontend
-cd admin
try pnpm install
-try pnpm run build
-cd ..
-
-
-
+try pnpm run build:etherpad
# Nuke the admin folder as it is not needed anymore :D
rm -rf admin
-
-export NODE_ENV=production
-try pnpm install --production
-
-
+rm -rf oidc
+rm -rf src/node_modules
log "copy the windows settings template..."
try cp settings.json.template settings.json
diff --git a/bin/plugins/lib/frontend-tests.yml b/bin/plugins/lib/frontend-tests.yml
index d1eaf8701..053ce180d 100644
--- a/bin/plugins/lib/frontend-tests.yml
+++ b/bin/plugins/lib/frontend-tests.yml
@@ -78,7 +78,7 @@ jobs:
- name: Run the frontend tests
shell: bash
run: |
- pnpm run dev &
+ pnpm run prod &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
diff --git a/bin/run.sh b/bin/run.sh
index c6c4c92c9..3f6b119bc 100755
--- a/bin/run.sh
+++ b/bin/run.sh
@@ -45,4 +45,4 @@ fi
log "Starting Etherpad..."
# cd src
-exec pnpm run dev "$@"
+exec pnpm run prod "$@"
diff --git a/package.json b/package.json
index 0953cb2d7..897830b17 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,8 @@
"plugins": "pnpm --filter bin run plugins",
"install-plugins": "pnpm --filter bin run plugins i",
"remove-plugins": "pnpm --filter bin run remove-plugins",
- "list-plugins": "pnpm --filter bin run list-plugins"
+ "list-plugins": "pnpm --filter bin run list-plugins",
+ "build:etherpad": "pnpm --filter admin run build-copy && pnpm --filter ui run build-copy"
},
"dependencies": {
"ep_etherpad-lite": "workspace:./src"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 13325a765..5f2324f6d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -152,6 +152,9 @@ importers:
cookie-parser:
specifier: ^1.4.6
version: 1.4.6
+ cross-env:
+ specifier: ^7.0.3
+ version: 7.0.3
cross-spawn:
specifier: ^7.0.3
version: 7.0.3
@@ -294,6 +297,9 @@ importers:
'@types/http-errors':
specifier: ^2.0.4
version: 2.0.4
+ '@types/jquery':
+ specifier: ^3.5.30
+ version: 3.5.30
'@types/jsdom':
specifier: ^21.1.7
version: 21.1.7
@@ -321,6 +327,9 @@ importers:
'@types/underscore':
specifier: ^1.11.15
version: 1.11.15
+ chokidar:
+ specifier: ^3.6.0
+ version: 3.6.0
eslint:
specifier: ^9.7.0
version: 9.7.0
@@ -360,6 +369,9 @@ importers:
ui:
devDependencies:
+ ep_etherpad-lite:
+ specifier: workspace:../src
+ version: link:../src
typescript:
specifier: ^5.5.3
version: 5.5.3
@@ -1489,6 +1501,9 @@ packages:
'@types/http-errors@2.0.4':
resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==}
+ '@types/jquery@3.5.30':
+ resolution: {integrity: sha512-nbWKkkyb919DOUxjmRVk8vwtDb0/k8FKncmUKFi+NY+QXqWltooxTrswvz4LspQwxvLdvzBN1TImr6cw3aQx2A==}
+
'@types/jsdom@21.1.7':
resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==}
@@ -1576,6 +1591,9 @@ packages:
'@types/sinonjs__fake-timers@8.1.5':
resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==}
+ '@types/sizzle@2.3.8':
+ resolution: {integrity: sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==}
+
'@types/superagent@8.1.7':
resolution: {integrity: sha512-NmIsd0Yj4DDhftfWvvAku482PZum4DBW7U51OvS8gvOkDDY0WT1jsVyDV3hK+vplrsYw8oDwi9QxOM7U68iwww==}
@@ -2080,6 +2098,11 @@ packages:
typescript:
optional: true
+ cross-env@7.0.3:
+ resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
+ engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
+ hasBin: true
+
cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@@ -5456,6 +5479,10 @@ snapshots:
'@types/http-errors@2.0.4': {}
+ '@types/jquery@3.5.30':
+ dependencies:
+ '@types/sizzle': 2.3.8
+
'@types/jsdom@21.1.7':
dependencies:
'@types/node': 20.14.11
@@ -5558,6 +5585,8 @@ snapshots:
'@types/sinonjs__fake-timers@8.1.5': {}
+ '@types/sizzle@2.3.8': {}
+
'@types/superagent@8.1.7':
dependencies:
'@types/cookiejar': 2.1.5
@@ -6137,6 +6166,10 @@ snapshots:
optionalDependencies:
typescript: 5.5.3
+ cross-env@7.0.3:
+ dependencies:
+ cross-spawn: 7.0.3
+
cross-spawn@7.0.3:
dependencies:
path-key: 3.1.1
diff --git a/src/ep.json b/src/ep.json
index a6d65a08f..c9b26c175 100644
--- a/src/ep.json
+++ b/src/ep.json
@@ -42,7 +42,8 @@
"name": "specialpages",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/specialpages",
- "expressPreSession": "ep_etherpad-lite/node/hooks/express/specialpages"
+ "expressPreSession": "ep_etherpad-lite/node/hooks/express/specialpages",
+ "socketio": "ep_etherpad-lite/node/hooks/express/specialpages"
}
},
{
diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts
index 2ed40391c..390949607 100644
--- a/src/node/handler/PadMessageHandler.ts
+++ b/src/node/handler/PadMessageHandler.ts
@@ -996,7 +996,8 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
percentageToScrollWhenUserPressesArrowUp:
settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp,
},
- initialChangesets: [], // FIXME: REMOVE THIS SHIT
+ initialChangesets: [], // FIXME: REMOVE THIS SHIT,
+ mode: process.env.NODE_ENV
};
// Add a username to the clientVars if one avaiable
diff --git a/src/node/hooks/express/specialpages.ts b/src/node/hooks/express/specialpages.ts
index 85a23479f..3677bbfa6 100644
--- a/src/node/hooks/express/specialpages.ts
+++ b/src/node/hooks/express/specialpages.ts
@@ -1,14 +1,23 @@
'use strict';
-const path = require('path');
-const eejs = require('../../eejs');
-const fs = require('fs');
+import path from 'node:path';
+const eejs = require('../../eejs')
+import fs from 'node:fs';
const fsp = fs.promises;
const toolbar = require('../../utils/toolbar');
const hooks = require('../../../static/js/pluginfw/hooks');
const settings = require('../../utils/Settings');
-const util = require('util');
+import util from 'node:util';
const webaccess = require('./webaccess');
+const plugins = require('../../../static/js/pluginfw/plugin_defs');
+
+import {build, buildSync} from 'esbuild'
+let ioI: { sockets: { sockets: any[]; }; } | null = null
+
+exports.socketio = (hookName: string, {io}: any) => {
+ ioI = io
+}
+
exports.expressPreSession = async (hookName:string, {app}:any) => {
// This endpoint is intended to conform to:
@@ -73,49 +82,263 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
});
};
-exports.expressCreateServer = (hookName:string, args:any, cb:Function) => {
+
+
+const convertTypescript = (content: string) => {
+ const outputRaw = buildSync({
+ stdin: {
+ contents: content,
+ resolveDir: path.join(settings.root, 'var','js'),
+ loader: 'js'
+ },
+ alias:{
+ "ep_etherpad-lite/static/js/browser": 'ep_etherpad-lite/static/js/vendors/browser',
+ "ep_etherpad-lite/static/js/nice-select": 'ep_etherpad-lite/static/js/vendors/nice-select'
+ },
+ bundle: true, // Bundle the files together
+ minify: process.env.NODE_ENV === "production", // Minify the output
+ sourcemap: !(process.env.NODE_ENV === "production"), // Generate source maps
+ sourceRoot: settings.root+"/src/static/js/",
+ target: ['es2020'], // Target ECMAScript version
+ metafile: true,
+ write: false, // Do not write to file system,
+ })
+ const output = outputRaw.outputFiles[0].text
+
+ return {
+ output,
+ hash: outputRaw.outputFiles[0].hash.replaceAll('/','2')
+ }
+}
+
+const handleLiveReload = async (args: any, padString: string, timeSliderString: string ) => {
+ const chokidar = await import('chokidar')
+ const watcher = chokidar.watch(path.join(settings.root, 'src', 'static', 'js'));
+ let routeHandlers: { [key: string]: Function } = {};
+
+ const setRouteHandler = (path: string, newHandler: Function) => {
+ routeHandlers[path] = newHandler;
+ };
+ args.app.use((req: any, res: any, next: Function) => {
+ if (req.path.startsWith('/p/') && req.path.split('/').length == 3) {
+ req.params = {
+ pad: req.path.split('/')[2]
+ }
+ routeHandlers['/p/:pad'](req, res);
+ } else if (req.path.startsWith('/p/') && req.path.split('/').length == 4) {
+ req.params = {
+ pad: req.path.split('/')[2]
+ }
+ routeHandlers['/p/:pad/timeslider'](req, res);
+ } else if (routeHandlers[req.path]) {
+ routeHandlers[req.path](req, res);
+ } else {
+ next();
+ }
+ });
+
+ function handleUpdate() {
+ convertTypescriptWatched(padString, (output, hash) => {
+ console.log("New pad hash is", hash)
+ setRouteHandler('/watch/pad', (req: any, res: any) => {
+ res.header('Content-Type', 'application/javascript');
+ res.send(output)
+ })
+
+ setRouteHandler("/p/:pad", (req: any, res: any, next: Function) => {
+ // The below might break for pads being rewritten
+ const isReadOnly = !webaccess.userCanModify(req.params.pad, req);
+
+ hooks.callAll('padInitToolbar', {
+ toolbar,
+ isReadOnly
+ });
+
+ // can be removed when require-kernel is dropped
+ res.header('Feature-Policy', 'sync-xhr \'self\'');
+ const content = eejs.require('ep_etherpad-lite/templates/pad.html', {
+ req,
+ toolbar,
+ isReadOnly,
+ entrypoint: '/watch/pad?hash=' + hash
+ })
+ res.send(content);
+ })
+ ioI!.sockets.sockets.forEach(socket => socket.emit('liveupdate'))
+ })
+ convertTypescriptWatched(timeSliderString, (output, hash) => {
+ // serve timeslider.html under /p/$padname/timeslider
+ console.log("New timeslider hash is", hash)
+
+ setRouteHandler('/watch/timeslider', (req: any, res: any) => {
+ res.header('Content-Type', 'application/javascript');
+ res.send(output)
+ })
+
+ setRouteHandler("/p/:pad/timeslider", (req: any, res: any, next: Function) => {
+ console.log("Reloading pad")
+ // The below might break for pads being rewritten
+ const isReadOnly = !webaccess.userCanModify(req.params.pad, req);
+
+ hooks.callAll('padInitToolbar', {
+ toolbar,
+ isReadOnly
+ });
+
+ // can be removed when require-kernel is dropped
+ res.header('Feature-Policy', 'sync-xhr \'self\'');
+ const content = eejs.require('ep_etherpad-lite/templates/timeslider.html', {
+ req,
+ toolbar,
+ isReadOnly,
+ entrypoint: '/watch/timeslider?hash=' + hash
+ })
+ res.send(content);
+ })
+ })
+ }
+
+ watcher.on('change', path => {
+ console.log(`File ${path} has been changed`);
+ handleUpdate();
+ });
+ handleUpdate()
+}
+
+const convertTypescriptWatched = (content: string, cb: (output:string, hash: string)=>void) => {
+ build({
+ stdin: {
+ contents: content,
+ resolveDir: path.join(settings.root, 'var','js'),
+ loader: 'js'
+ },
+ alias:{
+ "ep_etherpad-lite/static/js/browser": 'ep_etherpad-lite/static/js/vendors/browser',
+ "ep_etherpad-lite/static/js/nice-select": 'ep_etherpad-lite/static/js/vendors/nice-select'
+ },
+ bundle: true, // Bundle the files together
+ minify: process.env.NODE_ENV === "production", // Minify the output
+ sourcemap: !(process.env.NODE_ENV === "production"), // Generate source maps
+ sourceRoot: settings.root+"/src/static/js/",
+ target: ['es2020'], // Target ECMAScript version
+ metafile: true,
+ write: false, // Do not write to file system,
+ }).then((outputRaw) => {
+ cb(
+ outputRaw.outputFiles[0].text,
+ outputRaw.outputFiles[0].hash.replaceAll('/','2')
+ )
+ })
+}
+
+exports.expressCreateServer = async (hookName: string, args: any, cb: Function) => {
// serve index.html under /
- args.app.get('/', (req:any, res:any) => {
+ args.app.get('/', (req: any, res: any) => {
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req}));
});
- // serve pad.html under /p
- args.app.get('/p/:pad', (req:any, res:any, next:Function) => {
- // The below might break for pads being rewritten
- const isReadOnly = !webaccess.userCanModify(req.params.pad, req);
- hooks.callAll('padInitToolbar', {
- toolbar,
- isReadOnly,
+ const padString = eejs.require('ep_etherpad-lite/templates/padBootstrap.js', {
+ pluginModules: (() => {
+ const pluginModules = new Set();
+ for (const part of plugins.parts) {
+ for (const [, hookFnName] of Object.entries(part.client_hooks || {})) {
+ // @ts-ignore
+ pluginModules.add(hookFnName.split(':')[0]);
+ }
+ }
+ return [...pluginModules];
+ })(),
+ settings,
+ })
+
+ const timeSliderString = eejs.require('ep_etherpad-lite/templates/timeSliderBootstrap.js', {
+ pluginModules: (() => {
+ const pluginModules = new Set();
+ for (const part of plugins.parts) {
+ for (const [, hookFnName] of Object.entries(part.client_hooks || {})) {
+ // @ts-ignore
+ pluginModules.add(hookFnName.split(':')[0]);
+ }
+ }
+ return [...pluginModules];
+ })(),
+ settings,
+ })
+
+
+
+ const outdir = path.join(settings.root, 'var','js')
+
+ let fileNamePad: string
+ let fileNameTimeSlider: string
+ if(process.env.NODE_ENV === "production"){
+ const padSliderWrite = convertTypescript(padString)
+ const timeSliderWrite = convertTypescript(timeSliderString)
+
+ fileNamePad = `padbootstrap-${padSliderWrite.hash}.min.js`
+ fileNameTimeSlider = `timeSliderBootstrap-${timeSliderWrite.hash}.min.js`
+ const pathNamePad = path.join(outdir, fileNamePad)
+ const pathNameTimeSlider = path.join(outdir, fileNameTimeSlider)
+
+ if (!fs.existsSync(pathNamePad)) {
+ fs.writeFileSync(pathNamePad, padSliderWrite.output);
+ }
+
+ if (!fs.existsSync(pathNameTimeSlider)) {
+ fs.writeFileSync(pathNameTimeSlider,timeSliderWrite.output)
+ }
+
+ args.app.get("/"+fileNamePad, (req: any, res: any) => {
+ res.sendFile(pathNamePad)
+ })
+
+ args.app.get("/"+fileNameTimeSlider, (req: any, res: any) => {
+ res.sendFile(pathNameTimeSlider)
+ })
+
+
+ // serve pad.html under /p
+ args.app.get('/p/:pad', (req: any, res: any, next: Function) => {
+ // The below might break for pads being rewritten
+ const isReadOnly = !webaccess.userCanModify(req.params.pad, req);
+
+ hooks.callAll('padInitToolbar', {
+ toolbar,
+ isReadOnly
+ });
+
+ // can be removed when require-kernel is dropped
+ res.header('Feature-Policy', 'sync-xhr \'self\'');
+ const content = eejs.require('ep_etherpad-lite/templates/pad.html', {
+ req,
+ toolbar,
+ isReadOnly,
+ entrypoint: "/"+fileNamePad
+ })
+ res.send(content);
});
- // can be removed when require-kernel is dropped
- res.header('Feature-Policy', 'sync-xhr \'self\'');
- res.send(eejs.require('ep_etherpad-lite/templates/pad.html', {
- req,
- toolbar,
- isReadOnly,
- }));
- });
+ // serve timeslider.html under /p/$padname/timeslider
+ args.app.get('/p/:pad/timeslider', (req: any, res: any, next: Function) => {
+ hooks.callAll('padInitToolbar', {
+ toolbar,
+ });
- // serve timeslider.html under /p/$padname/timeslider
- args.app.get('/p/:pad/timeslider', (req:any, res:any, next:Function) => {
- hooks.callAll('padInitToolbar', {
- toolbar,
+ res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', {
+ req,
+ toolbar,
+ entrypoint: "/"+fileNameTimeSlider
+ }));
});
-
- res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', {
- req,
- toolbar,
- }));
- });
+ } else {
+ await handleLiveReload(args, padString, timeSliderString)
+ }
// The client occasionally polls this endpoint to get an updated expiration for the express_sid
// cookie. This handler must be installed after the express-session middleware.
- args.app.put('/_extendExpressSessionLifetime', (req:any, res:any) => {
+ args.app.put('/_extendExpressSessionLifetime', (req: any, res: any) => {
// express-session automatically calls req.session.touch() so we don't need to do it here.
res.json({status: 'ok'});
});
-
- return cb();
};
diff --git a/src/node/utils/Minify.js b/src/node/utils/Minify.js
index 032014335..2af200e1a 100644
--- a/src/node/utils/Minify.js
+++ b/src/node/utils/Minify.js
@@ -300,12 +300,12 @@ const getFileCompressed = async (filename, contentType) => {
try {
logger.info('Compress CSS file %s.', filename);
- const compressResult = await compressCSS(content);
+ const compressResult = await compressCSS(path.resolve(ROOT_DIR,filename));
if (compressResult.error) {
console.error(`Error compressing CSS (${filename}) using terser`, compressResult.error);
} else {
- content = compressResult.code.toString(); // Convert content obj code to string
+ content = compressResult
}
} catch (error) {
console.error(`CleanCSS.minify() returned an error on ${filename}: ${error}`);
diff --git a/src/node/utils/MinifyWorker.js b/src/node/utils/MinifyWorker.js
index 125bc663e..1485e86c3 100644
--- a/src/node/utils/MinifyWorker.js
+++ b/src/node/utils/MinifyWorker.js
@@ -3,9 +3,8 @@
* Worker thread to minify JS & CSS files out of the main NodeJS thread
*/
-const fsp = require('fs').promises;
import {expose} from 'threads'
-import {transform} from 'esbuild';
+import {build, transform} from 'esbuild';
/*
* Minify JS content
@@ -21,8 +20,23 @@ const compressJS = async (content) => {
* @param {string} ROOT_DIR - the root dir of Etherpad
*/
const compressCSS = async (content) => {
- return await transform(content, {loader: 'css', minify: true});
-
+ const transformedCSS = await build(
+ {
+ entryPoints: [content],
+ minify: true,
+ bundle: true,
+ loader:{
+ '.ttf': 'dataurl',
+ '.otf': 'dataurl',
+ '.woff': 'dataurl',
+ '.woff2': 'dataurl',
+ '.eot': 'dataurl',
+ '.svg': 'dataurl'
+ },
+ write: false
+ }
+ )
+ return transformedCSS.outputFiles[0].text
};
expose({
diff --git a/src/node/utils/toolbar.ts b/src/node/utils/toolbar.ts
index aac3fb3d3..f0ef45479 100644
--- a/src/node/utils/toolbar.ts
+++ b/src/node/utils/toolbar.ts
@@ -2,7 +2,7 @@
/**
* The Toolbar Module creates and renders the toolbars and buttons
*/
-const _ = require('underscore');
+import {isString, reduce, each, isUndefined, map, first, last, extend, escape} from 'underscore';
const removeItem = (array: string[], what: string) => {
let ax;
@@ -21,7 +21,7 @@ const defaultButtonAttributes = (name: string, overrides?: boolean) => ({
const tag = (name: string, attributes: AttributeObj, contents?: string) => {
const aStr = tagAttributes(attributes);
- if (_.isString(contents) && contents!.length > 0) {
+ if (isString(contents) && contents!.length > 0) {
return `<${name}${aStr}>${contents}${name}>`;
} else {
return `<${name}${aStr}>${name}>`;
@@ -34,14 +34,14 @@ type AttributeObj = {
}
const tagAttributes = (attributes: AttributeObj) => {
- attributes = _.reduce(attributes || {}, (o: AttributeObj, val: string, name: string) => {
- if (!_.isUndefined(val)) {
+ attributes = reduce(attributes || {}, (o: AttributeObj, val: string, name: string) => {
+ if (!isUndefined(val)) {
o[name] = val;
}
return o;
}, {});
- return ` ${_.map(attributes, (val: string, name: string) => `${name}="${_.escape(val)}"`).join(' ')}`;
+ return ` ${map(attributes, (val: string, name: string) => `${name}="${escape(val)}"`).join(' ')}`;
};
type ButtonGroupType = {
@@ -58,7 +58,7 @@ class ButtonGroup {
public static fromArray = function (array: string[]) {
const btnGroup = new ButtonGroup();
- _.each(array, (btnName: string) => {
+ each(array, (btnName: string) => {
const button = Button.load(btnName) as Button
btnGroup.addButton(button);
});
@@ -70,18 +70,19 @@ class ButtonGroup {
return this;
}
- render() {
+ render(): string {
if (this.buttons && this.buttons.length === 1) {
this.buttons[0].grouping = '';
} else if (this.buttons && this.buttons.length > 1) {
- _.first(this.buttons).grouping = 'grouped-left';
- _.last(this.buttons).grouping = 'grouped-right';
- _.each(this.buttons.slice(1, -1), (btn: Button) => {
+ first(this.buttons)!.grouping = 'grouped-left';
+ last(this.buttons)!.grouping = 'grouped-right';
+ each(this.buttons.slice(1, -1), (btn: Button) => {
btn.grouping = 'grouped-middle';
});
}
- return _.map(this.buttons, (btn: ButtonGroup) => {
+ // @ts-ignore
+ return map(this.buttons, (btn: ButtonGroup) => {
if (btn) return btn.render();
}).join('\n');
}
@@ -151,8 +152,8 @@ class SelectButton extends Button {
select(attributes: AttributeObj) {
const options: string[] = [];
- _.each(this.options, (opt: AttributeSelect) => {
- const a = _.extend({
+ each(this.options, (opt: AttributeSelect) => {
+ const a = extend({
value: opt.value,
}, opt.attributes);
@@ -299,7 +300,7 @@ module.exports = {
buttons[0].push('savedrevision');
}
- const groups = _.map(buttons, (group: string[]) => ButtonGroup.fromArray(group).render());
+ const groups = map(buttons, (group: string[]) => ButtonGroup.fromArray(group).render());
return groups.join(this.separator());
},
};
diff --git a/src/package.json b/src/package.json
index 1bb66a148..ffd243e96 100644
--- a/src/package.json
+++ b/src/package.json
@@ -34,6 +34,7 @@
"async": "^3.2.5",
"axios": "^1.7.2",
"cookie-parser": "^1.4.6",
+ "cross-env": "^7.0.3",
"cross-spawn": "^7.0.3",
"ejs": "^3.1.10",
"esbuild": "^0.23.0",
@@ -87,6 +88,7 @@
"@types/express": "^4.17.21",
"@types/formidable": "^3.4.5",
"@types/http-errors": "^2.0.4",
+ "@types/jquery": "^3.5.30",
"@types/jsdom": "^21.1.7",
"@types/jsonwebtoken": "^9.0.6",
"@types/mocha": "^10.0.7",
@@ -96,6 +98,7 @@
"@types/sinon": "^17.0.3",
"@types/supertest": "^6.0.2",
"@types/underscore": "^1.11.15",
+ "chokidar": "^3.6.0",
"eslint": "^9.7.0",
"eslint-config-etherpad": "^4.0.4",
"etherpad-cli-client": "^3.0.2",
@@ -120,17 +123,17 @@
},
"scripts": {
"lint": "eslint .",
- "test": "mocha --import=tsx --timeout 120000 --recursive tests/backend/specs/**.ts ../node_modules/ep_*/static/tests/backend/specs/**",
- "test-utils": "mocha --import=tsx --timeout 5000 --recursive tests/backend/specs/*utils.ts",
+ "test": "cross-env NODE_ENV=production mocha --import=tsx --timeout 120000 --recursive tests/backend/specs/**.ts ../node_modules/ep_*/static/tests/backend/specs/**",
+ "test-utils": "cross-env NODE_ENV=production mocha --import=tsx --timeout 5000 --recursive tests/backend/specs/*utils.ts",
"test-container": "mocha --import=tsx --timeout 5000 tests/container/specs/api",
- "dev": "node --require tsx/cjs node/server.ts",
- "prod": "node --require tsx/cjs node/server.ts",
+ "dev": "cross-env NODE_ENV=development node --require tsx/cjs node/server.ts",
+ "prod": "cross-env NODE_ENV=production node --require tsx/cjs node/server.ts",
"ts-check": "tsc --noEmit",
"ts-check:watch": "tsc --noEmit --watch",
- "test-ui": "npx playwright test tests/frontend-new/specs",
- "test-ui:ui": "npx playwright test tests/frontend-new/specs --ui",
- "test-admin": "npx playwright test tests/frontend-new/admin-spec --workers 1",
- "test-admin:ui": "npx playwright test tests/frontend-new/admin-spec --ui --workers 1",
+ "test-ui": "cross-env NODE_ENV=production npx playwright test tests/frontend-new/specs",
+ "test-ui:ui": "cross-env NODE_ENV=production npx playwright test tests/frontend-new/specs --ui",
+ "test-admin": "cross-env NODE_ENV=production npx playwright test tests/frontend-new/admin-spec --workers 1",
+ "test-admin:ui": "cross-env NODE_ENV=production npx playwright test tests/frontend-new/admin-spec --ui --workers 1",
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts"
},
"version": "2.1.1",
diff --git a/src/static/js/AttributeManager.js b/src/static/js/AttributeManager.js
index f508af641..63af431d9 100644
--- a/src/static/js/AttributeManager.js
+++ b/src/static/js/AttributeManager.js
@@ -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
diff --git a/src/static/js/ace.js b/src/static/js/ace.js
index b0a042570..a1b5d99c8 100644
--- a/src/static/js/ace.js
+++ b/src/static/js/ace.js
@@ -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 () {
//
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')); //
-
+/*
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),
diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js
index 868906cfd..641c5ecdb 100644
--- a/src/static/js/ace2_inner.js
+++ b/src/static/js/ace2_inner.js
@@ -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 = '';
+ if (!targetBody.firstChild) {
+ targetBody.innerHTML = '';
}
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]);
diff --git a/src/static/js/broadcast.js b/src/static/js/broadcast.js
index cd2211ae1..2163fd78e 100644
--- a/src/static/js/broadcast.js
+++ b/src/static/js/broadcast.js
@@ -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) => {
diff --git a/src/static/js/broadcast_slider.js b/src/static/js/broadcast_slider.js
index 80afffe28..848ba06cf 100644
--- a/src/static/js/broadcast_slider.js
+++ b/src/static/js/broadcast_slider.js
@@ -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;
diff --git a/src/static/js/caretPosition.js b/src/static/js/caretPosition.ts
similarity index 84%
rename from src/static/js/caretPosition.js
rename to src/static/js/caretPosition.ts
index 03af77f33..5134a0ed0 100644
--- a/src/static/js/caretPosition.js
+++ b/src/static/js/caretPosition.ts
@@ -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
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);
diff --git a/src/static/js/chat.js b/src/static/js/chat.js
index 9dee3be4f..d32a62c7a 100755
--- a/src/static/js/chat.js
+++ b/src/static/js/chat.js
@@ -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;
diff --git a/src/static/js/l10n.js b/src/static/js/l10n.js
deleted file mode 100644
index 7206f913b..000000000
--- a/src/static/js/l10n.js
+++ /dev/null
@@ -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);
diff --git a/src/static/js/l10n.ts b/src/static/js/l10n.ts
new file mode 100644
index 000000000..2211318c0
--- /dev/null
+++ b/src/static/js/l10n.ts
@@ -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()!;
+});
diff --git a/src/static/js/pad.js b/src/static/js/pad.js
index 8740e2fb2..d6648f031 100644
--- a/src/static/js/pad.js
+++ b/src/static/js/pad.js
@@ -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),
},
diff --git a/src/static/js/pad_automatic_reconnect.js b/src/static/js/pad_automatic_reconnect.js
index 576e0d350..03fc91432 100644
--- a/src/static/js/pad_automatic_reconnect.js
+++ b/src/static/js/pad_automatic_reconnect.js
@@ -1,4 +1,5 @@
'use strict';
+import html10n from './vendors/html10n';
exports.showCountDownTimerToReconnectOnModal = ($modal, pad) => {
if (clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) {
diff --git a/src/static/js/pad_editor.js b/src/static/js/pad_editor.js
index 585cccbb5..47a250734 100644
--- a/src/static/js/pad_editor.js
+++ b/src/static/js/pad_editor.js
@@ -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');
}
diff --git a/src/static/js/pad_impexp.js b/src/static/js/pad_impexp.js
index 4d607ff83..3aca9fb7c 100644
--- a/src/static/js/pad_impexp.js
+++ b/src/static/js/pad_impexp.js
@@ -22,6 +22,9 @@
* limitations under the License.
*/
+import html10n from './vendors/html10n';
+
+
const padimpexp = (() => {
let pad;
diff --git a/src/static/js/pad_savedrevs.js b/src/static/js/pad_savedrevs.js
index b5868f699..4082e0380 100644
--- a/src/static/js/pad_savedrevs.js
+++ b/src/static/js/pad_savedrevs.js
@@ -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
diff --git a/src/static/js/pad_userlist.js b/src/static/js/pad_userlist.js
index b689ae1ad..a0cbd4b44 100644
--- a/src/static/js/pad_userlist.js
+++ b/src/static/js/pad_userlist.js
@@ -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;
diff --git a/src/static/js/pad_utils.js b/src/static/js/pad_utils.js
index 58105d23c..467a8adc9 100644
--- a/src/static/js/pad_utils.js
+++ b/src/static/js/pad_utils.js
@@ -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
diff --git a/src/static/js/pluginfw/client_plugins.js b/src/static/js/pluginfw/client_plugins.js
index 221e786f8..3a0687733 100644
--- a/src/static/js/pluginfw/client_plugins.js
+++ b/src/static/js/pluginfw/client_plugins.js
@@ -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) => {
diff --git a/src/static/js/pluginfw/shared.js b/src/static/js/pluginfw/shared.js
index 2c81ccd81..b2c2337f6 100644
--- a/src/static/js/pluginfw/shared.js
+++ b/src/static/js/pluginfw/shared.js
@@ -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}" ` +
diff --git a/src/static/js/scroll.js b/src/static/js/scroll.js
deleted file mode 100644
index 86d6a3344..000000000
--- a/src/static/js/scroll.js
+++ /dev/null
@@ -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 is a line)
- Browser Line = each vertical line. A
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);
diff --git a/src/static/js/scroll.ts b/src/static/js/scroll.ts
new file mode 100644
index 000000000..95075d807
--- /dev/null
+++ b/src/static/js/scroll.ts
@@ -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
diff --git a/src/static/js/socketio.js b/src/static/js/socketio.js
index 1d3739775..cdc1c9a23 100644
--- a/src/static/js/socketio.js
+++ b/src/static/js/socketio.js
@@ -1,4 +1,4 @@
-'use strict';
+import io from 'socket.io-client';
/**
* Creates a socket.io connection.
diff --git a/src/static/js/timeslider.js b/src/static/js/timeslider.js
index 4cc5d45a6..8d8604b91 100644
--- a/src/static/js/timeslider.js
+++ b/src/static/js/timeslider.js
@@ -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);
diff --git a/src/static/js/types/RepModel.ts b/src/static/js/types/RepModel.ts
new file mode 100644
index 000000000..821549e1d
--- /dev/null
+++ b/src/static/js/types/RepModel.ts
@@ -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
+}
diff --git a/src/static/js/vendors/farbtastic.js b/src/static/js/vendors/farbtastic.js
index ad832dc72..5d0c4718c 100644
--- a/src/static/js/vendors/farbtastic.js
+++ b/src/static/js/vendors/farbtastic.js
@@ -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
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();
diff --git a/src/static/js/vendors/gritter.js b/src/static/js/vendors/gritter.js
index a20cb4de9..1b8b9a759 100644
--- a/src/static/js/vendors/gritter.js
+++ b/src/static/js/vendors/gritter.js
@@ -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;
diff --git a/src/static/js/vendors/html10n.js b/src/static/js/vendors/html10n.js
deleted file mode 100644
index 50b6d2279..000000000
--- a/src/static/js/vendors/html10n.js
+++ /dev/null
@@ -1,1056 +0,0 @@
-// WARNING: This file has been modified from the Original
-
-/**
- * Copyright (c) 2012 Marcel Klehr
- * Copyright (c) 2011-2012 Fabien Cazenave, Mozilla
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to
- * deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
- * sell copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
- * IN THE SOFTWARE.
- */
-window.html10n = (function(window, document, undefined) {
-
- // fix console
- (function() {
- const noop = function () {
- };
- const names = ["log", "debug", "info", "warn", "error", "assert", "dir", "dirxml", "group", "groupEnd", "time", "timeEnd", "count", "trace", "profile", "profileEnd"];
- const console = (window.console = window.console || {});
- for (let i = 0; i < names.length; ++i) {
- if (!console[names[i]]) {
- console[names[i]] = noop;
- }
- }
- }());
-
- // fix Array#forEach in IE
- // taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach
- if (!Array.prototype.forEach) {
- Array.prototype.forEach = function(fn, scope) {
- let i = 0, len = this.length;
- for(; i < len; ++i) {
- if (i in this) {
- fn.call(scope, this[i], i, this);
- }
- }
- };
- }
-
- // fix Array#indexOf in, guess what, IE! <3
- // taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf
- if (!Array.prototype.indexOf) {
- Array.prototype.indexOf = function (searchElement /*, fromIndex */ ) {
- "use strict";
- if (this == null) {
- throw new TypeError();
- }
- const t = Object(this);
- const len = t.length >>> 0;
- if (len === 0) {
- return -1;
- }
- let n = 0;
- if (arguments.length > 1) {
- n = Number(arguments[1]);
- if (n != n) { // shortcut for verifying if it's NaN
- n = 0;
- } else if (n != 0 && n != Infinity && n != -Infinity) {
- n = (n > 0 || -1) * Math.floor(Math.abs(n));
- }
- }
- if (n >= len) {
- return -1;
- }
- var k = n >= 0 ? n : Math.max(len - Math.abs(n), 0);
- for (; k < len; k++) {
- if (k in t && t[k] === searchElement) {
- return k;
- }
- }
- return -1;
- }
- }
-
- /**
- * MicroEvent - to make any js object an event emitter (server or browser)
- */
- const MicroEvent = function () {
- };
- MicroEvent.prototype = {
- bind: function(event, fct){
- this._events = this._events || {};
- this._events[event] = this._events[event] || [];
- this._events[event].push(fct);
- },
- unbind: function(event, fct){
- this._events = this._events || {};
- if( event in this._events === false ) return;
- this._events[event].splice(this._events[event].indexOf(fct), 1);
- },
- trigger: function(event /* , args... */){
- this._events = this._events || {};
- if( event in this._events === false ) return;
- for(var i = 0; i < this._events[event].length; i++){
- this._events[event][i].apply(this, Array.prototype.slice.call(arguments, 1))
- }
- }
- };
- /**
- * mixin will delegate all MicroEvent.js function in the destination object
- * @param {Object} the object which will support MicroEvent
- */
- MicroEvent.mixin = function(destObject){
- var props = ['bind', 'unbind', 'trigger'];
- if(!destObject) return;
- for(var i = 0; i < props.length; i ++){
- destObject[props[i]] = MicroEvent.prototype[props[i]];
- }
- }
-
- /**
- * Loader
- * The loader is responsible for loading
- * and caching all necessary resources
- */
- function Loader(resources) {
- this.resources = resources
- this.cache = {} // file => contents
- this.langs = {} // lang => strings
- }
-
- Loader.prototype.load = function(lang, cb) {
- if(this.langs[lang]) return cb()
-
- if (this.resources.length > 0) {
- var reqs = 0;
- for (var i=0, n=this.resources.length; i < n; i++) {
- this.fetch(this.resources[i], lang, function(e) {
- reqs++;
- if(e) console.warn(e)
-
- if (reqs < n) return;// Call back once all reqs are completed
- cb && cb()
- })
- }
- }
- }
-
- Loader.prototype.fetch = function(href, lang, cb) {
- var that = this
-
- if (this.cache[href]) {
- this.parse(lang, href, this.cache[href], cb)
- return;
- }
-
- var xhr = new XMLHttpRequest()
- xhr.open('GET', href, /*async: */true)
- if (xhr.overrideMimeType) {
- xhr.overrideMimeType('application/json; charset=utf-8');
- }
- xhr.onreadystatechange = function() {
- if (xhr.readyState == 4) {
- if (xhr.status == 200 || xhr.status === 0) {
- var data = JSON.parse(xhr.responseText)
- that.cache[href] = data
- // Pass on the contents for parsing
- that.parse(lang, href, data, cb)
- } else {
- cb(new Error('Failed to load '+href))
- }
- }
- };
- xhr.send(null);
- }
-
- Loader.prototype.parse = function(lang, currHref, data, cb) {
- if ('object' != typeof data) {
- cb(new Error('A file couldn\'t be parsed as json.'))
- return
- }
-
- // Issue #6129: Fix exceptions caused by browsers
- // Also for fallback, see BCP 47 RFC 4647 section 3.4
- // NOTE: this output the all lowercase form
- function getBcp47LangCode(browserLang) {
- const bcp47Lang = browserLang.toLowerCase();
- // Browser => BCP 47
- const langCodeMap = {
- '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[bcp47Lang] ?? bcp47Lang;
- }
-
- // Issue #6129: Fix exceptions
- // NOTE: translatewiki.net use all lowercase form by default ('en-gb' insted of 'en-GB')
- function getJsonLangCode(bcp47Lang) {
- const jsonLang = bcp47Lang.toLowerCase();
- // BCP 47 => JSON
- const langCodeMap = {
- 'sr-cyrl': 'sr-ec',
- 'sr-latn': 'sr-el',
- 'zh-hant-hk': 'zh-hk',
- };
-
- return langCodeMap[jsonLang] ?? jsonLang;
- }
-
- let bcp47LangCode = getBcp47LangCode(lang);
- let jsonLangCode = getJsonLangCode(bcp47LangCode);
-
- // Check if lang exists
- 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);
- }
-
- // Check if already found data or ROOT lang exists (e.g 'ru')
- 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 cb(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 = currHref+"/../"+data[lang]
- }
-
- this.fetch(importUrl, lang, cb)
- return
- }
-
- if ('object' != typeof data[lang]) {
- cb(new Error('Translations should be specified as JSON objects!'))
- return
- }
-
- this.langs[lang] = data[lang]
- // TODO: Also store accompanying langs
- cb()
- }
-
-
- /**
- * The html10n object
- */
- const html10n =
- {
- language: null
- };
- MicroEvent.mixin(html10n)
-
- html10n.macros = {}
-
- html10n.rtl = ["ar","dv","fa","ha","he","ks","ku","ps","ur","yi"]
-
- /**
- * 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 {Function}
- * 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'.
- */
- function getPluralRules(lang) {
- var locales2rules = {
- '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
- };
-
- // utility functions for plural rules methods
- function isIn(n, list) {
- return list.indexOf(n) !== -1;
- }
- function isBetween(n, start, end) {
- return start <= n && n <= end;
- }
-
- // list of all plural rules methods:
- // map an integer to the plural form name to use
- var pluralRules = {
- '0': function(n) {
- return 'other';
- },
- '1': function(n) {
- 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) {
- if (n !== 0 && (n % 10) === 0)
- return 'many';
- if (n == 2)
- return 'two';
- if (n == 1)
- return 'one';
- return 'other';
- },
- '3': function(n) {
- if (n == 1)
- return 'one';
- return 'other';
- },
- '4': function(n) {
- if ((isBetween(n, 0, 1)))
- return 'one';
- return 'other';
- },
- '5': function(n) {
- if ((isBetween(n, 0, 2)) && n != 2)
- return 'one';
- return 'other';
- },
- '6': function(n) {
- if (n === 0)
- return 'zero';
- if ((n % 10) == 1 && (n % 100) != 11)
- return 'one';
- return 'other';
- },
- '7': function(n) {
- if (n == 2)
- return 'two';
- if (n == 1)
- return 'one';
- return 'other';
- },
- '8': function(n) {
- 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) {
- if (n === 0 || n != 1 && (isBetween((n % 100), 1, 19)))
- return 'few';
- if (n == 1)
- return 'one';
- return 'other';
- },
- '10': function(n) {
- 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) {
- 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) {
- if ((isBetween(n, 2, 4)))
- return 'few';
- if (n == 1)
- return 'one';
- return 'other';
- },
- '13': function(n) {
- 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) {
- 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) {
- 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) {
- if ((n % 10) == 1 && n != 11)
- return 'one';
- return 'other';
- },
- '17': function(n) {
- 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) {
- if (n === 0)
- return 'zero';
- if ((isBetween(n, 0, 2)) && n !== 0 && n != 2)
- return 'one';
- return 'other';
- },
- '19': function(n) {
- if ((isBetween(n, 2, 10)))
- return 'few';
- if ((isBetween(n, 0, 1)))
- return 'one';
- return 'other';
- },
- '20': function(n) {
- 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) {
- if (n === 0)
- return 'zero';
- if (n == 1)
- return 'one';
- return 'other';
- },
- '22': function(n) {
- if ((isBetween(n, 0, 1)) || (isBetween(n, 11, 99)))
- return 'one';
- return 'other';
- },
- '23': function(n) {
- if ((isBetween((n % 10), 1, 2)) || (n % 20) === 0)
- return 'one';
- return 'other';
- },
- '24': function(n) {
- 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';
- }
- };
-
- // return a function that gives the plural form name for a given integer
- var index = locales2rules[lang.replace(/-.*$/, '')];
- if (!(index in pluralRules)) {
- console.warn('plural form unknown for [' + lang + ']');
- return function() { return 'other'; };
- }
- return pluralRules[index];
- }
-
- /**
- * pre-defined 'plural' macro
- */
- html10n.macros.plural = function(key, param, opts) {
- var str
- , n = parseFloat(param);
- if (isNaN(n))
- return;
-
- // initialize _pluralRules
- if (!this._pluralRules)
- this._pluralRules = getPluralRules(html10n.language);
- var 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;
- };
-
- /**
- * Localize a document
- * @param langs An array of lang codes defining fallbacks
- */
- html10n.localize = function(langs) {
- var that = this
- // if only one string => create an array
- if ('string' == typeof langs) langs = [langs]
-
- // Expand two-part locale specs
- var i=0
- langs.forEach(function(lang) {
- if(!lang) return;
- langs[i++] = lang;
- if(~lang.indexOf('-')) langs[i++] = lang.substr(0, lang.indexOf('-'));
- })
-
- this.build(langs, function(er, translations) {
- html10n.translations = translations
- html10n.translateElement(translations)
- that.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
- */
- html10n.translateElement = function(translations, element) {
- element = element || document.documentElement
-
- var children = element? getTranslatableChildren(element) : document.childNodes;
- for (var i=0, n=children.length; i < n; i++) {
- this.translateNode(translations, children[i])
- }
-
- // translate element itself if necessary
- this.translateNode(translations, element)
- }
-
- function asyncForEach(list, iterator, cb) {
- var i = 0
- , n = list.length
- iterator(list[i], i, function each(err) {
- if(err) console.error(err)
- i++
- if (i < n) return iterator(list[i],i, each);
- cb()
- })
- }
-
- function getTranslatableChildren(element) {
- if(!document.querySelectorAll) {
- if (!element) return []
- var nodes = element.getElementsByTagName('*')
- , l10nElements = []
- for (var i=0, n=nodes.length; i < n; i++) {
- if (nodes[i].getAttribute('data-l10n-id'))
- l10nElements.push(nodes[i]);
- }
- return l10nElements
- }
- return element.querySelectorAll('*[data-l10n-id]')
- }
-
- html10n.get = function(id, args) {
- var translations = html10n.translations
- if(!translations) return console.warn('No translations available (yet)')
- if(!translations[id]) return console.warn('Could not find string '+id)
-
- // apply macros
- var str = translations[id]
-
- str = substMacros(id, str, args)
-
- // apply args
- str = substArguments(str, args)
-
- return str
- }
-
- // replace {{arguments}} with their values or the
- // associated translation string (based on its key)
- function substArguments(str, args) {
- var reArgs = /\{\{\s*([a-zA-Z\.]+)\s*\}\}/
- , match
- var translations = html10n.translations;
- while (match = reArgs.exec(str)) {
- if (!match || match.length < 2)
- return str // argument key not found
-
- var arg = match[1]
- , sub = ''
- if (args && arg in args) {
- sub = args[arg]
- } else if (translations && arg in translations) {
- sub = translations[arg]
- } else {
- console.warn('Could not find argument {{' + arg + '}}')
- return str
- }
-
- str = str.substring(0, match.index) + sub + str.substr(match.index + match[0].length)
- }
-
- return str
- }
-
- // replace {[macros]} with their values
- function substMacros(key, str, args) {
- var 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
- var macroName = match[1]
- , paramName = match[2]
- , optv = match[3]
- , opts = {}
-
- if (!(macroName in html10n.macros)) continue
-
- if(optv) {
- optv.match(/(?=\s*)([a-zA-Z]+)\: ?([ a-zA-Z{}]+)(?=,?)/g).forEach(function(arg) {
- var parts = arg.split(':')
- , name = parts[0]
- , value = parts[1].trim()
- opts[name] = value
- })
- }
-
- var param
- if (args && paramName in args) {
- param = args[paramName]
- } else if (paramName in html10n.translations) {
- param = translations[paramName]
- }
-
- // there's no macro parser: it has to be defined in html10n.macros
- var macro = html10n.macros[macroName]
- str = str.substr(0, match.index) + macro(key, param, opts) + str.substr(match.index+match[0].length)
- }
-
- return str
- }
-
- /**
- * Applies translations to a DOM node (recursive)
- */
- html10n.translateNode = function(translations, node) {
- var str = {}
-
- // get id
- str.id = node.getAttribute('data-l10n-id')
- if (!str.id) return
-
- if(!translations[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'))
- }else{
- try{
- str.args = eval(node.getAttribute('data-l10n-args'))
- }catch(e) {
- console.warn('Couldn\'t parse args for '+str.id)
- }
- }
-
- str.str = html10n.get(str.id, str.args)
-
- // get attribute name to apply str to
- var 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.substr(index + 1) in attrList) {
- // an attribute has been specified (example: "my_translation_key.placeholder")
- prop = str.id.substr(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') {
- 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 {
- var children = node.childNodes,
- found = false
- for (var i=0, n=children.length; 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)
- }
- }
- }
-
- /**
- * 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)
- */
- html10n.build = function(langs, cb) {
- var that = this
- , build = {}
-
- asyncForEach(langs, function (lang, i, next) {
- if(!lang) return next();
- that.loader.load(lang, next)
- }, function() {
- var lang
- langs.reverse()
-
- // loop through the priority array...
- for (var i=0, n=langs.length; i < n; i++) {
- lang = langs[i]
-
- if(!lang) continue;
- if(!(lang in that.loader.langs)) {// uh, we don't have this lang availbable..
- // then check for related langs
- if(~lang.indexOf('-')) lang = lang.split('-')[0];
- for(var l in that.loader.langs) {
- if(lang != l && l.indexOf(lang) === 0) {
- lang = l
- break;
- }
- }
- if(lang != l) continue;
- }
-
- // ... and apply all strings of the current lang in the list
- // to our build object
- for (var string in that.loader.langs[lang]) {
- build[string] = that.loader.langs[lang][string]
- }
-
- // the last applied lang will be exposed as the
- // lang the page was translated to
- that.language = lang
- }
- cb(null, build)
- })
- }
-
- /**
- * Returns the language that was last applied to the translations hash
- * thus overriding most of the formerly applied langs
- */
- html10n.getLanguage = function() {
- return this.language;
- }
-
- /**
- * Returns the direction of the language returned be html10n#getLanguage
- */
- html10n.getDirection = function() {
- if(!this.language) return
- var langCode = this.language.indexOf('-') == -1? this.language : this.language.substr(0, this.language.indexOf('-'))
- return html10n.rtl.indexOf(langCode) == -1? 'ltr' : 'rtl'
- }
-
- /**
- * Index all s
- */
- html10n.index = function () {
- // Find all s
- var links = document.getElementsByTagName('link')
- , resources = []
- for (var 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.trigger('indexed')
- }
-
- if (document.addEventListener) // modern browsers and IE9+
- document.addEventListener('DOMContentLoaded', function() {
- html10n.index()
- }, false)
- else if (window.attachEvent)
- window.attachEvent('onload', function() {
- html10n.index()
- }, false)
-
- // gettext-like shortcut
- if (window._ === undefined)
- window._ = html10n.get;
-
- return html10n
-})(window, document)
diff --git a/src/static/js/vendors/html10n.ts b/src/static/js/vendors/html10n.ts
new file mode 100644
index 000000000..39da71200
--- /dev/null
+++ b/src/static/js/vendors/html10n.ts
@@ -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
+ private macros: Map
+
+ 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) =>{
+ 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, 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()
+
+ 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 s
+ */
+ index() {
+ // Find all 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, 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
+
+ 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
+ langs: Map
+
+ 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()
diff --git a/src/static/js/vendors/nice-select.js b/src/static/js/vendors/nice-select.js
index d19de5332..447ff6413 100644
--- a/src/static/js/vendors/nice-select.js
+++ b/src/static/js/vendors/nice-select.js
@@ -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;
diff --git a/src/templates/pad.html b/src/templates/pad.html
index c0c56bf24..08437b628 100644
--- a/src/templates/pad.html
+++ b/src/templates/pad.html
@@ -34,7 +34,6 @@
for the JavaScript code in this page.|
*/
-
@@ -53,8 +52,6 @@
<% e.end_block(); %>
-
-
<% e.begin_block("body"); %>
@@ -442,67 +439,11 @@
<% e.begin_block("scripts"); %>
-
-
-
-
-
-
+
<% e.begin_block("customScripts"); %>
<% e.end_block(); %>
-
-
-
<% e.end_block(); %>
diff --git a/src/templates/padBootstrap.js b/src/templates/padBootstrap.js
new file mode 100644
index 000000000..c86d170c1
--- /dev/null
+++ b/src/templates/padBootstrap.js
@@ -0,0 +1,45 @@
+
+(async () => {
+
+ require('../../src/static/js/l10n')
+
+ window.clientVars = {
+ // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the server
+ // sends the CLIENT_VARS message.
+ randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>,
+ };
+
+ // Allow other frames to access this frame's modules.
+ //window.require.resolveTmp = require.resolve('ep_etherpad-lite/static/js/pad_cookie');
+
+ const basePath = new URL('..', window.location.href).pathname;
+ window.$ = window.jQuery = require('../../src/static/js/rjquery').jQuery;
+ window.browser = require('../../src/static/js/vendors/browser');
+ const pad = require('../../src/static/js/pad');
+ pad.baseURL = basePath;
+ window.plugins = require('../../src/static/js/pluginfw/client_plugins');
+ const hooks = require('../../src/static/js/pluginfw/hooks');
+
+ // TODO: These globals shouldn't exist.
+ window.pad = pad.pad;
+ window.chat = require('../../src/static/js/chat').chat;
+ window.padeditbar = require('../../src/static/js/pad_editbar').padeditbar;
+ window.padimpexp = require('../../src/static/js/pad_impexp').padimpexp;
+ require('../../src/static/js/skin_variants');
+ require('../../src/static/js/basic_error_handler')
+
+ window.plugins.baseURL = basePath;
+ await window.plugins.update(new Map([
+ <% for (const module of pluginModules) { %>
+ [<%- JSON.stringify(module) %>, require("../../src/plugin_packages/"+<%- JSON.stringify(module) %>)],
+ <% } %>
+]));
+ // Mechanism for tests to register hook functions (install fake plugins).
+ window._postPluginUpdateForTestingDone = false;
+ if (window._postPluginUpdateForTesting != null) window._postPluginUpdateForTesting();
+ window._postPluginUpdateForTestingDone = true;
+ window.pluginDefs = require('../../src/static/js/pluginfw/plugin_defs');
+ pad.init();
+ await new Promise((resolve) => $(resolve));
+ await hooks.aCallAll('documentReady');
+})();
diff --git a/src/templates/padViteBootstrap.js b/src/templates/padViteBootstrap.js
new file mode 100644
index 000000000..05f759077
--- /dev/null
+++ b/src/templates/padViteBootstrap.js
@@ -0,0 +1,41 @@
+window.$ = window.jQuery = await import('../../src/static/js/rjquery').jQuery;
+await import('../../src/static/js/l10n')
+
+window.clientVars = {
+ // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the server
+ // sends the CLIENT_VARS message.
+ randomVersionString: "7a7bdbad",
+};
+
+(async () => {
+ // Allow other frames to access this frame's modules.
+ //window.require.resolveTmp = require.resolve('ep_etherpad-lite/static/js/pad_cookie');
+
+ const basePath = new URL('..', window.location.href).pathname;
+ window.browser = require('../../src/static/js/vendors/browser');
+ const pad = require('../../src/static/js/pad');
+ pad.baseURL = basePath;
+ window.plugins = require('../../src/static/js/pluginfw/client_plugins');
+ const hooks = require('../../src/static/js/pluginfw/hooks');
+
+ // TODO: These globals shouldn't exist.
+ window.pad = pad.pad;
+ window.chat = require('../../src/static/js/chat').chat;
+ window.padeditbar = require('../../src/static/js/pad_editbar').padeditbar;
+ window.padimpexp = require('../../src/static/js/pad_impexp').padimpexp;
+ require('../../src/static/js/skin_variants');
+ require('../../src/static/js/basic_error_handler')
+
+ window.plugins.baseURL = basePath;
+ await window.plugins.update(new Map([
+
+ ]));
+ // Mechanism for tests to register hook functions (install fake plugins).
+ window._postPluginUpdateForTestingDone = false;
+ if (window._postPluginUpdateForTesting != null) window._postPluginUpdateForTesting();
+ window._postPluginUpdateForTestingDone = true;
+ window.pluginDefs = require('../../src/static/js/pluginfw/plugin_defs');
+ pad.init();
+ await new Promise((resolve) => $(resolve));
+ await hooks.aCallAll('documentReady');
+})();
diff --git a/src/templates/timeSliderBootstrap.js b/src/templates/timeSliderBootstrap.js
new file mode 100644
index 000000000..e3138cfbd
--- /dev/null
+++ b/src/templates/timeSliderBootstrap.js
@@ -0,0 +1,37 @@
+// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt
+window.clientVars = {
+ // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the
+ // server sends the CLIENT_VARS message.
+ randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>,
+};
+let BroadcastSlider;
+
+
+(function () {
+ const timeSlider = require('ep_etherpad-lite/static/js/timeslider')
+ const pathComponents = location.pathname.split('/');
+
+ // Strip 'p', the padname and 'timeslider' from the pathname and set as baseURL
+ const baseURL = pathComponents.slice(0,pathComponents.length-3).join('/') + '/';
+ require('ep_etherpad-lite/static/js/l10n')
+ window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; // Expose jQuery #HACK
+ require('ep_etherpad-lite/static/js/vendors/gritter')
+
+ window.browser = require('ep_etherpad-lite/static/js/vendors/browser');
+
+ window.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');
+ const socket = timeSlider.socket;
+ BroadcastSlider = timeSlider.BroadcastSlider;
+ plugins.baseURL = baseURL;
+ plugins.update(function () {
+
+
+ /* TODO: These globals shouldn't exist. */
+
+ });
+ const padeditbar = require('ep_etherpad-lite/static/js/pad_editbar').padeditbar;
+ const padimpexp = require('ep_etherpad-lite/static/js/pad_impexp').padimpexp;
+ timeSlider.baseURL = baseURL;
+ timeSlider.init();
+ padeditbar.init()
+})();
diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html
index 71346f21e..e2178e54e 100644
--- a/src/templates/timeslider.html
+++ b/src/templates/timeslider.html
@@ -47,8 +47,6 @@
<% e.begin_block("timesliderScripts"); %>
-
-
<% e.end_block(); %>
@@ -250,58 +248,14 @@
-
-
-
-
+
<% e.end_block(); %>