From 404486069c204cbec840aa802fc6a18dac013418 Mon Sep 17 00:00:00 2001 From: Richard Hansen Date: Thu, 25 Feb 2021 21:11:30 -0500 Subject: [PATCH] ace: Build the outer and inner iframes programmatically This makes the code easier to read and it silences Chrome's `document.write()` warning: https://developers.google.com/web/updates/2016/08/removing-document-write This is a redo of commit a17f9bf3cfc745a44d0e57b77912e346ffd3ce1c, which was reverted in commit 912f0f195faf19b11a5db928b3846fbb09388004 due to a CSS bug. --- src/static/js/ace.js | 294 ++++++++++++++++++++++++++++--------------- 1 file changed, 192 insertions(+), 102 deletions(-) diff --git a/src/static/js/ace.js b/src/static/js/ace.js index 0d273b85b..fd6fca379 100644 --- a/src/static/js/ace.js +++ b/src/static/js/ace.js @@ -28,19 +28,88 @@ const hooks = require('./pluginfw/hooks'); 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); + }); +}; + +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)); + debugLog('Ace2Editor.init() polling'); + } + if (!done) debugLog('Ace2Editor.init() poll condition became true'); +}; + +// 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 = []; @@ -109,16 +178,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 }); @@ -135,109 +207,127 @@ 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'; info.frame = outerFrame; document.getElementById(containerId).appendChild(outerFrame); + const outerWindow = outerFrame.contentWindow; - const editorDocument = outerFrame.contentWindow.document; - + // For some unknown reason Firefox replaces outerWindow.document with a new Document object some + // time between running the above code and firing the outerWindow load event. Work around it by + // waiting until the load event fires before mutating the Document object. 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'); + + // This must be done after the Window's load event. See above comment. + const outerDocument = outerWindow.document; + + // tag + outerDocument.insertBefore( + outerDocument.implementation.createDocumentType('html', '', ''), outerDocument.firstChild); + 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 + innerFrame.ace_outerWin = outerWindow; + outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild); + const innerWindow = innerFrame.contentWindow; + + // Wait before mutating the inner document. See above comment recarding outerWindow load. + debugLog('Ace2Editor.init() waiting for inner frame'); + await frameReady(innerFrame); + debugLog('Ace2Editor.init() inner frame ready'); + + // This must be done after the Window's load event. See above comment. + const innerDocument = innerWindow.document; + + // tag + innerDocument.insertBefore( + innerDocument.implementation.createDocumentType('html', '', ''), innerDocument.firstChild); + 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 new Promise((resolve, reject) => innerWindow.Ace2Inner.init( + info, (err) => err != null ? reject(err) : resolve())); + debugLog('Ace2Editor.init() Ace2Inner.init() returned'); loaded = true; doActionsPendingInit(); debugLog('Ace2Editor.init() done');