From f37a168cd95f8b65c1742ab51abfacad0507c381 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Wed, 4 Nov 2020 00:44:43 +0100 Subject: [PATCH] Convert `Ui/Redactor/Autosave` to TypeScript --- .../WoltLabSuite/Core/Ui/Redactor/Autosave.js | 301 +++++++------- .../WoltLabSuite/Core/Ui/Redactor/Autosave.js | 380 ------------------ .../WoltLabSuite/Core/Ui/Redactor/Autosave.ts | 363 +++++++++++++++++ .../WoltLabSuite/Core/Ui/Redactor/Editor.ts | 18 + 4 files changed, 519 insertions(+), 543 deletions(-) delete mode 100644 wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.js create mode 100644 wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.ts diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Redactor/Autosave.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Redactor/Autosave.js index e2fb5368f9..3a313f801a 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Redactor/Autosave.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Redactor/Autosave.js @@ -2,75 +2,57 @@ * Manages the autosave process storing the current editor message in the local * storage to recover it on browser crash or accidental navigation. * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @module WoltLabSuite/Core/Ui/Redactor/Autosave + * @author Alexander Ebert + * @copyright 2001-2019 WoltLab GmbH + * @license GNU Lesser General Public License + * @module WoltLabSuite/Core/Ui/Redactor/Autosave */ -define(['Core', 'Devtools', 'EventHandler', 'Language', 'Dom/Traverse', './Metacode'], function (Core, Devtools, EventHandler, Language, DomTraverse, UiRedactorMetacode) { +define(["require", "exports", "tslib", "../../Core", "../../Devtools", "../../Event/Handler", "../../Language", "./Metacode"], function (require, exports, tslib_1, Core, Devtools_1, EventHandler, Language, UiRedactorMetacode) { "use strict"; - if (!COMPILER_TARGET_DEFAULT) { - var Fake = function () { }; - Fake.prototype = { - init: function () { }, - getInitialValue: function () { }, - getMetaData: function () { }, - watch: function () { }, - destroy: function () { }, - clear: function () { }, - createOverlay: function () { }, - hideOverlay: function () { }, - _saveToStorage: function () { }, - _cleanup: function () { } - }; - return Fake; - } + Core = tslib_1.__importStar(Core); + Devtools_1 = tslib_1.__importDefault(Devtools_1); + EventHandler = tslib_1.__importStar(EventHandler); + Language = tslib_1.__importStar(Language); + UiRedactorMetacode = tslib_1.__importStar(UiRedactorMetacode); // time between save requests in seconds - var _frequency = 15; - /** - * @param {Element} element textarea element - * @constructor - */ - function UiRedactorAutosave(element) { this.init(element); } - UiRedactorAutosave.prototype = { + const _frequency = 15; + class UiRedactorAutosave { /** * Initializes the autosave handler and removes outdated messages from storage. * * @param {Element} element textarea element */ - init: function (element) { + constructor(element) { this._container = null; - this._metaData = {}; this._editor = null; - this._element = element; this._isActive = true; this._isPending = false; - this._key = Core.getStoragePrefix() + elData(this._element, 'autosave'); - this._lastMessage = ''; - this._originalMessage = ''; - this._overlay = null; + this._lastMessage = ""; + this._metaData = {}; + this._originalMessage = ""; this._restored = false; this._timer = null; + this._element = element; + this._key = Core.getStoragePrefix() + this._element.dataset.autosave; + //this._overlay = null; this._cleanup(); // remove attribute to prevent Redactor's built-in autosave to kick in - this._element.removeAttribute('data-autosave'); - var form = DomTraverse.parentByTag(this._element, 'FORM'); + delete this._element.dataset.autosave; + const form = this._element.closest("form"); if (form !== null) { - form.addEventListener('submit', this.destroy.bind(this)); + form.addEventListener("submit", this.destroy.bind(this)); } // export meta data - EventHandler.add('com.woltlab.wcf.redactor2', 'getMetaData_' + this._element.id, (function (data) { - for (var key in this._metaData) { - if (this._metaData.hasOwnProperty(key)) { - data[key] = this._metaData[key]; - } - } - }).bind(this)); + EventHandler.add("com.woltlab.wcf.redactor2", `getMetaData_${this._element.id}`, (data) => { + Object.entries(this._metaData).forEach(([key, value]) => { + data[key] = value; + }); + }); // clear editor content on reset - EventHandler.add('com.woltlab.wcf.redactor2', 'reset_' + this._element.id, this.hideOverlay.bind(this)); - document.addEventListener('visibilitychange', this._onVisibilityChange.bind(this)); - }, - _onVisibilityChange: function () { + EventHandler.add("com.woltlab.wcf.redactor2", `reset_${this._element.id}`, () => this.hideOverlay()); + document.addEventListener("visibilitychange", () => this._onVisibilityChange()); + } + _onVisibilityChange() { if (document.hidden) { this._isActive = false; this._isPending = true; @@ -79,241 +61,234 @@ define(['Core', 'Devtools', 'EventHandler', 'Language', 'Dom/Traverse', './Metac this._isActive = true; this._isPending = false; } - }, + } /** * Returns the initial value for the textarea, used to inject message * from storage into the editor before initialization. * * @return {string} message content */ - getInitialValue: function () { - //noinspection JSUnresolvedVariable - if (window.ENABLE_DEVELOPER_TOOLS && Devtools._internal_.editorAutosave() === false) { - //noinspection JSUnresolvedVariable + getInitialValue() { + if (window.ENABLE_DEVELOPER_TOOLS && !Devtools_1.default._internal_.editorAutosave()) { return this._element.value; } - var value = ''; + let value = ""; try { - value = window.localStorage.getItem(this._key); + value = window.localStorage.getItem(this._key) || ""; } catch (e) { - window.console.warn("Unable to access local storage: " + e.message); + const errorMessage = e.message; + window.console.warn(`Unable to access local storage: ${errorMessage}`); } + let metaData = null; try { - value = JSON.parse(value); + metaData = JSON.parse(value); } catch (e) { - value = ''; + // We do not care for JSON errors. } // Check if the storage is outdated. - if (value !== null && typeof value === 'object' && value.content) { - var lastEditTime = ~~elData(this._element, 'autosave-last-edit-time'); - if (lastEditTime * 1000 <= value.timestamp) { + if (metaData !== null && typeof metaData === "object" && metaData.content) { + const lastEditTime = ~~this._element.dataset.autosaveLastEditTime; + if (lastEditTime * 1000 <= metaData.timestamp) { // Compare the stored version with the editor content, but only use the `innerText` property // in order to ignore differences in whitespace, e. g. caused by indentation of HTML tags. - var div1 = elCreate('div'); + const div1 = document.createElement("div"); div1.innerHTML = this._element.value; - var div2 = elCreate('div'); - div2.innerHTML = value.content; + const div2 = document.createElement("div"); + div2.innerHTML = metaData.content; if (div1.innerText.trim() !== div2.innerText.trim()) { - //noinspection JSUnresolvedVariable this._originalMessage = this._element.value; this._restored = true; - this._metaData = value.meta || {}; - return value.content; + this._metaData = metaData.meta || {}; + return metaData.content; } } } - //noinspection JSUnresolvedVariable return this._element.value; - }, + } /** * Returns the stored meta data. - * - * @return {Object} */ - getMetaData: function () { + getMetaData() { return this._metaData; - }, + } /** * Enables periodical save of editor contents to local storage. - * - * @param {$.Redactor} editor redactor instance */ - watch: function (editor) { + watch(editor) { this._editor = editor; if (this._timer !== null) { throw new Error("Autosave timer is already active."); } - this._timer = window.setInterval(this._saveToStorage.bind(this), _frequency * 1000); + this._timer = window.setInterval(() => this._saveToStorage(), _frequency * 1000); this._saveToStorage(); this._isPending = false; - }, + } /** * Disables autosave handler, for use on editor destruction. */ - destroy: function () { + destroy() { this.clear(); this._editor = null; - window.clearInterval(this._timer); + if (this._timer) { + window.clearInterval(this._timer); + } this._timer = null; this._isPending = false; - }, + } /** * Removed the stored message, for use after a message has been submitted. */ - clear: function () { + clear() { this._metaData = {}; - this._lastMessage = ''; + this._lastMessage = ""; try { window.localStorage.removeItem(this._key); } catch (e) { - window.console.warn("Unable to remove from local storage: " + e.message); + const errorMessage = e.message; + window.console.warn(`Unable to remove from local storage: ${errorMessage}`); } - }, + } /** * Creates the autosave controls, used to keep or discard the restored draft. */ - createOverlay: function () { + createOverlay() { if (!this._restored) { return; } - var container = elCreate('div'); - container.className = 'redactorAutosaveRestored active'; - var title = elCreate('span'); - title.textContent = Language.get('wcf.editor.autosave.restored'); + const editor = this._editor; + const container = document.createElement("div"); + container.className = "redactorAutosaveRestored active"; + const title = document.createElement("span"); + title.textContent = Language.get("wcf.editor.autosave.restored"); container.appendChild(title); - var button = elCreate('a'); - button.className = 'jsTooltip'; - button.href = '#'; - button.title = Language.get('wcf.editor.autosave.keep'); - button.innerHTML = ''; - button.addEventListener('click', (function (event) { + const buttonKeep = document.createElement("a"); + buttonKeep.className = "jsTooltip"; + buttonKeep.href = "#"; + buttonKeep.title = Language.get("wcf.editor.autosave.keep"); + buttonKeep.innerHTML = ''; + buttonKeep.addEventListener("click", (event) => { event.preventDefault(); this.hideOverlay(); - }).bind(this)); - container.appendChild(button); - button = elCreate('a'); - button.className = 'jsTooltip'; - button.href = '#'; - button.title = Language.get('wcf.editor.autosave.discard'); - button.innerHTML = ''; - button.addEventListener('click', (function (event) { + }); + container.appendChild(buttonKeep); + const buttonDiscard = document.createElement("a"); + buttonDiscard.className = "jsTooltip"; + buttonDiscard.href = "#"; + buttonDiscard.title = Language.get("wcf.editor.autosave.discard"); + buttonDiscard.innerHTML = ''; + buttonDiscard.addEventListener("click", (event) => { event.preventDefault(); // remove from storage this.clear(); // set code - var content = UiRedactorMetacode.convertFromHtml(this._editor.core.element()[0].id, this._originalMessage); - this._editor.code.start(content); + const content = UiRedactorMetacode.convertFromHtml(editor.core.element()[0].id, this._originalMessage); + editor.code.start(content); // set value - this._editor.core.textarea().val(this._editor.clean.onSync(this._editor.$editor.html())); - this.hideOverlay(); - }).bind(this)); - container.appendChild(button); - this._editor.core.box()[0].appendChild(container); - var callback = (function () { - this._editor.core.editor()[0].removeEventListener('click', callback); + editor.core.textarea().val(editor.clean.onSync(editor.$editor.html())); this.hideOverlay(); - }).bind(this); - this._editor.core.editor()[0].addEventListener('click', callback); + }); + container.appendChild(buttonDiscard); + editor.core.box()[0].appendChild(container); + editor.core.editor()[0].addEventListener("click", () => this.hideOverlay(), { once: true }); this._container = container; - }, + } /** * Hides the autosave controls. */ - hideOverlay: function () { + hideOverlay() { if (this._container !== null) { - this._container.classList.remove('active'); - window.setTimeout((function () { + this._container.classList.remove("active"); + window.setTimeout(() => { if (this._container !== null) { - elRemove(this._container); + this._container.remove(); } this._container = null; - this._originalMessage = ''; - }).bind(this), 1000); + this._originalMessage = ""; + }, 1000); } - }, + } /** * Saves the current message to storage unless there was no change. - * - * @protected */ - _saveToStorage: function () { + _saveToStorage() { if (!this._isActive) { - if (!this._isPending) + if (!this._isPending) { return; + } // save one last time before suspending this._isPending = false; } //noinspection JSUnresolvedVariable - if (window.ENABLE_DEVELOPER_TOOLS && Devtools._internal_.editorAutosave() === false) { - //noinspection JSUnresolvedVariable + if (window.ENABLE_DEVELOPER_TOOLS && !Devtools_1.default._internal_.editorAutosave()) { return; } - var content = this._editor.code.get(); - if (this._editor.utils.isEmpty(content)) { - content = ''; + const editor = this._editor; + let content = editor.code.get(); + if (editor.utils.isEmpty(content)) { + content = ""; } if (this._lastMessage === content) { // break if content hasn't changed return; } - if (content === '') { + if (content === "") { return this.clear(); } try { - EventHandler.fire('com.woltlab.wcf.redactor2', 'autosaveMetaData_' + this._element.id, this._metaData); + EventHandler.fire("com.woltlab.wcf.redactor2", `autosaveMetaData_${this._element.id}`, this._metaData); window.localStorage.setItem(this._key, JSON.stringify({ content: content, meta: this._metaData, - timestamp: Date.now() + timestamp: Date.now(), })); this._lastMessage = content; } catch (e) { - window.console.warn("Unable to write to local storage: " + e.message); + const errorMessage = e.message; + window.console.warn(`Unable to write to local storage: ${errorMessage}`); } - }, + } /** * Removes stored messages older than one week. - * - * @protected */ - _cleanup: function () { - var oneWeekAgo = Date.now() - (7 * 24 * 3600 * 1000), removeKeys = []; - var i, key, length, value; - for (i = 0, length = window.localStorage.length; i < length; i++) { - key = window.localStorage.key(i); + _cleanup() { + const oneWeekAgo = Date.now() - 7 * 24 * 3600 * 1000; + Object.keys(window.localStorage).forEach((key) => { // check if key matches our prefix - if (key.indexOf(Core.getStoragePrefix()) !== 0) { - continue; + if (!key.startsWith(Core.getStoragePrefix())) { + return; } + let value = ""; try { - value = window.localStorage.getItem(key); + value = window.localStorage.getItem(key) || ""; } catch (e) { - window.console.warn("Unable to access local storage: " + e.message); + const errorMessage = e.message; + window.console.warn(`Unable to access local storage: ${errorMessage}`); } + let timestamp = 0; try { - value = JSON.parse(value); + const content = JSON.parse(value); + timestamp = content.timestamp; } catch (e) { - value = { timestamp: 0 }; + // We do not care for JSON errors. } - if (!value || value.timestamp < oneWeekAgo) { - removeKeys.push(key); - } - } - for (i = 0, length = removeKeys.length; i < length; i++) { - try { - window.localStorage.removeItem(removeKeys[i]); - } - catch (e) { - window.console.warn("Unable to remove from local storage: " + e.message); + if (!value || timestamp < oneWeekAgo) { + try { + window.localStorage.removeItem(key); + } + catch (e) { + const errorMessage = e.message; + window.console.warn(`Unable to remove from local storage: ${errorMessage}`); + } } - } + }); } - }; + } + Core.enableLegacyInheritance(UiRedactorAutosave); return UiRedactorAutosave; }); diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.js deleted file mode 100644 index 6712e0ba71..0000000000 --- a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.js +++ /dev/null @@ -1,380 +0,0 @@ -/** - * Manages the autosave process storing the current editor message in the local - * storage to recover it on browser crash or accidental navigation. - * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @module WoltLabSuite/Core/Ui/Redactor/Autosave - */ -define(['Core', 'Devtools', 'EventHandler', 'Language', 'Dom/Traverse', './Metacode'], function(Core, Devtools, EventHandler, Language, DomTraverse, UiRedactorMetacode) { - "use strict"; - - if (!COMPILER_TARGET_DEFAULT) { - var Fake = function() {}; - Fake.prototype = { - init: function() {}, - getInitialValue: function() {}, - getMetaData: function () {}, - watch: function() {}, - destroy: function() {}, - clear: function() {}, - createOverlay: function() {}, - hideOverlay: function() {}, - _saveToStorage: function() {}, - _cleanup: function() {} - }; - return Fake; - } - - // time between save requests in seconds - var _frequency = 15; - - /** - * @param {Element} element textarea element - * @constructor - */ - function UiRedactorAutosave(element) { this.init(element); } - UiRedactorAutosave.prototype = { - /** - * Initializes the autosave handler and removes outdated messages from storage. - * - * @param {Element} element textarea element - */ - init: function (element) { - this._container = null; - this._metaData = {}; - this._editor = null; - this._element = element; - this._isActive = true; - this._isPending = false; - this._key = Core.getStoragePrefix() + elData(this._element, 'autosave'); - this._lastMessage = ''; - this._originalMessage = ''; - this._overlay = null; - this._restored = false; - this._timer = null; - - this._cleanup(); - - // remove attribute to prevent Redactor's built-in autosave to kick in - this._element.removeAttribute('data-autosave'); - - var form = DomTraverse.parentByTag(this._element, 'FORM'); - if (form !== null) { - form.addEventListener('submit', this.destroy.bind(this)); - } - - // export meta data - EventHandler.add('com.woltlab.wcf.redactor2', 'getMetaData_' + this._element.id, (function (data) { - for (var key in this._metaData) { - if (this._metaData.hasOwnProperty(key)) { - data[key] = this._metaData[key]; - } - } - }).bind(this)); - - // clear editor content on reset - EventHandler.add('com.woltlab.wcf.redactor2', 'reset_' + this._element.id, this.hideOverlay.bind(this)); - - document.addEventListener('visibilitychange', this._onVisibilityChange.bind(this)); - }, - - _onVisibilityChange: function () { - if (document.hidden) { - this._isActive = false; - this._isPending = true; - } - else { - this._isActive = true; - this._isPending = false; - } - }, - - /** - * Returns the initial value for the textarea, used to inject message - * from storage into the editor before initialization. - * - * @return {string} message content - */ - getInitialValue: function() { - //noinspection JSUnresolvedVariable - if (window.ENABLE_DEVELOPER_TOOLS && Devtools._internal_.editorAutosave() === false) { - //noinspection JSUnresolvedVariable - return this._element.value; - } - - var value = ''; - try { - value = window.localStorage.getItem(this._key); - } - catch (e) { - window.console.warn("Unable to access local storage: " + e.message); - } - - try { - value = JSON.parse(value); - } - catch (e) { - value = ''; - } - - // Check if the storage is outdated. - if (value !== null && typeof value === 'object' && value.content) { - var lastEditTime = ~~elData(this._element, 'autosave-last-edit-time'); - if (lastEditTime * 1000 <= value.timestamp) { - // Compare the stored version with the editor content, but only use the `innerText` property - // in order to ignore differences in whitespace, e. g. caused by indentation of HTML tags. - var div1 = elCreate('div'); - div1.innerHTML = this._element.value; - var div2 = elCreate('div'); - div2.innerHTML = value.content; - - if (div1.innerText.trim() !== div2.innerText.trim()) { - //noinspection JSUnresolvedVariable - this._originalMessage = this._element.value; - this._restored = true; - - this._metaData = value.meta || {}; - - return value.content; - } - } - } - - //noinspection JSUnresolvedVariable - return this._element.value; - }, - - /** - * Returns the stored meta data. - * - * @return {Object} - */ - getMetaData: function () { - return this._metaData; - }, - - /** - * Enables periodical save of editor contents to local storage. - * - * @param {$.Redactor} editor redactor instance - */ - watch: function(editor) { - this._editor = editor; - - if (this._timer !== null) { - throw new Error("Autosave timer is already active."); - } - - this._timer = window.setInterval(this._saveToStorage.bind(this), _frequency * 1000); - - this._saveToStorage(); - - this._isPending = false; - }, - - /** - * Disables autosave handler, for use on editor destruction. - */ - destroy: function () { - this.clear(); - - this._editor = null; - - window.clearInterval(this._timer); - this._timer = null; - this._isPending = false; - }, - - /** - * Removed the stored message, for use after a message has been submitted. - */ - clear: function () { - this._metaData = {}; - this._lastMessage = ''; - - try { - window.localStorage.removeItem(this._key); - } - catch (e) { - window.console.warn("Unable to remove from local storage: " + e.message); - } - }, - - /** - * Creates the autosave controls, used to keep or discard the restored draft. - */ - createOverlay: function () { - if (!this._restored) { - return; - } - - var container = elCreate('div'); - container.className = 'redactorAutosaveRestored active'; - - var title = elCreate('span'); - title.textContent = Language.get('wcf.editor.autosave.restored'); - container.appendChild(title); - - var button = elCreate('a'); - button.className = 'jsTooltip'; - button.href = '#'; - button.title = Language.get('wcf.editor.autosave.keep'); - button.innerHTML = ''; - button.addEventListener('click', (function (event) { - event.preventDefault(); - - this.hideOverlay(); - }).bind(this)); - container.appendChild(button); - - button = elCreate('a'); - button.className = 'jsTooltip'; - button.href = '#'; - button.title = Language.get('wcf.editor.autosave.discard'); - button.innerHTML = ''; - button.addEventListener('click', (function (event) { - event.preventDefault(); - - // remove from storage - this.clear(); - - // set code - var content = UiRedactorMetacode.convertFromHtml(this._editor.core.element()[0].id, this._originalMessage); - this._editor.code.start(content); - - // set value - this._editor.core.textarea().val(this._editor.clean.onSync(this._editor.$editor.html())); - - this.hideOverlay(); - }).bind(this)); - container.appendChild(button); - - this._editor.core.box()[0].appendChild(container); - - var callback = (function () { - this._editor.core.editor()[0].removeEventListener('click', callback); - - this.hideOverlay(); - }).bind(this); - this._editor.core.editor()[0].addEventListener('click', callback); - - this._container = container; - }, - - /** - * Hides the autosave controls. - */ - hideOverlay: function () { - if (this._container !== null) { - this._container.classList.remove('active'); - - window.setTimeout((function () { - if (this._container !== null) { - elRemove(this._container); - } - - this._container = null; - this._originalMessage = ''; - }).bind(this), 1000); - } - }, - - /** - * Saves the current message to storage unless there was no change. - * - * @protected - */ - _saveToStorage: function() { - if (!this._isActive) { - if (!this._isPending) return; - - // save one last time before suspending - this._isPending = false; - } - - //noinspection JSUnresolvedVariable - if (window.ENABLE_DEVELOPER_TOOLS && Devtools._internal_.editorAutosave() === false) { - //noinspection JSUnresolvedVariable - return; - } - - var content = this._editor.code.get(); - if (this._editor.utils.isEmpty(content)) { - content = ''; - } - - if (this._lastMessage === content) { - // break if content hasn't changed - return; - } - - if (content === '') { - return this.clear(); - } - - try { - EventHandler.fire('com.woltlab.wcf.redactor2', 'autosaveMetaData_' + this._element.id, this._metaData); - - window.localStorage.setItem(this._key, JSON.stringify({ - content: content, - meta: this._metaData, - timestamp: Date.now() - })); - - this._lastMessage = content; - } - catch (e) { - window.console.warn("Unable to write to local storage: " + e.message); - } - }, - - /** - * Removes stored messages older than one week. - * - * @protected - */ - _cleanup: function () { - var oneWeekAgo = Date.now() - (7 * 24 * 3600 * 1000), removeKeys = []; - var i, key, length, value; - for (i = 0, length = window.localStorage.length; i < length; i++) { - key = window.localStorage.key(i); - - // check if key matches our prefix - if (key.indexOf(Core.getStoragePrefix()) !== 0) { - continue; - } - - try { - value = window.localStorage.getItem(key); - } - catch (e) { - window.console.warn("Unable to access local storage: " + e.message); - } - - try { - value = JSON.parse(value); - } - catch (e) { - value = { timestamp: 0 }; - } - - if (!value || value.timestamp < oneWeekAgo) { - removeKeys.push(key); - } - } - - for (i = 0, length = removeKeys.length; i < length; i++) { - try { - window.localStorage.removeItem(removeKeys[i]); - } - catch (e) { - window.console.warn("Unable to remove from local storage: " + e.message); - } - } - } - }; - - return UiRedactorAutosave; -}); diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.ts new file mode 100644 index 0000000000..f07992fbc4 --- /dev/null +++ b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.ts @@ -0,0 +1,363 @@ +/** + * Manages the autosave process storing the current editor message in the local + * storage to recover it on browser crash or accidental navigation. + * + * @author Alexander Ebert + * @copyright 2001-2019 WoltLab GmbH + * @license GNU Lesser General Public License + * @module WoltLabSuite/Core/Ui/Redactor/Autosave + */ + +import * as Core from "../../Core"; +import Devtools from "../../Devtools"; +import * as EventHandler from "../../Event/Handler"; +import * as Language from "../../Language"; +import { RedactorEditor } from "./Editor"; +import * as UiRedactorMetacode from "./Metacode"; + +interface AutosaveMetaData { + [key: string]: unknown; +} + +interface AutosaveContent { + content: string; + meta: AutosaveMetaData; + timestamp: number; +} + +// time between save requests in seconds +const _frequency = 15; + +class UiRedactorAutosave { + protected _container: HTMLElement | null = null; + protected _editor: RedactorEditor | null = null; + protected readonly _element: HTMLTextAreaElement; + protected _isActive = true; + protected _isPending = false; + protected readonly _key: string; + protected _lastMessage = ""; + protected _metaData: AutosaveMetaData = {}; + protected _originalMessage = ""; + protected _restored = false; + protected _timer: number | null = null; + + /** + * Initializes the autosave handler and removes outdated messages from storage. + * + * @param {Element} element textarea element + */ + constructor(element: HTMLTextAreaElement) { + this._element = element; + this._key = Core.getStoragePrefix() + this._element.dataset.autosave!; + //this._overlay = null; + + this._cleanup(); + + // remove attribute to prevent Redactor's built-in autosave to kick in + delete this._element.dataset.autosave; + + const form = this._element.closest("form"); + if (form !== null) { + form.addEventListener("submit", this.destroy.bind(this)); + } + + // export meta data + EventHandler.add("com.woltlab.wcf.redactor2", `getMetaData_${this._element.id}`, (data: AutosaveMetaData) => { + Object.entries(this._metaData).forEach(([key, value]) => { + data[key] = value; + }); + }); + + // clear editor content on reset + EventHandler.add("com.woltlab.wcf.redactor2", `reset_${this._element.id}`, () => this.hideOverlay()); + + document.addEventListener("visibilitychange", () => this._onVisibilityChange()); + } + + protected _onVisibilityChange(): void { + if (document.hidden) { + this._isActive = false; + this._isPending = true; + } else { + this._isActive = true; + this._isPending = false; + } + } + + /** + * Returns the initial value for the textarea, used to inject message + * from storage into the editor before initialization. + * + * @return {string} message content + */ + getInitialValue(): string { + if (window.ENABLE_DEVELOPER_TOOLS && !Devtools._internal_.editorAutosave()) { + return this._element.value; + } + + let value = ""; + try { + value = window.localStorage.getItem(this._key) || ""; + } catch (e) { + const errorMessage = (e as Error).message; + window.console.warn(`Unable to access local storage: ${errorMessage}`); + } + + let metaData: AutosaveContent | null = null; + try { + metaData = JSON.parse(value); + } catch (e) { + // We do not care for JSON errors. + } + + // Check if the storage is outdated. + if (metaData !== null && typeof metaData === "object" && metaData.content) { + const lastEditTime = ~~this._element.dataset.autosaveLastEditTime!; + if (lastEditTime * 1_000 <= metaData.timestamp) { + // Compare the stored version with the editor content, but only use the `innerText` property + // in order to ignore differences in whitespace, e. g. caused by indentation of HTML tags. + const div1 = document.createElement("div"); + div1.innerHTML = this._element.value; + const div2 = document.createElement("div"); + div2.innerHTML = metaData.content; + + if (div1.innerText.trim() !== div2.innerText.trim()) { + this._originalMessage = this._element.value; + this._restored = true; + + this._metaData = metaData.meta || {}; + + return metaData.content; + } + } + } + + return this._element.value; + } + + /** + * Returns the stored meta data. + */ + getMetaData(): AutosaveMetaData { + return this._metaData; + } + + /** + * Enables periodical save of editor contents to local storage. + */ + watch(editor: RedactorEditor): void { + this._editor = editor; + + if (this._timer !== null) { + throw new Error("Autosave timer is already active."); + } + + this._timer = window.setInterval(() => this._saveToStorage(), _frequency * 1_000); + + this._saveToStorage(); + + this._isPending = false; + } + + /** + * Disables autosave handler, for use on editor destruction. + */ + destroy(): void { + this.clear(); + + this._editor = null; + + if (this._timer) { + window.clearInterval(this._timer); + } + + this._timer = null; + this._isPending = false; + } + + /** + * Removed the stored message, for use after a message has been submitted. + */ + clear(): void { + this._metaData = {}; + this._lastMessage = ""; + + try { + window.localStorage.removeItem(this._key); + } catch (e) { + const errorMessage = (e as Error).message; + window.console.warn(`Unable to remove from local storage: ${errorMessage}`); + } + } + + /** + * Creates the autosave controls, used to keep or discard the restored draft. + */ + createOverlay(): void { + if (!this._restored) { + return; + } + + const editor = this._editor!; + + const container = document.createElement("div"); + container.className = "redactorAutosaveRestored active"; + + const title = document.createElement("span"); + title.textContent = Language.get("wcf.editor.autosave.restored"); + container.appendChild(title); + + const buttonKeep = document.createElement("a"); + buttonKeep.className = "jsTooltip"; + buttonKeep.href = "#"; + buttonKeep.title = Language.get("wcf.editor.autosave.keep"); + buttonKeep.innerHTML = ''; + buttonKeep.addEventListener("click", (event) => { + event.preventDefault(); + + this.hideOverlay(); + }); + container.appendChild(buttonKeep); + + const buttonDiscard = document.createElement("a"); + buttonDiscard.className = "jsTooltip"; + buttonDiscard.href = "#"; + buttonDiscard.title = Language.get("wcf.editor.autosave.discard"); + buttonDiscard.innerHTML = ''; + buttonDiscard.addEventListener("click", (event) => { + event.preventDefault(); + + // remove from storage + this.clear(); + + // set code + const content = UiRedactorMetacode.convertFromHtml(editor.core.element()[0].id, this._originalMessage); + editor.code.start(content); + + // set value + editor.core.textarea().val(editor.clean.onSync(editor.$editor.html())); + + this.hideOverlay(); + }); + container.appendChild(buttonDiscard); + + editor.core.box()[0].appendChild(container); + + editor.core.editor()[0].addEventListener("click", () => this.hideOverlay(), { once: true }); + + this._container = container; + } + + /** + * Hides the autosave controls. + */ + hideOverlay(): void { + if (this._container !== null) { + this._container.classList.remove("active"); + + window.setTimeout(() => { + if (this._container !== null) { + this._container.remove(); + } + + this._container = null; + this._originalMessage = ""; + }, 1_000); + } + } + + /** + * Saves the current message to storage unless there was no change. + */ + protected _saveToStorage(): void { + if (!this._isActive) { + if (!this._isPending) { + return; + } + + // save one last time before suspending + this._isPending = false; + } + + //noinspection JSUnresolvedVariable + if (window.ENABLE_DEVELOPER_TOOLS && !Devtools._internal_.editorAutosave()) { + return; + } + + const editor = this._editor!; + let content = editor.code.get(); + if (editor.utils.isEmpty(content)) { + content = ""; + } + + if (this._lastMessage === content) { + // break if content hasn't changed + return; + } + + if (content === "") { + return this.clear(); + } + + try { + EventHandler.fire("com.woltlab.wcf.redactor2", `autosaveMetaData_${this._element.id}`, this._metaData); + + window.localStorage.setItem( + this._key, + JSON.stringify({ + content: content, + meta: this._metaData, + timestamp: Date.now(), + } as AutosaveContent), + ); + + this._lastMessage = content; + } catch (e) { + const errorMessage = (e as Error).message; + window.console.warn(`Unable to write to local storage: ${errorMessage}`); + } + } + + /** + * Removes stored messages older than one week. + */ + protected _cleanup(): void { + const oneWeekAgo = Date.now() - 7 * 24 * 3_600 * 1_000; + + Object.keys(window.localStorage).forEach((key) => { + // check if key matches our prefix + if (!key.startsWith(Core.getStoragePrefix())) { + return; + } + + let value = ""; + try { + value = window.localStorage.getItem(key) || ""; + } catch (e) { + const errorMessage = (e as Error).message; + window.console.warn(`Unable to access local storage: ${errorMessage}`); + } + + let timestamp = 0; + try { + const content: AutosaveContent = JSON.parse(value); + timestamp = content.timestamp; + } catch (e) { + // We do not care for JSON errors. + } + + if (!value || timestamp < oneWeekAgo) { + try { + window.localStorage.removeItem(key); + } catch (e) { + const errorMessage = (e as Error).message; + window.console.warn(`Unable to remove from local storage: ${errorMessage}`); + } + } + }); + } +} + +Core.enableLegacyInheritance(UiRedactorAutosave); + +export = UiRedactorAutosave; diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Editor.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Editor.ts index e0f968cd8f..338f40c154 100644 --- a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Editor.ts +++ b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Editor.ts @@ -1,8 +1,26 @@ export interface RedactorEditor { + $editor: JQuery; + buffer: { set: () => void; }; + clean: { + onSync: (html: string) => string; + }; + code: { + get: () => string; + start: (html: string) => void; + }; + core: { + box: () => JQuery; + editor: () => JQuery; + element: () => JQuery; + textarea: () => JQuery; + }; insert: { text: (text: string) => void; }; + utils: { + isEmpty: (html: string) => boolean; + }; } -- 2.20.1