diff --git a/src/static/js/ace.js b/src/static/js/ace.js index 8613d9e37..59bf0f863 100644 --- a/src/static/js/ace.js +++ b/src/static/js/ace.js @@ -32,12 +32,78 @@ const pluginUtils = require('./pluginfw/shared'); // 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; + cleanup(); + resolve(); + }; + const errorCb = () => { + const err = new Error(`Ace2Editor.init() error event while waiting for ${event} event`); + cleanup(); + reject(err); + }; + cleanup = () => { + cleanup = () => {}; + obj.removeEventListener(event, successCb); + obj.removeEventListener('error', errorCb); + }; + cleanups.push(cleanup); + obj.addEventListener(event, successCb); + obj.addEventListener('error', errorCb); + }); +}; + +const pollCondition = async (predicate, cleanups, pollPeriod, timeout) => { + let done = false; + cleanups.push(() => { done = true; }); + // Pause a tick to give the predicate a chance to become true before adding latency. + await new Promise((resolve) => setTimeout(resolve, 0)); + const start = Date.now(); + while (!done && !predicate()) { + if (Date.now() - start > timeout) throw new Error('timeout'); + await new Promise((resolve) => setTimeout(resolve, pollPeriod)); + } +}; + +// Resolves when the frame's document is ready to be mutated: +// - Firefox seems to replace the frame's contentWindow.document object with a different object +// after the frame is created so we need to wait for the window's load event before continuing. +// - Chrome doesn't need any waiting (not even next tick), but on Windows it never seems to fire +// any events. Eventually the document's readyState becomes 'complete' (even though it never +// fires a readystatechange event), so this function waits for that to happen to avoid returning +// too soon on Firefox. +// - Safari behaves like Chrome. +// I'm not sure how other browsers behave, 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'), + // If all else fails, poll. + pollCondition(() => doc().readyState === 'complete', cleanups, 10, 5000), + ]); + } 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 = []; @@ -126,27 +192,30 @@ const Ace2Editor = function () { return {embeded: embededFiles, remote: remoteFiles}; }; - const pushStyleTagsFor = (buffer, files) => { + const addStyleTagsFor = (doc, files) => { const sorted = sortFilesByEmbeded(files); const embededFiles = sorted.embeded; const remoteFiles = sorted.remote; if (embededFiles.length > 0) { - buffer.push(''); + const css = embededFiles.map((f) => Ace2Editor.EMBEDED[f]).join('\n'); + const style = doc.createElement('style'); + style.type = 'text/css'; + style.appendChild(doc.createTextNode(css)); + doc.head.appendChild(style); } for (const file of remoteFiles) { - 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 }); @@ -167,103 +236,119 @@ const Ace2Editor = function () { $$INCLUDE_CSS( `../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 () => { - 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; - - await new Promise((resolve, reject) => window.plugins.ensure( - (err) => err != null ? reject(err) : resolve())); - const editorInfo = parent.parent.ace2EditorInfo; - await new Promise((resolve, reject) => window.Ace2Inner.init( - editorInfo, (err) => err != null ? reject(err) : resolve())); - editorInfo.onEditorReady(); - })();`)); - - iframeHTML.push(''); - - hooks.callAll('aceInitInnerdocbodyHead', { - iframeHTML, - }); - - iframeHTML.push(' '); - - const outerScript = `(async () => { - await new Promise((resolve) => { window.onload = () => resolve(); }); - 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(); - })();`; - - 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), - '', - '', - '', - '