From e55dbcd13ae7261dc7684f4163eb6eb5d0d725df Mon Sep 17 00:00:00 2001 From: Stefan Galinski Date: Mon, 2 Mar 2015 20:12:34 +0100 Subject: [PATCH] [FEATURE] Rework the loading mechanism with a mutation observer Resolves: https://forge.typo3.org/issues/65163 Resolves: https://forge.typo3.org/issues/63783 --- Classes/Loader.php | 45 +- Contrib/MutationObserver/MutationObserver.js | 568 +++++++++++++++++++ Resources/Public/JavaScript/Loader.js | 184 ++++++ 3 files changed, 773 insertions(+), 24 deletions(-) create mode 100644 Contrib/MutationObserver/MutationObserver.js create mode 100644 Resources/Public/JavaScript/Loader.js diff --git a/Classes/Loader.php b/Classes/Loader.php index 06d8218..3cc77cf 100644 --- a/Classes/Loader.php +++ b/Classes/Loader.php @@ -113,26 +113,21 @@ class Loader { /** * Returns a file that contains the tinyMCE configuration * - * @param bool $loadConfigurationWithTimer useful in relation with AJAX * @return string */ - protected function getConfiguration($loadConfigurationWithTimer = FALSE) { + protected function getConfiguration() { $configuration = $this->tinymceConfiguration['preJS']; $configuration .= ' - var executeTinymceInit = function() { - if (!window.tinymce || (window.tinymce && !window.tinymce.init)) { - return; - } - - window.tinymce.init({ - ' . $this->replaceTypo3Paths($this->tinymceConfiguration['configurationData']) . ' - }); - }; - executeTinymceInit(); + document.addEventListener( + "DOMContentLoaded", function() { + if (window.tinymce && window.tinymce.init) { + (new SG.TinyMceLoader(window.tinymce, { + ' . $this->replaceTypo3Paths($this->tinymceConfiguration['configurationData']) . ' + })); + } + }, false + ); '; - if ($loadConfigurationWithTimer) { - $configuration .= 'window.setInterval(executeTinymceInit, 1000);' . "\n"; - } $configuration .= $this->tinymceConfiguration['postJS']; $filename = 'tinymceConfiguration' . sha1($configuration) . '.js'; @@ -150,10 +145,9 @@ class Loader { * * Note: This function can only be called once. * - * @param bool $loadConfigurationWithTimer * @return string */ - public function getJS($loadConfigurationWithTimer = FALSE) { + public function getJS() { $output = ''; if (!self::$init) { self::$init = TRUE; @@ -181,10 +175,9 @@ class Loader { * Note: This function can only be called once. * * @param PageRenderer $pageRenderer - * @param bool $loadConfigurationWithTimer * @return void */ - public function loadJsViaPageRenderer(PageRenderer $pageRenderer, $loadConfigurationWithTimer = FALSE) { + public function loadJsViaPageRenderer(PageRenderer $pageRenderer) { if (self::$init) { return; } @@ -192,15 +185,15 @@ class Loader { $pathToTinyMceExtension = ExtensionManagementUtility::extRelPath('tinymce'); $script = $GLOBALS['BACK_PATH'] . $pathToTinyMceExtension . 'Contrib/tinymce/tinymce.min.js'; - $pageRenderer->addJsLibrary('tinymce', $script); + $pageRenderer->addJsLibrary('tinymce', $script, 'text/javascript', FALSE, TRUE, '', TRUE); $script = $GLOBALS['BACK_PATH'] . $pathToTinyMceExtension . 'Contrib/MutationObserver/MutationObserver.js'; - $pageRenderer->addJsLibrary('MutationObserver', $script); + $pageRenderer->addJsLibrary('MutationObserver', $script, 'text/javascript', FALSE, TRUE, '', TRUE); $script = $GLOBALS['BACK_PATH'] . $pathToTinyMceExtension . 'Resources/Public/JavaScript/Loader.js'; - $pageRenderer->addJsFile($script); + $pageRenderer->addJsFile($script, 'text/javascript', FALSE, TRUE, '', TRUE); - $script = $this->getConfiguration($loadConfigurationWithTimer); + $script = $this->getConfiguration(); $pageRenderer->addJsFile($script, 'text/javascript', FALSE, TRUE, '', TRUE); } @@ -251,7 +244,11 @@ class Loader { $value = '\'' . $value . '\''; } - $this->tinymceConfiguration['configurationData'] .= "\n," . $key . ': ' . $value . "\n"; + if ($this->tinymceConfiguration['configurationData'] !== '') { + $this->tinymceConfiguration['configurationData'] .= "\n,"; + } + + $this->tinymceConfiguration['configurationData'] .= $key . ': ' . $value; } /** diff --git a/Contrib/MutationObserver/MutationObserver.js b/Contrib/MutationObserver/MutationObserver.js new file mode 100644 index 0000000..53424fb --- /dev/null +++ b/Contrib/MutationObserver/MutationObserver.js @@ -0,0 +1,568 @@ +/** + * @license + * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt + */ + +(function(global) { + + var registrationsTable = new WeakMap(); + + var setImmediate; + + // As much as we would like to use the native implementation, IE + // (all versions) suffers a rather annoying bug where it will drop or defer + // callbacks when heavy DOM operations are being performed concurrently. + // + // For a thorough discussion on this, see: + // http://codeforhire.com/2013/09/21/setimmediate-and-messagechannel-broken-on-internet-explorer-10/ + if (/Trident|Edge/.test(navigator.userAgent)) { + // Sadly, this bug also affects postMessage and MessageQueues. + // + // We would like to use the onreadystatechange hack for IE <= 10, but it is + // dangerous in the polyfilled environment due to requiring that the + // observed script element be in the document. + setImmediate = setTimeout; + + // If some other browser ever implements it, let's prefer their native + // implementation: + } else if (window.setImmediate) { + setImmediate = window.setImmediate; + + // Otherwise, we fall back to postMessage as a means of emulating the next + // task semantics of setImmediate. + } else { + var setImmediateQueue = []; + var sentinel = String(Math.random()); + window.addEventListener('message', function(e) { + if (e.data === sentinel) { + var queue = setImmediateQueue; + setImmediateQueue = []; + queue.forEach(function(func) { + func(); + }); + } + }); + setImmediate = function(func) { + setImmediateQueue.push(func); + window.postMessage(sentinel, '*'); + }; + } + + // This is used to ensure that we never schedule 2 callas to setImmediate + var isScheduled = false; + + // Keep track of observers that needs to be notified next time. + var scheduledObservers = []; + + /** + * Schedules |dispatchCallback| to be called in the future. + * @param {MutationObserver} observer + */ + function scheduleCallback(observer) { + scheduledObservers.push(observer); + if (!isScheduled) { + isScheduled = true; + setImmediate(dispatchCallbacks); + } + } + + function wrapIfNeeded(node) { + return window.ShadowDOMPolyfill && + window.ShadowDOMPolyfill.wrapIfNeeded(node) || + node; + } + + function dispatchCallbacks() { + // http://dom.spec.whatwg.org/#mutation-observers + + isScheduled = false; // Used to allow a new setImmediate call above. + + var observers = scheduledObservers; + scheduledObservers = []; + // Sort observers based on their creation UID (incremental). + observers.sort(function(o1, o2) { + return o1.uid_ - o2.uid_; + }); + + var anyNonEmpty = false; + observers.forEach(function(observer) { + + // 2.1, 2.2 + var queue = observer.takeRecords(); + // 2.3. Remove all transient registered observers whose observer is mo. + removeTransientObserversFor(observer); + + // 2.4 + if (queue.length) { + observer.callback_(queue, observer); + anyNonEmpty = true; + } + }); + + // 3. + if (anyNonEmpty) + dispatchCallbacks(); + } + + function removeTransientObserversFor(observer) { + observer.nodes_.forEach(function(node) { + var registrations = registrationsTable.get(node); + if (!registrations) + return; + registrations.forEach(function(registration) { + if (registration.observer === observer) + registration.removeTransientObservers(); + }); + }); + } + + /** + * This function is used for the "For each registered observer observer (with + * observer's options as options) in target's list of registered observers, + * run these substeps:" and the "For each ancestor ancestor of target, and for + * each registered observer observer (with options options) in ancestor's list + * of registered observers, run these substeps:" part of the algorithms. The + * |options.subtree| is checked to ensure that the callback is called + * correctly. + * + * @param {Node} target + * @param {function(MutationObserverInit):MutationRecord} callback + */ + function forEachAncestorAndObserverEnqueueRecord(target, callback) { + for (var node = target; node; node = node.parentNode) { + var registrations = registrationsTable.get(node); + + if (registrations) { + for (var j = 0; j < registrations.length; j++) { + var registration = registrations[j]; + var options = registration.options; + + // Only target ignores subtree. + if (node !== target && !options.subtree) + continue; + + var record = callback(options); + if (record) + registration.enqueue(record); + } + } + } + } + + var uidCounter = 0; + + /** + * The class that maps to the DOM MutationObserver interface. + * @param {Function} callback. + * @constructor + */ + function JsMutationObserver(callback) { + this.callback_ = callback; + this.nodes_ = []; + this.records_ = []; + this.uid_ = ++uidCounter; + } + + JsMutationObserver.prototype = { + observe: function(target, options) { + target = wrapIfNeeded(target); + + // 1.1 + if (!options.childList && !options.attributes && !options.characterData || + + // 1.2 + options.attributeOldValue && !options.attributes || + + // 1.3 + options.attributeFilter && options.attributeFilter.length && + !options.attributes || + + // 1.4 + options.characterDataOldValue && !options.characterData) { + + throw new SyntaxError(); + } + + var registrations = registrationsTable.get(target); + if (!registrations) + registrationsTable.set(target, registrations = []); + + // 2 + // If target's list of registered observers already includes a registered + // observer associated with the context object, replace that registered + // observer's options with options. + var registration; + for (var i = 0; i < registrations.length; i++) { + if (registrations[i].observer === this) { + registration = registrations[i]; + registration.removeListeners(); + registration.options = options; + break; + } + } + + // 3. + // Otherwise, add a new registered observer to target's list of registered + // observers with the context object as the observer and options as the + // options, and add target to context object's list of nodes on which it + // is registered. + if (!registration) { + registration = new Registration(this, target, options); + registrations.push(registration); + this.nodes_.push(target); + } + + registration.addListeners(); + }, + + disconnect: function() { + this.nodes_.forEach(function(node) { + var registrations = registrationsTable.get(node); + for (var i = 0; i < registrations.length; i++) { + var registration = registrations[i]; + if (registration.observer === this) { + registration.removeListeners(); + registrations.splice(i, 1); + // Each node can only have one registered observer associated with + // this observer. + break; + } + } + }, this); + this.records_ = []; + }, + + takeRecords: function() { + var copyOfRecords = this.records_; + this.records_ = []; + return copyOfRecords; + } + }; + + /** + * @param {string} type + * @param {Node} target + * @constructor + */ + function MutationRecord(type, target) { + this.type = type; + this.target = target; + this.addedNodes = []; + this.removedNodes = []; + this.previousSibling = null; + this.nextSibling = null; + this.attributeName = null; + this.attributeNamespace = null; + this.oldValue = null; + } + + function copyMutationRecord(original) { + var record = new MutationRecord(original.type, original.target); + record.addedNodes = original.addedNodes.slice(); + record.removedNodes = original.removedNodes.slice(); + record.previousSibling = original.previousSibling; + record.nextSibling = original.nextSibling; + record.attributeName = original.attributeName; + record.attributeNamespace = original.attributeNamespace; + record.oldValue = original.oldValue; + return record; + }; + + // We keep track of the two (possibly one) records used in a single mutation. + var currentRecord, recordWithOldValue; + + /** + * Creates a record without |oldValue| and caches it as |currentRecord| for + * later use. + * @param {string} oldValue + * @return {MutationRecord} + */ + function getRecord(type, target) { + return currentRecord = new MutationRecord(type, target); + } + + /** + * Gets or creates a record with |oldValue| based in the |currentRecord| + * @param {string} oldValue + * @return {MutationRecord} + */ + function getRecordWithOldValue(oldValue) { + if (recordWithOldValue) + return recordWithOldValue; + recordWithOldValue = copyMutationRecord(currentRecord); + recordWithOldValue.oldValue = oldValue; + return recordWithOldValue; + } + + function clearRecords() { + currentRecord = recordWithOldValue = undefined; + } + + /** + * @param {MutationRecord} record + * @return {boolean} Whether the record represents a record from the current + * mutation event. + */ + function recordRepresentsCurrentMutation(record) { + return record === recordWithOldValue || record === currentRecord; + } + + /** + * Selects which record, if any, to replace the last record in the queue. + * This returns |null| if no record should be replaced. + * + * @param {MutationRecord} lastRecord + * @param {MutationRecord} newRecord + * @param {MutationRecord} + */ + function selectRecord(lastRecord, newRecord) { + if (lastRecord === newRecord) + return lastRecord; + + // Check if the the record we are adding represents the same record. If + // so, we keep the one with the oldValue in it. + if (recordWithOldValue && recordRepresentsCurrentMutation(lastRecord)) + return recordWithOldValue; + + return null; + } + + /** + * Class used to represent a registered observer. + * @param {MutationObserver} observer + * @param {Node} target + * @param {MutationObserverInit} options + * @constructor + */ + function Registration(observer, target, options) { + this.observer = observer; + this.target = target; + this.options = options; + this.transientObservedNodes = []; + } + + Registration.prototype = { + enqueue: function(record) { + var records = this.observer.records_; + var length = records.length; + + // There are cases where we replace the last record with the new record. + // For example if the record represents the same mutation we need to use + // the one with the oldValue. If we get same record (this can happen as we + // walk up the tree) we ignore the new record. + if (records.length > 0) { + var lastRecord = records[length - 1]; + var recordToReplaceLast = selectRecord(lastRecord, record); + if (recordToReplaceLast) { + records[length - 1] = recordToReplaceLast; + return; + } + } else { + scheduleCallback(this.observer); + } + + records[length] = record; + }, + + addListeners: function() { + this.addListeners_(this.target); + }, + + addListeners_: function(node) { + var options = this.options; + if (options.attributes) + node.addEventListener('DOMAttrModified', this, true); + + if (options.characterData) + node.addEventListener('DOMCharacterDataModified', this, true); + + if (options.childList) + node.addEventListener('DOMNodeInserted', this, true); + + if (options.childList || options.subtree) + node.addEventListener('DOMNodeRemoved', this, true); + }, + + removeListeners: function() { + this.removeListeners_(this.target); + }, + + removeListeners_: function(node) { + var options = this.options; + if (options.attributes) + node.removeEventListener('DOMAttrModified', this, true); + + if (options.characterData) + node.removeEventListener('DOMCharacterDataModified', this, true); + + if (options.childList) + node.removeEventListener('DOMNodeInserted', this, true); + + if (options.childList || options.subtree) + node.removeEventListener('DOMNodeRemoved', this, true); + }, + + /** + * Adds a transient observer on node. The transient observer gets removed + * next time we deliver the change records. + * @param {Node} node + */ + addTransientObserver: function(node) { + // Don't add transient observers on the target itself. We already have all + // the required listeners set up on the target. + if (node === this.target) + return; + + this.addListeners_(node); + this.transientObservedNodes.push(node); + var registrations = registrationsTable.get(node); + if (!registrations) + registrationsTable.set(node, registrations = []); + + // We know that registrations does not contain this because we already + // checked if node === this.target. + registrations.push(this); + }, + + removeTransientObservers: function() { + var transientObservedNodes = this.transientObservedNodes; + this.transientObservedNodes = []; + + transientObservedNodes.forEach(function(node) { + // Transient observers are never added to the target. + this.removeListeners_(node); + + var registrations = registrationsTable.get(node); + for (var i = 0; i < registrations.length; i++) { + if (registrations[i] === this) { + registrations.splice(i, 1); + // Each node can only have one registered observer associated with + // this observer. + break; + } + } + }, this); + }, + + handleEvent: function(e) { + // Stop propagation since we are managing the propagation manually. + // This means that other mutation events on the page will not work + // correctly but that is by design. + e.stopImmediatePropagation(); + + switch (e.type) { + case 'DOMAttrModified': + // http://dom.spec.whatwg.org/#concept-mo-queue-attributes + + var name = e.attrName; + var namespace = e.relatedNode.namespaceURI; + var target = e.target; + + // 1. + var record = new getRecord('attributes', target); + record.attributeName = name; + record.attributeNamespace = namespace; + + // 2. + var oldValue = + e.attrChange === MutationEvent.ADDITION ? null : e.prevValue; + + forEachAncestorAndObserverEnqueueRecord(target, function(options) { + // 3.1, 4.2 + if (!options.attributes) + return; + + // 3.2, 4.3 + if (options.attributeFilter && options.attributeFilter.length && + options.attributeFilter.indexOf(name) === -1 && + options.attributeFilter.indexOf(namespace) === -1) { + return; + } + // 3.3, 4.4 + if (options.attributeOldValue) + return getRecordWithOldValue(oldValue); + + // 3.4, 4.5 + return record; + }); + + break; + + case 'DOMCharacterDataModified': + // http://dom.spec.whatwg.org/#concept-mo-queue-characterdata + var target = e.target; + + // 1. + var record = getRecord('characterData', target); + + // 2. + var oldValue = e.prevValue; + + + forEachAncestorAndObserverEnqueueRecord(target, function(options) { + // 3.1, 4.2 + if (!options.characterData) + return; + + // 3.2, 4.3 + if (options.characterDataOldValue) + return getRecordWithOldValue(oldValue); + + // 3.3, 4.4 + return record; + }); + + break; + + case 'DOMNodeRemoved': + this.addTransientObserver(e.target); + // Fall through. + case 'DOMNodeInserted': + // http://dom.spec.whatwg.org/#concept-mo-queue-childlist + var changedNode = e.target; + var addedNodes, removedNodes; + if (e.type === 'DOMNodeInserted') { + addedNodes = [changedNode]; + removedNodes = []; + } else { + + addedNodes = []; + removedNodes = [changedNode]; + } + var previousSibling = changedNode.previousSibling; + var nextSibling = changedNode.nextSibling; + + // 1. + var record = getRecord('childList', e.target.parentNode); + record.addedNodes = addedNodes; + record.removedNodes = removedNodes; + record.previousSibling = previousSibling; + record.nextSibling = nextSibling; + + forEachAncestorAndObserverEnqueueRecord(e.relatedNode, function(options) { + // 2.1, 3.2 + if (!options.childList) + return; + + // 2.2, 3.3 + return record; + }); + + } + + clearRecords(); + } + }; + + global.JsMutationObserver = JsMutationObserver; + + if (!global.MutationObserver) + global.MutationObserver = JsMutationObserver; + + +})(this); \ No newline at end of file diff --git a/Resources/Public/JavaScript/Loader.js b/Resources/Public/JavaScript/Loader.js new file mode 100644 index 0000000..4b9a2ee --- /dev/null +++ b/Resources/Public/JavaScript/Loader.js @@ -0,0 +1,184 @@ +/*************************************************************** + * Copyright notice + * + * (c) thomas-p / https://github.com/Thomas-P + * (c) sgalinski Internet Services (http://www.sgalinski.de) + * + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +var SG = SG || {}; + +SG.TinyMceLoader = function(tinymce, options) { + options = options || {}; + + if (!tinymce) { + return console.error('Couldn\'t load dependency tinyMCE, break here.'); + } + + if (!MutationObserver) { + return console.error('Couldn\'t load dependency MutationObserver, break here.') + } + + // store original init from the tinyMCE + this.originalInit = tinymce.init; + + // stores the default options + this.defaultOptions = options; + + // storage for options + this.tinyMceOptions = {}; + + // selector for tinyMCE + this.selector = ''; + + // initialization flag + this.firstInit = false; + + this.initialize(options); +}; + +SG.TinyMceLoader.prototype = { + /** + * Override for the tinyMCE.init. Handle only the options. + * + * @return {void} + */ + initialize: function(options) { + options = options || {}; + if (this.firstInit) { + console.error('The tinyMCE loader should only be called once'); + } + this.firstInit = true; + + // override options with default from extension + var defaultOptions = this.defaultOptions || {}; + for (var key in defaultOptions) { + if (defaultOptions.hasOwnProperty(key)) { + options[key] = defaultOptions[key]; + } + } + + // override options.setup with own integration + options.setup = this.setup(options.setup); + this.tinyMceOptions = options; + + if (options.selector) { + this.selector = String(options.selector); + } + + this.setupTinymce(document.body); + this.initMutationObserver(); + }, + + /** + * Setup override to manage changes + * + * @return {function} + */ + setup: function(setupMethod) { + return function(editor) { + var changeMethod = this.tinyMceOptions.changeMethod; + + if (setupMethod && typeof setupMethod === 'function') { + setupMethod.call(this, editor); + } + + editor.on( + 'change', function() { + if (typeof changeMethod === 'function') { + var target = editor.targetElm; + var name = target.name; + var found = name.match(/^data\[(.*)]\[(.*)]\[(.*)]$/); + if (found) { + changeMethod.apply(target, [found[1], found[2], found[3], name]); + } + } + + editor.save(); + } + ); + }.bind(this); + }, + + /** + * Automatically apply the tinyMCE editor on new elements + * + * @return {void} + */ + initMutationObserver: function() { + var observer = new MutationObserver( + function(mutations) { + if (!this.selector) { + return; + } + + mutations.forEach( + function(mutation) { + if (!mutation.target) { + return; + } + + this.setupTinymce(mutation.target); + }.bind(this) + ); + }.bind(this) + ); + + //noinspection JSCheckFunctionSignatures + observer.observe(document, {subtree: true, childList: true}); + }, + + /** + * Setups the tinymce instances for a given element + * + * @param {Element} element + * @return {void} + */ + setupTinymce: function(element) { + var hasMatches = false; + this.tinyMceOptions.selector = ''; + var elements = element.querySelectorAll(this.selector); + for (var index = 0; index < elements.length; ++index) { + var node = elements[index]; + + if (!node.dataset) { + node.dataset = {}; + } + + if (node.dataset.isTinyMCE) { + continue; + } + + // add tinyMCE to node + if (!node.id) { + node.id = new Date().getUTCMilliseconds(); + } + + node.dataset.isTinyMCE = true; + this.tinyMceOptions.selector += ',#' + node.id; + hasMatches = true; + } + + if (hasMatches) { + this.tinyMceOptions.selector = this.tinyMceOptions.selector.slice(1); + this.originalInit.call(tinymce, this.tinyMceOptions); + } + } +}; \ No newline at end of file -- GitLab