diff --git a/CHANGELOG.md b/CHANGELOG.md index 12a9fd431..5f60b235f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +# 1.8.13 + +### Notable fixes + +* Fixed a bug in the safeRun.sh script (#4935) +* Don't create sessions on some static resources (#4921) +* Fixed issue with non-opening device keyboard on smartphones (#4929) +* Add version string to iframe_editor.css to prevent stale cache entry (#4964) + +### Notable enhancements + +* Refactor pad loading (no document.write anymore) (#4960) +* Improve import/export functionality, logging and tests (#4957) +* Refactor CSS manager creation (#4963) +* Better metrics +* Add test for client height (#4965) + +### Dependencies + +* ueberDB2 1.3.2 -> 1.4.4 +* express-rate-limit 5.2.5 -> 5.2.6 +* etherpad-require-kernel 1.0.9 -> 1.0.11 + # 1.8.12 Special mention: Thanks to Sauce Labs for additional testing tunnels to help us grow! :) diff --git a/README.md b/README.md index 4bc972285..d3543186b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Etherpad is extremely flexible providing you the means to modify it to solve wha * [Video Chat](https://video.etherpad.com) - Plugins to enable Video and Audio chat in a pad. * [Collaboration++](https://collab.etherpad.com) - Plugins to improve the really-real time collaboration experience, suitable for busy pads. * [Document Analysis](https://analysis.etherpad.com) - Plugins to improve author and document analysis during and post creation. +* [Scale](https://shard.etherpad.com) - Etherpad running at scale with pad sharding which allows Etherpad to scale to ∞ number of Active Pads with up to ~20,000 edits per second, per pad. # Project Status diff --git a/src/bin/safeRun.sh b/src/bin/safeRun.sh index d9efa241a..d980b9300 100755 --- a/src/bin/safeRun.sh +++ b/src/bin/safeRun.sh @@ -24,8 +24,8 @@ fatal() { error "$@"; exit 1; } LAST_EMAIL_SEND=0 # Move to the Etherpad base directory. -MY_DIR=$(try cd "${0%/*}" && try pwd -P) || exit 1 -try cd "${MY_DIR}/../.." +MY_DIR=$(cd "${0%/*}" && pwd -P) || exit 1 +cd "${MY_DIR}/../.." || exit 1 # Check if a logfile parameter is set LOG="$1" diff --git a/src/locales/ku-latn.json b/src/locales/ku-latn.json index d1194ce04..537c4074f 100644 --- a/src/locales/ku-latn.json +++ b/src/locales/ku-latn.json @@ -31,7 +31,7 @@ "pad.settings.stickychat": "Di ekranê de hertim çet bike", "pad.settings.chatandusers": "Çeta û Bikarhênera Nîşan bide", "pad.settings.colorcheck": "Rengên nivîskarîye", - "pad.settings.linenocheck": "Hejmarên rêze", + "pad.settings.linenocheck": "Hejmarên rêzê", "pad.settings.rtlcheck": "Bila naverok ji raste ber bi çepe be xwendin?", "pad.settings.fontType": "Tîpa nivîsê:", "pad.settings.language": "Ziman:", diff --git a/src/node/db/DB.js b/src/node/db/DB.js index c0993e8ec..e1097e905 100644 --- a/src/node/db/DB.js +++ b/src/node/db/DB.js @@ -24,6 +24,7 @@ const ueberDB = require('ueberdb2'); const settings = require('../utils/Settings'); const log4js = require('log4js'); +const stats = require('../stats'); const util = require('util'); // set database settings @@ -48,6 +49,13 @@ exports.init = async () => await new Promise((resolve, reject) => { process.exit(1); } + if (db.metrics != null) { + for (const [metric, value] of Object.entries(db.metrics)) { + if (typeof value !== 'number') continue; + stats.gauge(`ueberdb_${metric}`, () => db.metrics[metric]); + } + } + // everything ok, set up Promise-based methods ['get', 'set', 'findKeys', 'getSub', 'setSub', 'remove'].forEach((fn) => { exports[fn] = util.promisify(db[fn].bind(db)); diff --git a/src/node/handler/ExportHandler.js b/src/node/handler/ExportHandler.js index fbb9e57da..0bf75a17f 100644 --- a/src/node/handler/ExportHandler.js +++ b/src/node/handler/ExportHandler.js @@ -33,24 +33,12 @@ const util = require('util'); const fsp_writeFile = util.promisify(fs.writeFile); const fsp_unlink = util.promisify(fs.unlink); -let convertor = null; - -// load abiword only if it is enabled -if (settings.abiword != null) { - convertor = require('../utils/Abiword'); -} - -// Use LibreOffice if an executable has been defined in the settings -if (settings.soffice != null) { - convertor = require('../utils/LibreOffice'); -} - const tempDirectory = os.tmpdir(); /** * do a requested export */ -const doExport = async (req, res, padId, readOnlyId, type) => { +exports.doExport = async (req, res, padId, readOnlyId, type) => { // avoid naming the read-only file as the original pad's id let fileName = readOnlyId ? readOnlyId : padId; @@ -85,7 +73,7 @@ const doExport = async (req, res, padId, readOnlyId, type) => { const newHTML = await hooks.aCallFirst('exportHTMLSend', html); if (newHTML.length) html = newHTML; res.send(html); - throw 'stop'; + return; } // else write the html export to a file @@ -98,7 +86,7 @@ const doExport = async (req, res, padId, readOnlyId, type) => { html = null; await TidyHtml.tidy(srcFile); - // send the convert job to the convertor (abiword, libreoffice, ..) + // send the convert job to the converter (abiword, libreoffice, ..) const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`; // Allow plugins to overwrite the convert in export process @@ -106,12 +94,11 @@ const doExport = async (req, res, padId, readOnlyId, type) => { if (result.length > 0) { // console.log("export handled by plugin", destFile); } else { - // @TODO no Promise interface for convertors (yet) - await new Promise((resolve, reject) => { - convertor.convertFile(srcFile, destFile, type, (err) => { - err ? reject('convertFailed') : resolve(); - }); - }); + const converter = + settings.soffice != null ? require('../utils/LibreOffice') + : settings.abiword != null ? require('../utils/Abiword') + : null; + await converter.convertFile(srcFile, destFile, type); } // send the file @@ -128,11 +115,3 @@ const doExport = async (req, res, padId, readOnlyId, type) => { await fsp_unlink(destFile); } }; - -exports.doExport = (req, res, padId, readOnlyId, type) => { - doExport(req, res, padId, readOnlyId, type).catch((err) => { - if (err !== 'stop') { - throw err; - } - }); -}; diff --git a/src/node/handler/ImportHandler.js b/src/node/handler/ImportHandler.js index d97ce91ef..acaaf0927 100644 --- a/src/node/handler/ImportHandler.js +++ b/src/node/handler/ImportHandler.js @@ -55,17 +55,17 @@ const rm = async (path) => { } }; -let convertor = null; +let converter = null; let exportExtension = 'htm'; // load abiword only if it is enabled and if soffice is disabled if (settings.abiword != null && settings.soffice == null) { - convertor = require('../utils/Abiword'); + converter = require('../utils/Abiword'); } // load soffice only if it is enabled if (settings.soffice != null) { - convertor = require('../utils/LibreOffice'); + converter = require('../utils/LibreOffice'); exportExtension = 'html'; } @@ -80,8 +80,8 @@ const doImport = async (req, res, padId) => { // set html in the pad const randNum = Math.floor(Math.random() * 0xFFFFFFFF); - // setting flag for whether to use convertor or not - let useConvertor = (convertor != null); + // setting flag for whether to use converter or not + let useConverter = (converter != null); const form = new formidable.IncomingForm(); form.keepExtensions = true; @@ -170,30 +170,25 @@ const doImport = async (req, res, padId) => { // convert file to html if necessary if (!importHandledByPlugin && !directDatabaseAccess) { if (fileIsTXT) { - // Don't use convertor for text files - useConvertor = false; + // Don't use converter for text files + useConverter = false; } // See https://github.com/ether/etherpad-lite/issues/2572 - if (fileIsHTML || !useConvertor) { - // if no convertor only rename + if (fileIsHTML || !useConverter) { + // if no converter only rename await fs.rename(srcFile, destFile); } else { - // @TODO - no Promise interface for convertors (yet) - await new Promise((resolve, reject) => { - convertor.convertFile(srcFile, destFile, exportExtension, (err) => { - // catch convert errors - if (err) { - logger.warn(`Converting Error: ${err.stack || err}`); - return reject(new ImportError('convertFailed')); - } - resolve(); - }); - }); + try { + await converter.convertFile(srcFile, destFile, exportExtension); + } catch (err) { + logger.warn(`Converting Error: ${err.stack || err}`); + throw new ImportError('convertFailed'); + } } } - if (!useConvertor && !directDatabaseAccess) { + if (!useConverter && !directDatabaseAccess) { // Read the file with no encoding for raw buffer access. const buf = await fs.readFile(destFile); @@ -224,7 +219,7 @@ const doImport = async (req, res, padId) => { // change text of the pad and broadcast the changeset if (!directDatabaseAccess) { - if (importHandledByPlugin || useConvertor || fileIsHTML) { + if (importHandledByPlugin || useConverter || fileIsHTML) { try { await importHtml.setPadHTML(pad, text); } catch (err) { diff --git a/src/node/hooks/express/importexport.js b/src/node/hooks/express/importexport.js index d6f287c6b..ab8d60376 100644 --- a/src/node/hooks/express/importexport.js +++ b/src/node/hooks/express/importexport.js @@ -60,7 +60,7 @@ exports.expressCreateServer = (hookName, args, cb) => { } console.log(`Exporting pad "${req.params.pad}" in ${req.params.type} format`); - exportHandler.doExport(req, res, padId, readOnlyId, req.params.type); + await exportHandler.doExport(req, res, padId, readOnlyId, req.params.type); } })().catch((err) => next(err || new Error(err))); }); diff --git a/src/node/hooks/express/webaccess.js b/src/node/hooks/express/webaccess.js index 51d57ae2e..5ff957a52 100644 --- a/src/node/hooks/express/webaccess.js +++ b/src/node/hooks/express/webaccess.js @@ -10,12 +10,20 @@ const readOnlyManager = require('../../db/ReadOnlyManager'); hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead'; const staticPathsRE = new RegExp(`^/(?:${[ - 'api/.*', + 'api(?:/.*)?', 'favicon\\.ico', + 'ep/pad/connection-diagnostic-info', + 'javascript', 'javascripts/.*', + 'jserror/?', 'locales\\.json', + 'locales/.*', + 'rest/.*', 'pluginfw/.*', + 'robots.txt', 'static/.*', + 'stats/?', + 'tests/frontend(?:/.*)?' ].join('|')})$`); exports.normalizeAuthzLevel = (level) => { diff --git a/src/node/server.js b/src/node/server.js index fc62b4471..a574116eb 100755 --- a/src/node/server.js +++ b/src/node/server.js @@ -46,6 +46,7 @@ const hooks = require('../static/js/pluginfw/hooks'); const pluginDefs = require('../static/js/pluginfw/plugin_defs'); const plugins = require('../static/js/pluginfw/plugins'); const settings = require('./utils/Settings'); +const stats = require('./stats'); const logger = log4js.getLogger('server'); @@ -104,8 +105,6 @@ exports.start = async () => { // Check if Etherpad version is up-to-date UpdateCheck.check(); - // start up stats counting system - const stats = require('./stats'); stats.gauge('memoryUsage', () => process.memoryUsage().rss); stats.gauge('memoryUsageHeap', () => process.memoryUsage().heapUsed); @@ -215,6 +214,7 @@ exports.exit = async (err = null) => { logger.info('Received SIGTERM signal'); err = null; } else if (err != null) { + logger.error(`Metrics at time of fatal error:\n${JSON.stringify(stats.toJSON(), null, 2)}`); logger.error(err.stack || err.toString()); process.exitCode = 1; if (exitCalled) { diff --git a/src/node/utils/Abiword.js b/src/node/utils/Abiword.js index b93646cd5..1ed487ae1 100644 --- a/src/node/utils/Abiword.js +++ b/src/node/utils/Abiword.js @@ -24,111 +24,67 @@ const async = require('async'); const settings = require('./Settings'); const os = require('os'); -let doConvertTask; - // on windows we have to spawn a process for each convertion, // cause the plugin abicommand doesn't exist on this platform if (os.type().indexOf('Windows') > -1) { - let stdoutBuffer = ''; - - doConvertTask = (task, callback) => { - // span an abiword process to perform the conversion - const abiword = spawn(settings.abiword, [`--to=${task.destFile}`, task.srcFile]); - - // delegate the processing of stdout to another function - abiword.stdout.on('data', (data) => { - // add data to buffer - stdoutBuffer += data.toString(); + exports.convertFile = async (srcFile, destFile, type) => { + const abiword = spawn(settings.abiword, [`--to=${destFile}`, srcFile]); + let stdoutBuffer = ''; + abiword.stdout.on('data', (data) => { stdoutBuffer += data.toString(); }); + abiword.stderr.on('data', (data) => { stdoutBuffer += data.toString(); }); + await new Promise((resolve, reject) => { + abiword.on('exit', (code) => { + if (code !== 0) return reject(new Error(`Abiword died with exit code ${code}`)); + if (stdoutBuffer !== '') { + console.log(stdoutBuffer); + } + resolve(); + }); }); - - // append error messages to the buffer - abiword.stderr.on('data', (data) => { - stdoutBuffer += data.toString(); - }); - - // throw exceptions if abiword is dieing - abiword.on('exit', (code) => { - if (code !== 0) { - return callback(`Abiword died with exit code ${code}`); - } - - if (stdoutBuffer !== '') { - console.log(stdoutBuffer); - } - - callback(); - }); - }; - - exports.convertFile = (srcFile, destFile, type, callback) => { - doConvertTask({srcFile, destFile, type}, callback); }; // on unix operating systems, we can start abiword with abicommand and // communicate with it via stdin/stdout // thats much faster, about factor 10 } else { - // spawn the abiword process let abiword; let stdoutCallback = null; const spawnAbiword = () => { abiword = spawn(settings.abiword, ['--plugin', 'AbiCommand']); let stdoutBuffer = ''; let firstPrompt = true; - - // append error messages to the buffer - abiword.stderr.on('data', (data) => { - stdoutBuffer += data.toString(); - }); - - // abiword died, let's restart abiword and return an error with the callback + abiword.stderr.on('data', (data) => { stdoutBuffer += data.toString(); }); abiword.on('exit', (code) => { spawnAbiword(); - stdoutCallback(`Abiword died with exit code ${code}`); + if (stdoutCallback != null) { + stdoutCallback(new Error(`Abiword died with exit code ${code}`)); + stdoutCallback = null; + } }); - - // delegate the processing of stdout to a other function abiword.stdout.on('data', (data) => { - // add data to buffer stdoutBuffer += data.toString(); - // we're searching for the prompt, cause this means everything we need is in the buffer if (stdoutBuffer.search('AbiWord:>') !== -1) { - // filter the feedback message - const err = stdoutBuffer.search('OK') !== -1 ? null : stdoutBuffer; - - // reset the buffer + const err = stdoutBuffer.search('OK') !== -1 ? null : new Error(stdoutBuffer); stdoutBuffer = ''; - - // call the callback with the error message - // skip the first prompt if (stdoutCallback != null && !firstPrompt) { stdoutCallback(err); stdoutCallback = null; } - firstPrompt = false; } }); }; spawnAbiword(); - doConvertTask = (task, callback) => { + const queue = async.queue((task, callback) => { abiword.stdin.write(`convert ${task.srcFile} ${task.destFile} ${task.type}\n`); - // create a callback that calls the task callback and the caller callback stdoutCallback = (err) => { - callback(); - console.log('queue continue'); - try { - task.callback(err); - } catch (e) { - console.error('Abiword File failed to convert', e); - } + if (err != null) console.error('Abiword File failed to convert', err); + callback(err); }; - }; + }, 1); - // Queue with the converts we have to do - const queue = async.queue(doConvertTask, 1); - exports.convertFile = (srcFile, destFile, type, callback) => { - queue.push({srcFile, destFile, type, callback}); + exports.convertFile = async (srcFile, destFile, type) => { + await queue.pushAsync({srcFile, destFile, type}); }; } diff --git a/src/node/utils/LibreOffice.js b/src/node/utils/LibreOffice.js index 914d79a4b..276bc3003 100644 --- a/src/node/utils/LibreOffice.js +++ b/src/node/utils/LibreOffice.js @@ -18,7 +18,7 @@ */ const async = require('async'); -const fs = require('fs'); +const fs = require('fs').promises; const log4js = require('log4js'); const os = require('os'); const path = require('path'); @@ -27,76 +27,50 @@ const spawn = require('child_process').spawn; const libreOfficeLogger = log4js.getLogger('LibreOffice'); -const doConvertTask = (task, callback) => { +const doConvertTask = async (task) => { const tmpDir = os.tmpdir(); - async.series([ - /* - * use LibreOffice to convert task.srcFile to another format, given in - * task.type - */ - (callback) => { - libreOfficeLogger.debug( - `Converting ${task.srcFile} to format ${task.type}. The result will be put in ${tmpDir}` - ); - const soffice = spawn(settings.soffice, [ - '--headless', - '--invisible', - '--nologo', - '--nolockcheck', - '--writer', - '--convert-to', - task.type, - task.srcFile, - '--outdir', - tmpDir, - ]); - // Soffice/libreoffice is buggy and often hangs. - // To remedy this we kill the spawned process after a while. - const hangTimeout = setTimeout(() => { - soffice.stdin.pause(); // required to kill hanging threads - soffice.kill(); - }, 120000); - - let stdoutBuffer = ''; - - // Delegate the processing of stdout to another function - soffice.stdout.on('data', (data) => { - stdoutBuffer += data.toString(); - }); - - // Append error messages to the buffer - soffice.stderr.on('data', (data) => { - stdoutBuffer += data.toString(); - }); - - soffice.on('exit', (code) => { - clearTimeout(hangTimeout); - if (code !== 0) { - // Throw an exception if libreoffice failed - return callback(`LibreOffice died with exit code ${code} and message: ${stdoutBuffer}`); - } - - // if LibreOffice exited succesfully, go on with processing - callback(); - }); - }, - - // Move the converted file to the correct place - (callback) => { - const filename = path.basename(task.srcFile); - const sourceFile = `${filename.substr(0, filename.lastIndexOf('.'))}.${task.fileExtension}`; - const sourcePath = path.join(tmpDir, sourceFile); - libreOfficeLogger.debug(`Renaming ${sourcePath} to ${task.destFile}`); - fs.rename(sourcePath, task.destFile, callback); - }, - ], (err) => { - // Invoke the callback for the local queue - callback(); - - // Invoke the callback for the task - task.callback(err); + libreOfficeLogger.debug( + `Converting ${task.srcFile} to format ${task.type}. The result will be put in ${tmpDir}`); + const soffice = spawn(settings.soffice, [ + '--headless', + '--invisible', + '--nologo', + '--nolockcheck', + '--writer', + '--convert-to', + task.type, + task.srcFile, + '--outdir', + tmpDir, + ]); + // Soffice/libreoffice is buggy and often hangs. + // To remedy this we kill the spawned process after a while. + const hangTimeout = setTimeout(() => { + soffice.stdin.pause(); // required to kill hanging threads + soffice.kill(); + }, 120000); + let stdoutBuffer = ''; + soffice.stdout.on('data', (data) => { stdoutBuffer += data.toString(); }); + soffice.stderr.on('data', (data) => { stdoutBuffer += data.toString(); }); + await new Promise((resolve, reject) => { + soffice.on('exit', (code) => { + clearTimeout(hangTimeout); + if (code !== 0) { + const err = + new Error(`LibreOffice died with exit code ${code} and message: ${stdoutBuffer}`); + libreOfficeLogger.error(err.stack); + return reject(err); + } + resolve(); + }); }); + + const filename = path.basename(task.srcFile); + const sourceFile = `${filename.substr(0, filename.lastIndexOf('.'))}.${task.fileExtension}`; + const sourcePath = path.join(tmpDir, sourceFile); + libreOfficeLogger.debug(`Renaming ${sourcePath} to ${task.destFile}`); + await fs.rename(sourcePath, task.destFile); }; // Conversion tasks will be queued up, so we don't overload the system @@ -110,7 +84,7 @@ const queue = async.queue(doConvertTask, 1); * @param {String} type The type to convert into * @param {Function} callback Standard callback function */ -exports.convertFile = (srcFile, destFile, type, callback) => { +exports.convertFile = async (srcFile, destFile, type) => { // Used for the moving of the file, not the conversion const fileExtension = type; @@ -129,23 +103,10 @@ exports.convertFile = (srcFile, destFile, type, callback) => { // we need to convert to odt first, then to doc // to avoid `Error: no export filter for /tmp/xxxx.doc` error if (type === 'doc') { - queue.push({ - srcFile, - destFile: destFile.replace(/\.doc$/, '.odt'), - type: 'odt', - callback: () => { - queue.push( - { - srcFile: srcFile.replace(/\.html$/, '.odt'), - destFile, - type, - callback, - fileExtension, - } - ); - }, - }); + const intermediateFile = destFile.replace(/\.doc$/, '.odt'); + await queue.pushAsync({srcFile, destFile: intermediateFile, type: 'odt', fileExtension: 'odt'}); + await queue.pushAsync({srcFile: intermediateFile, destFile, type, fileExtension}); } else { - queue.push({srcFile, destFile, type, callback, fileExtension}); + await queue.pushAsync({srcFile, destFile, type, fileExtension}); } }; diff --git a/src/node/utils/tar.json b/src/node/utils/tar.json index 8fcc45aa2..7f1fe0135 100644 --- a/src/node/utils/tar.json +++ b/src/node/utils/tar.json @@ -14,6 +14,7 @@ , "pad_automatic_reconnect.js" , "ace.js" , "collab_client.js" + , "cssmanager.js" , "pad_userlist.js" , "pad_impexp.js" , "pad_savedrevs.js" @@ -61,7 +62,6 @@ , "Changeset.js" , "ChangesetUtils.js" , "skiplist.js" - , "cssmanager.js" , "colorutils.js" , "undomodule.js" , "$unorm/lib/unorm.js" diff --git a/src/package-lock.json b/src/package-lock.json index 14421f869..409d3a332 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,6 +1,6 @@ { "name": "ep_etherpad-lite", - "version": "1.8.12", + "version": "1.8.13", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -15,9 +15,9 @@ } }, "@azure/abort-controller": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.0.3.tgz", - "integrity": "sha512-kCibMwqffnwlw3c+e879rCE1Am1I2BfhjOeO54XNA8i/cEuzktnBQbTrzh67XwibHO05YuNgZzSWy9ocVfFAGw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.0.4.tgz", + "integrity": "sha512-lNUmDRVGpanCsiUN3NWxFTdwmdFI53xwhkTFfHDGTYk46ca7Ind3nanJc+U6Zj9Tv+9nTCWRBscWEW1DyKOpTw==", "requires": { "tslib": "^2.0.0" }, @@ -287,9 +287,9 @@ "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==" }, "@types/node": { - "version": "14.14.31", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.31.tgz", - "integrity": "sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==" + "version": "14.14.34", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.34.tgz", + "integrity": "sha512-dBPaxocOK6UVyvhbnpFIj2W+S+1cBTkHQbFQfeeJhoKFbzYcVUGHvddeWPSucKATb3F0+pgDq0i6ghEaZjsugA==" }, "@types/node-fetch": { "version": "2.5.8", @@ -2051,9 +2051,9 @@ } }, "etherpad-require-kernel": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/etherpad-require-kernel/-/etherpad-require-kernel-1.0.9.tgz", - "integrity": "sha1-7Y8E6f0szsOgBVu20t/p2ZkS5+I=" + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/etherpad-require-kernel/-/etherpad-require-kernel-1.0.11.tgz", + "integrity": "sha512-I03bkNiBMrcsJRSl0IqotUU70s9v6VISrITj/cQgAoVQSoRFbV/NUn2fPIF4LskysTpmwlmwJqgfL2FZpAtxEw==" }, "etherpad-yajsml": { "version": "0.0.4", @@ -2110,9 +2110,9 @@ } }, "express-rate-limit": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.2.5.tgz", - "integrity": "sha512-fv9mf4hWRKZHVlY8ChVNYnGxa49m0zQ6CrJxNiXe2IjJPqicrqoA/JOyBbvs4ufSSLZ6NTzhtgEyLcdfbe+Q6Q==" + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.2.6.tgz", + "integrity": "sha512-nE96xaxGfxiS5jP3tD3kIW1Jg9yQgX0rXCs3rCkZtmbWHEGyotwaezkLj7bnB41Z0uaOLM8W4AX6qHao4IZ2YA==" }, "express-session": { "version": "1.17.1", @@ -7689,9 +7689,9 @@ "optional": true }, "simple-git": { - "version": "2.35.2", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-2.35.2.tgz", - "integrity": "sha512-UjOKsrz92Bx7z00Wla5V6qLSf5X2XSp0sL2gzKw1Bh7iJfDPDaU7gK5avIup0yo1/sMOSUMQer2b9GcnF6nmTQ==", + "version": "2.36.2", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-2.36.2.tgz", + "integrity": "sha512-orBEf65GfSiQMsYedbJXSiRNnIRvhbeE5rrxZuEimCpWxDZOav0KLy2IEiPi1YJCF+zaC2quiJF8A4TsxI9/tw==", "requires": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", @@ -8436,13 +8436,12 @@ } }, "ueberdb2": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/ueberdb2/-/ueberdb2-1.3.2.tgz", - "integrity": "sha512-7Ub5jDsIS+qjjsNV7yp1CHXHVe2K9ZUpwaHi9BZf3ai0DxtuHOfMada1wxL6iyEjwYXh/Nsu80iyId51wHFf4A==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/ueberdb2/-/ueberdb2-1.4.4.tgz", + "integrity": "sha512-hcexgTdMa6gMquv5r6rOBsr76awMlqAjQiMMJ72qrzuatLYJ6D1EQTK/Jqo4nOD/jklXHM2yFw1mNcHsrlEzrw==", "requires": { "async": "^3.2.0", "cassandra-driver": "^4.5.1", - "channels": "0.0.4", "dirty": "^1.1.1", "elasticsearch": "^16.7.1", "mongodb": "^3.6.3", @@ -8774,9 +8773,9 @@ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" }, "xmldom": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.4.0.tgz", - "integrity": "sha512-2E93k08T30Ugs+34HBSTQLVtpi6mCddaY8uO+pMNk1pqSjV5vElzn4mmh6KLxN3hki8rNcHSYzILoh3TEWORvA==" + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.5.0.tgz", + "integrity": "sha512-Foaj5FXVzgn7xFzsKeNIde9g6aFBxTPi37iwsno8QvApmtg7KYrr+OPyRHcJF7dud2a5nGRBXK3n0dL62Gf7PA==" }, "xmlhttprequest-ssl": { "version": "1.5.5", diff --git a/src/package.json b/src/package.json index ada568d95..853065a22 100644 --- a/src/package.json +++ b/src/package.json @@ -38,10 +38,10 @@ "cookie-parser": "1.4.5", "cross-spawn": "^7.0.3", "ejs": "^3.1.6", - "etherpad-require-kernel": "1.0.9", + "etherpad-require-kernel": "1.0.11", "etherpad-yajsml": "0.0.4", "express": "4.17.1", - "express-rate-limit": "5.2.5", + "express-rate-limit": "5.2.6", "express-session": "1.17.1", "find-root": "1.1.0", "formidable": "1.2.2", @@ -69,7 +69,7 @@ "threads": "^1.4.0", "tiny-worker": "^2.3.0", "tinycon": "0.6.8", - "ueberdb2": "^1.3.2", + "ueberdb2": "^1.4.4", "underscore": "1.12.0", "unorm": "1.6.0", "wtfnode": "^0.8.4" @@ -246,6 +246,6 @@ "test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs", "test-container": "mocha --timeout 5000 tests/container/specs/api" }, - "version": "1.8.12", + "version": "1.8.13", "license": "Apache-2.0" } diff --git a/src/static/css/iframe_editor.css b/src/static/css/iframe_editor.css index 2a63c3802..912174411 100644 --- a/src/static/css/iframe_editor.css +++ b/src/static/css/iframe_editor.css @@ -4,8 +4,7 @@ @import url('./lists_and_indents.css'); -html.inner-editor { - height: auto !important; +html.outer-editor, html.inner-editor { background-color: transparent !important; } #outerdocbody { @@ -38,6 +37,11 @@ html.inner-editor { white-space: normal; word-wrap: break-word; overflow-wrap: break-word; + /* + * Make the contenteditable area at least as big as the screen so that mobile + * users can tap anywhere to bring up their device's keyboard. + */ + min-height: 100vh; } #innerdocbody, #sidediv { diff --git a/src/static/css/pad/layout.css b/src/static/css/pad/layout.css index e5b79c268..7f77ca58d 100644 --- a/src/static/css/pad/layout.css +++ b/src/static/css/pad/layout.css @@ -1,11 +1,14 @@ html, body { width: 100%; - height: 100%; + height: auto; margin: 0; padding: 0; } -html:not(.inner-editor), html:not(.inner-editor) body { + +/* used in pad and timeslider */ +html.pad, html.pad body { overflow: hidden; + height: 100%; } body { display: flex; diff --git a/src/static/js/Changeset.js b/src/static/js/Changeset.js index b47979c19..8c3627b04 100644 --- a/src/static/js/Changeset.js +++ b/src/static/js/Changeset.js @@ -50,10 +50,9 @@ exports.error = (msg) => { * @param b {boolean} assertion condition * @param msgParts {string} error to be passed if it fails */ -exports.assert = (b, msgParts) => { +exports.assert = (b, ...msgParts) => { if (!b) { - const msg = Array.prototype.slice.call(arguments, 1).join(''); - exports.error(`Failed assertion: ${msg}`); + exports.error(`Failed assertion: ${msgParts.join('')}`); } }; diff --git a/src/static/js/ace.js b/src/static/js/ace.js index 44556dd0f..059bac76e 100644 --- a/src/static/js/ace.js +++ b/src/static/js/ace.js @@ -25,22 +25,69 @@ // requires: undefined const hooks = require('./pluginfw/hooks'); +const makeCSSManager = require('./cssmanager').makeCSSManager; const pluginUtils = require('./pluginfw/shared'); const debugLog = (...args) => {}; -window.debugLog = debugLog; // The inner and outer iframe's locations are about:blank, so relative URLs are relative to that. // Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari // errors out unless given an absolute URL for a JavaScript-created element. const absUrl = (url) => new URL(url, window.location.href).href; -const scriptTag = - (source) => ``; +const eventFired = async (obj, event, cleanups = [], predicate = () => true) => { + if (typeof cleanups === 'function') { + predicate = cleanups; + cleanups = []; + } + await new Promise((resolve, reject) => { + let cleanup; + const successCb = () => { + if (!predicate()) return; + debugLog(`Ace2Editor.init() ${event} event on`, obj); + cleanup(); + resolve(); + }; + const errorCb = () => { + const err = new Error(`Ace2Editor.init() error event while waiting for ${event} event`); + debugLog(`${err} on object`, obj); + cleanup(); + reject(err); + }; + cleanup = () => { + cleanup = () => {}; + obj.removeEventListener(event, successCb); + obj.removeEventListener('error', errorCb); + }; + cleanups.push(cleanup); + obj.addEventListener(event, successCb); + obj.addEventListener('error', errorCb); + }); +}; + +// Resolves when the frame's document is ready to be mutated. Browsers seem to be quirky about +// iframe ready events so this function throws the kitchen sink at the problem. Maybe one day we'll +// find a concise general solution. +const frameReady = async (frame) => { + // Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace + // the document object after the frame is first created for some reason. ¯\_(ツ)_/¯ + const doc = () => frame.contentDocument; + const cleanups = []; + try { + await Promise.race([ + eventFired(frame, 'load', cleanups), + eventFired(frame.contentWindow, 'load', cleanups), + eventFired(doc(), 'load', cleanups), + eventFired(doc(), 'DOMContentLoaded', cleanups), + eventFired(doc(), 'readystatechange', cleanups, () => doc.readyState === 'complete'), + ]); + } finally { + for (const cleanup of cleanups) cleanup(); + } +}; const Ace2Editor = function () { let info = {editor: this}; - window.ace2EditorInfo = info; // Make it accessible to iframes. let loaded = false; let actionsPendingInit = []; @@ -109,16 +156,19 @@ const Ace2Editor = function () { // returns array of {error: , time: +new Date()} this.getUnhandledErrors = () => loaded ? info.ace_getUnhandledErrors() : []; - const pushStyleTagsFor = (buffer, files) => { + const addStyleTagsFor = (doc, files) => { for (const file of files) { - buffer.push(``); + const link = doc.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = absUrl(encodeURI(file)); + doc.head.appendChild(link); } }; this.destroy = pendingInit(() => { info.ace_dispose(); info.frame.parentNode.removeChild(info.frame); - delete window.ace2EditorInfo; info = null; // prevent IE 6 closure memory leaks }); @@ -127,7 +177,7 @@ const Ace2Editor = function () { this.importText(initialCode); const includedCSS = [ - '../static/css/iframe_editor.css', + `../static/css/iframe_editor.css?v=${clientVars.randomVersionString}`, `../static/css/pad.css?v=${clientVars.randomVersionString}`, ...hooks.callAll('aceEditorCSS').map( // Allow urls to external CSS - http(s):// and //some/path.css @@ -135,110 +185,135 @@ const Ace2Editor = function () { `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`, ]; - const doctype = ''; + const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== ''); - const iframeHTML = []; - - iframeHTML.push(doctype); - iframeHTML.push(``); - pushStyleTagsFor(iframeHTML, includedCSS); - const requireKernelUrl = - absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`); - iframeHTML.push(``); - // Pre-fetch modules to improve load performance. - for (const module of ['ace2_inner', 'ace2_common']) { - const url = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` + - `?callback=require.define&v=${clientVars.randomVersionString}`); - iframeHTML.push(``); - } - - iframeHTML.push(scriptTag(`(async () => { - parent.parent.debugLog('Ace2Editor.init() inner frame ready'); - const require = window.require; - require.setRootURI(${JSON.stringify(absUrl('../javascripts/src'))}); - require.setLibraryURI(${JSON.stringify(absUrl('../javascripts/lib'))}); - require.setGlobalKeyPath('require'); - - // intentially moved before requiring client_plugins to save a 307 - window.Ace2Inner = require('ep_etherpad-lite/static/js/ace2_inner'); - window.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins'); - window.plugins.adoptPluginsFromAncestorsOf(window); - - window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; - - parent.parent.debugLog('Ace2Editor.init() waiting for plugins'); - await new Promise((resolve, reject) => window.plugins.ensure( - (err) => err != null ? reject(err) : resolve())); - parent.parent.debugLog('Ace2Editor.init() waiting for Ace2Inner.init()'); - const editorInfo = parent.parent.ace2EditorInfo; - await new Promise((resolve, reject) => window.Ace2Inner.init( - editorInfo, (err) => err != null ? reject(err) : resolve())); - parent.parent.debugLog('Ace2Editor.init() Ace2Inner.init() returned'); - editorInfo.onEditorReady(); - })();`)); - - iframeHTML.push(''); - - hooks.callAll('aceInitInnerdocbodyHead', { - iframeHTML, - }); - - iframeHTML.push(' '); - - const outerScript = `(async () => { - await new Promise((resolve) => { window.onload = () => resolve(); }); - parent.debugLog('Ace2Editor.init() outer frame ready'); - window.onload = null; - await new Promise((resolve) => setTimeout(resolve, 0)); - const iframe = document.createElement('iframe'); - iframe.name = 'ace_inner'; - iframe.title = 'pad'; - iframe.scrolling = 'no'; - iframe.frameBorder = 0; - iframe.allowTransparency = true; // for IE - iframe.ace_outerWin = window; - document.body.insertBefore(iframe, document.body.firstChild); - const doc = iframe.contentWindow.document; - doc.open(); - doc.write(${JSON.stringify(iframeHTML.join('\n'))}); - doc.close(); - parent.debugLog('Ace2Editor.init() waiting for inner frame'); - })();`; - - const outerHTML = - [doctype, ``]; - pushStyleTagsFor(outerHTML, includedCSS); - - // bizarrely, in FF2, a file with no "external" dependencies won't finish loading properly - // (throbs busy while typing) - const pluginNames = pluginUtils.clientPluginNames(); - outerHTML.push( - '', - '', - scriptTag(outerScript), - '', - '', - '
', - '
x
', - ''); - - const outerFrame = document.createElement('IFRAME'); + const outerFrame = document.createElement('iframe'); outerFrame.name = 'ace_outer'; outerFrame.frameBorder = 0; // for IE outerFrame.title = 'Ether'; + // Some browsers do strange things unless the iframe has a src or srcdoc property: + // - Firefox replaces the frame's contentWindow.document object with a different object after + // the frame is created. This can be worked around by waiting for the window's load event + // before continuing. + // - Chrome never fires any events on the frame or document. Eventually the document's + // readyState becomes 'complete' even though it never fires a readystatechange event. + // - Safari behaves like Chrome. + outerFrame.srcdoc = ''; info.frame = outerFrame; document.getElementById(containerId).appendChild(outerFrame); - - const editorDocument = outerFrame.contentWindow.document; + const outerWindow = outerFrame.contentWindow; debugLog('Ace2Editor.init() waiting for outer frame'); - await new Promise((resolve, reject) => { - info.onEditorReady = (err) => err != null ? reject(err) : resolve(); - editorDocument.open(); - editorDocument.write(outerHTML.join('')); - editorDocument.close(); + await frameReady(outerFrame); + debugLog('Ace2Editor.init() outer frame ready'); + + // Firefox might replace the outerWindow.document object after iframe creation so this variable + // is assigned after the Window's load event. + const outerDocument = outerWindow.document; + + // tag + outerDocument.documentElement.classList.add('outer-editor', 'outerdoc', ...skinVariants); + + // tag + addStyleTagsFor(outerDocument, includedCSS); + const outerStyle = outerDocument.createElement('style'); + outerStyle.type = 'text/css'; + outerStyle.title = 'dynamicsyntax'; + outerDocument.head.appendChild(outerStyle); + + // tag + outerDocument.body.id = 'outerdocbody'; + outerDocument.body.classList.add('outerdocbody', ...pluginUtils.clientPluginNames()); + const sideDiv = outerDocument.createElement('div'); + sideDiv.id = 'sidediv'; + sideDiv.classList.add('sidediv'); + outerDocument.body.appendChild(sideDiv); + const lineMetricsDiv = outerDocument.createElement('div'); + lineMetricsDiv.id = 'linemetricsdiv'; + lineMetricsDiv.appendChild(outerDocument.createTextNode('x')); + outerDocument.body.appendChild(lineMetricsDiv); + + const innerFrame = outerDocument.createElement('iframe'); + innerFrame.name = 'ace_inner'; + innerFrame.title = 'pad'; + innerFrame.scrolling = 'no'; + innerFrame.frameBorder = 0; + innerFrame.allowTransparency = true; // for IE + // The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above + // outerFrame.srcdoc. + innerFrame.srcdoc = ''; + innerFrame.ace_outerWin = outerWindow; + outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild); + const innerWindow = innerFrame.contentWindow; + + debugLog('Ace2Editor.init() waiting for inner frame'); + await frameReady(innerFrame); + debugLog('Ace2Editor.init() inner frame ready'); + + // Firefox might replace the innerWindow.document object after iframe creation so this variable + // is assigned after the Window's load event. + const innerDocument = innerWindow.document; + + // tag + innerDocument.documentElement.classList.add('inner-editor', ...skinVariants); + + // tag + addStyleTagsFor(innerDocument, includedCSS); + const requireKernel = innerDocument.createElement('script'); + requireKernel.type = 'text/javascript'; + requireKernel.src = + absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`); + innerDocument.head.appendChild(requireKernel); + // Pre-fetch modules to improve load performance. + for (const module of ['ace2_inner', 'ace2_common']) { + const script = innerDocument.createElement('script'); + script.type = 'text/javascript'; + script.src = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` + + `?callback=require.define&v=${clientVars.randomVersionString}`); + innerDocument.head.appendChild(script); + } + const innerStyle = innerDocument.createElement('style'); + innerStyle.type = 'text/css'; + innerStyle.title = 'dynamicsyntax'; + innerDocument.head.appendChild(innerStyle); + const headLines = []; + hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines}); + const tmp = innerDocument.createElement('div'); + tmp.innerHTML = headLines.join('\n'); + while (tmp.firstChild) innerDocument.head.appendChild(tmp.firstChild); + + // tag + innerDocument.body.id = 'innerdocbody'; + innerDocument.body.classList.add('innerdocbody'); + innerDocument.body.setAttribute('role', 'application'); + 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'); + const require = innerWindow.require; + require.setRootURI(absUrl('../javascripts/src')); + require.setLibraryURI(absUrl('../javascripts/lib')); + require.setGlobalKeyPath('require'); + + // intentially moved before requiring client_plugins to save a 307 + innerWindow.Ace2Inner = require('ep_etherpad-lite/static/js/ace2_inner'); + innerWindow.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins'); + innerWindow.plugins.adoptPluginsFromAncestorsOf(innerWindow); + + innerWindow.$ = innerWindow.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; + + debugLog('Ace2Editor.init() waiting for plugins'); + await new Promise((resolve, reject) => innerWindow.plugins.ensure( + (err) => err != null ? reject(err) : resolve())); + debugLog('Ace2Editor.init() waiting for Ace2Inner.init()'); + await innerWindow.Ace2Inner.init(info, { + inner: makeCSSManager(innerStyle.sheet), + outer: makeCSSManager(outerStyle.sheet), + parent: makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet), }); + debugLog('Ace2Editor.init() Ace2Inner.init() returned'); loaded = true; doActionsPendingInit(); debugLog('Ace2Editor.init() done'); diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js index 8fef627b5..d57773196 100644 --- a/src/static/js/ace2_inner.js +++ b/src/static/js/ace2_inner.js @@ -30,11 +30,10 @@ const htmlPrettyEscape = Ace2Common.htmlPrettyEscape; const noop = Ace2Common.noop; const hooks = require('./pluginfw/hooks'); -function Ace2Inner(editorInfo) { +function Ace2Inner(editorInfo, cssManagers) { const makeChangesetTracker = require('./changesettracker').makeChangesetTracker; const colorutils = require('./colorutils').colorutils; const makeContentCollector = require('./contentcollector').makeContentCollector; - const makeCSSManager = require('./cssmanager').makeCSSManager; const domline = require('./domline').domline; const AttribPool = require('./AttributePool'); const Changeset = require('./Changeset'); @@ -157,10 +156,6 @@ function Ace2Inner(editorInfo) { const scheduler = parent; // hack for opera required - let dynamicCSS = null; - let outerDynamicCSS = null; - let parentDynamicCSS = null; - const performDocumentReplaceRange = (start, end, newText) => { if (start === undefined) start = rep.selStart; if (end === undefined) end = rep.selEnd; @@ -181,12 +176,6 @@ function Ace2Inner(editorInfo) { performDocumentApplyChangeset(cs); }; - const initDynamicCSS = () => { - dynamicCSS = makeCSSManager('dynamicsyntax'); - outerDynamicCSS = makeCSSManager('dynamicsyntax', 'outer'); - parentDynamicCSS = makeCSSManager('dynamicsyntax', 'parent'); - }; - const changesetTracker = makeChangesetTracker(scheduler, rep.apool, { withCallbacks: (operationName, f) => { inCallStackIfNecessary(operationName, () => { @@ -214,15 +203,12 @@ function Ace2Inner(editorInfo) { editorInfo.ace_getAuthorInfos = getAuthorInfos; const setAuthorStyle = (author, info) => { - if (!dynamicCSS) { - return; - } const authorSelector = getAuthorColorClassSelector(getAuthorClassName(author)); const authorStyleSet = hooks.callAll('aceSetAuthorStyle', { - dynamicCSS, - parentDynamicCSS, - outerDynamicCSS, + dynamicCSS: cssManagers.inner, + outerDynamicCSS: cssManagers.outer, + parentDynamicCSS: cssManagers.parent, info, author, authorSelector, @@ -234,16 +220,16 @@ function Ace2Inner(editorInfo) { } if (!info) { - dynamicCSS.removeSelectorStyle(authorSelector); - parentDynamicCSS.removeSelectorStyle(authorSelector); + cssManagers.inner.removeSelectorStyle(authorSelector); + cssManagers.parent.removeSelectorStyle(authorSelector); } else if (info.bgcolor) { let bgcolor = info.bgcolor; if ((typeof info.fade) === 'number') { bgcolor = fadeColor(bgcolor, info.fade); } - const authorStyle = dynamicCSS.selectorStyle(authorSelector); - const parentAuthorStyle = parentDynamicCSS.selectorStyle(authorSelector); + const authorStyle = cssManagers.inner.selectorStyle(authorSelector); + const parentAuthorStyle = cssManagers.parent.selectorStyle(authorSelector); // author color authorStyle.backgroundColor = bgcolor; @@ -3895,44 +3881,39 @@ function Ace2Inner(editorInfo) { editorInfo.ace_performDocumentApplyAttributesToRange = (...args) => documentAttributeManager.setAttributesOnRange(...args); - this.init = (cb) => { - $(document).ready(() => { - doc = document; // defined as a var in scope outside - inCallStack('setup', () => { - const body = doc.getElementById('innerdocbody'); - root = body; // defined as a var in scope outside - if (browser.firefox) $(root).addClass('mozilla'); - if (browser.safari) $(root).addClass('safari'); - root.classList.toggle('authorColors', true); - root.classList.toggle('doesWrap', doesWrap); + this.init = async () => { + await $.ready; + doc = document; // defined as a var in scope outside + inCallStack('setup', () => { + const body = doc.getElementById('innerdocbody'); + root = body; // defined as a var in scope outside + if (browser.firefox) $(root).addClass('mozilla'); + if (browser.safari) $(root).addClass('safari'); + root.classList.toggle('authorColors', true); + root.classList.toggle('doesWrap', doesWrap); - initDynamicCSS(); + enforceEditability(); - enforceEditability(); + // set up dom and rep + while (root.firstChild) root.removeChild(root.firstChild); + const oneEntry = createDomLineEntry(''); + doRepLineSplice(0, rep.lines.length(), [oneEntry]); + insertDomLines(null, [oneEntry.domInfo]); + rep.alines = Changeset.splitAttributionLines( + Changeset.makeAttribution('\n'), '\n'); - // set up dom and rep - while (root.firstChild) root.removeChild(root.firstChild); - const oneEntry = createDomLineEntry(''); - doRepLineSplice(0, rep.lines.length(), [oneEntry]); - insertDomLines(null, [oneEntry.domInfo]); - rep.alines = Changeset.splitAttributionLines( - Changeset.makeAttribution('\n'), '\n'); + bindTheEventHandlers(); + }); - bindTheEventHandlers(); - }); - - hooks.callAll('aceInitialized', { - editorInfo, - rep, - documentAttributeManager, - }); - - scheduler.setTimeout(cb, 0); + hooks.callAll('aceInitialized', { + editorInfo, + rep, + documentAttributeManager, }); }; } -exports.init = (editorInfo, cb) => { - const editor = new Ace2Inner(editorInfo); - editor.init(cb); +exports.init = async (editorInfo, cssManagers) => { + const editor = new Ace2Inner(editorInfo, cssManagers); + await editor.init(); }; diff --git a/src/static/js/broadcast.js b/src/static/js/broadcast.js index 26370f6b5..909b6a085 100644 --- a/src/static/js/broadcast.js +++ b/src/static/js/broadcast.js @@ -468,14 +468,14 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro BroadcastSlider.onSlider(goToRevisionIfEnabled); - const dynamicCSS = makeCSSManager('dynamicsyntax'); + const dynamicCSS = makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet); const authorData = {}; const receiveAuthorData = (newAuthorData) => { for (const [author, data] of Object.entries(newAuthorData)) { const bgcolor = typeof data.colorId === 'number' ? clientVars.colorPalette[data.colorId] : data.colorId; - if (bgcolor && dynamicCSS) { + if (bgcolor) { const selector = dynamicCSS.selectorStyle(`.${linestylefilter.getAuthorClassName(author)}`); selector.backgroundColor = bgcolor; selector.color = (colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5) diff --git a/src/static/js/cssmanager.js b/src/static/js/cssmanager.js index 0fcdad403..5bf2adb30 100644 --- a/src/static/js/cssmanager.js +++ b/src/static/js/cssmanager.js @@ -22,37 +22,7 @@ * limitations under the License. */ -const makeCSSManager = (emptyStylesheetTitle, doc) => { - if (doc === true) { - doc = 'parent'; - } else if (!doc) { - doc = 'inner'; - } - - const getSheetByTitle = (title) => { - let win; - if (doc === 'parent') { - win = window.parent.parent; - } else if (doc === 'inner') { - win = window; - } else if (doc === 'outer') { - win = window.parent; - } else { - throw new Error('Unknown dynamic style container'); - } - const allSheets = win.document.styleSheets; - - for (let i = 0; i < allSheets.length; i++) { - const s = allSheets[i]; - if (s.title === title) { - return s; - } - } - return null; - }; - - const browserSheet = getSheetByTitle(emptyStylesheetTitle); - +exports.makeCSSManager = (browserSheet) => { const browserRules = () => (browserSheet.cssRules || browserSheet.rules); const browserDeleteRule = (i) => { @@ -100,5 +70,3 @@ const makeCSSManager = (emptyStylesheetTitle, doc) => { info: () => `${selectorList.length}:${browserRules().length}`, }; }; - -exports.makeCSSManager = makeCSSManager; diff --git a/src/static/js/pad.js b/src/static/js/pad.js index 77a37cd1f..485a4b2fe 100644 --- a/src/static/js/pad.js +++ b/src/static/js/pad.js @@ -501,7 +501,7 @@ const pad = { // order of inits is important here: padimpexp.init(this); padsavedrevs.init(this); - padeditor.init(postAceInit, pad.padOptions.view || {}, this); + padeditor.init(pad.padOptions.view || {}, this).then(postAceInit); paduserlist.init(pad.myUserInfo, this); padconnectionstatus.init(); padmodals.init(this); diff --git a/src/static/js/pad_editor.js b/src/static/js/pad_editor.js index 75481fe5e..d8c3ae5ac 100644 --- a/src/static/js/pad_editor.js +++ b/src/static/js/pad_editor.js @@ -34,30 +34,20 @@ const padeditor = (() => { ace: null, // this is accessed directly from other files viewZoom: 100, - init: (readyFunc, initialViewOptions, _pad) => { + init: async (initialViewOptions, _pad) => { Ace2Editor = require('./ace').Ace2Editor; pad = _pad; settings = pad.settings; - - const aceReady = () => { - $('#editorloadingbox').hide(); - if (readyFunc) { - readyFunc(); - - // Listen for clicks on sidediv items - const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody'); - $outerdoc.find('#sidedivinner').on('click', 'div', function () { - const targetLineNumber = $(this).index() + 1; - window.location.hash = `L${targetLineNumber}`; - }); - - exports.focusOnLine(self.ace); - } - }; - self.ace = new Ace2Editor(); - self.ace.init('editorcontainer', '').then( - () => aceReady(), (err) => { throw err || new Error(err); }); + await self.ace.init('editorcontainer', ''); + $('#editorloadingbox').hide(); + // Listen for clicks on sidediv items + const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody'); + $outerdoc.find('#sidedivinner').on('click', 'div', function () { + const targetLineNumber = $(this).index() + 1; + window.location.hash = `L${targetLineNumber}`; + }); + exports.focusOnLine(self.ace); self.ace.setProperty('wraps', true); if (pad.getIsDebugEnabled()) { self.ace.setProperty('dmesg', pad.dmesg); diff --git a/src/static/skins/no-skin/pad.css b/src/static/skins/no-skin/pad.css index e69de29bb..a9eae81f2 100644 --- a/src/static/skins/no-skin/pad.css +++ b/src/static/skins/no-skin/pad.css @@ -0,0 +1 @@ +/* intentionally empty */ diff --git a/src/templates/pad.html b/src/templates/pad.html index 26243806a..7bf2346f9 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -7,7 +7,7 @@ <% e.begin_block("htmlHead"); %> - + <% e.end_block(); %> <%=settings.title%> diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html index c577f3013..b3fd3d006 100644 --- a/src/templates/timeslider.html +++ b/src/templates/timeslider.html @@ -3,7 +3,7 @@ , langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs %> - + <%=settings.title%> Timeslider