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}`; } else { return `<${name}${aStr}>`; @@ -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(); %> diff --git a/src/tests/frontend/travis/runnerLoadTest.sh b/src/tests/frontend/travis/runnerLoadTest.sh index 6582b4b51..250d01f19 100755 --- a/src/tests/frontend/travis/runnerLoadTest.sh +++ b/src/tests/frontend/travis/runnerLoadTest.sh @@ -24,7 +24,7 @@ s!"points":[^,]*!"points": 1000! ' settings.json.template >settings.json log "Assuming src/bin/installDeps.sh has already been run" -(cd src && npm run dev & +(cd src && pnpm run prod & ep_pid=$!) log "Waiting for Etherpad to accept connections (http://localhost:9001)..." diff --git a/ui/package.json b/ui/package.json index 57346c0e9..717d4a178 100644 --- a/ui/package.json +++ b/ui/package.json @@ -6,9 +6,11 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "build-copy": "tsc && vite build --outDir ../src/static/oidc --emptyOutDir" }, "devDependencies": { + "ep_etherpad-lite": "workspace:../src", "typescript": "^5.5.3", "vite": "^5.3.4" } diff --git a/ui/pad.html b/ui/pad.html new file mode 100644 index 000000000..6b34d7e9a --- /dev/null +++ b/ui/pad.html @@ -0,0 +1,686 @@ + + + + + + Etherpad + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+ + + +
+ + + + + + + +
+ +
+ +
+

+ You do not have permission to access this pad +

+
+ + +

+
+ Loading... +

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + 0 +
+ +
+
+
+

+ + █   +
+
+
+ +
+
+
+ +
+
+
+
+
+ + + + + + + + + + +
+ + + + + + + + + + + + + + diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 2ce13f8f7..11fb71d46 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,10 +1,18 @@ // vite.config.js import { resolve } from 'path' import { defineConfig } from 'vite' +import vitePluginRequire from 'vite-plugin-require'; +import { viteCommonjs } from '@originjs/vite-plugin-commonjs' export default defineConfig({ - base: '/views/', + base: '/views/', + plugins: [ + viteCommonjs(), + ], build: { + commonjsOptions:{ + transformMixedEsModules: true, + }, outDir: resolve(__dirname, '../src/static/oidc'), rollupOptions: { input: { @@ -14,4 +22,31 @@ export default defineConfig({ }, emptyOutDir: true, }, + server:{ + proxy:{ + '/static':{ + target: 'http://localhost:9001', + changeOrigin: true, + secure: false, + }, + '/views/manifest.json':{ + target: 'http://localhost:9001', + changeOrigin: true, + secure: false, + rewrite: (path) => path.replace(/^\/views/, ''), + }, + '/locales.json':{ + target: 'http://localhost:9001', + changeOrigin: true, + secure: false, + rewrite: (path) => path.replace(/^\/views/, ''), + }, + '/locales':{ + target: 'http://localhost:9001', + changeOrigin: true, + secure: false, + rewrite: (path) => path.replace(/^\/views/, ''), + }, + } + } }) diff --git a/var/js/.gitignore b/var/js/.gitignore new file mode 100644 index 000000000..086f4e283 --- /dev/null +++ b/var/js/.gitignore @@ -0,0 +1,2 @@ +*.js +*.map