+++ /dev/null
-/**
- * Bootstraps WCF's JavaScript with additions for the ACP usage.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/ACP/Bootstrap
- */
-define(['WoltLab/WCF/Bootstrap'], function(Bootstrap) {
- "use strict";
-
- /**
- * ACP Boostrapper.
- *
- * @exports WoltLab/WCF/ACP/Bootstrap
- */
- var ACPBootstrap = {
- /**
- * Bootstraps general modules and frontend exclusive ones.
- *
- * @param {object<string, *>} options bootstrap options
- */
- setup: function(options) {
- Bootstrap.setup();
- }
- };
-
- return ACPBootstrap;
-});
--- /dev/null
+/**
+ * Bootstraps WCF's JavaScript with additions for the ACP usage.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/ACP/Bootstrap
+ */
+define(['WoltLab/WCF/Bootstrap'], function(Bootstrap) {
+ "use strict";
+
+ /**
+ * ACP Boostrapper.
+ *
+ * @exports WoltLab/WCF/ACP/Bootstrap
+ */
+ var ACPBootstrap = {
+ /**
+ * Bootstraps general modules and frontend exclusive ones.
+ *
+ * @param {object<string, *>} options bootstrap options
+ */
+ setup: function(options) {
+ Bootstrap.setup();
+ }
+ };
+
+ return ACPBootstrap;
+});
+++ /dev/null
-/**
- * Converts a message containing HTML tags into BBCodes.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/BBCode/FromHtml
- */
-define(['EventHandler', 'StringUtil', 'DOM/Traverse'], function(EventHandler, StringUtil, DOMTraverse) {
- "use strict";
-
- var _converter = [];
- var _inlineConverter = {};
- var _sourceConverter = [];
-
- /**
- * Returns true if a whitespace should be inserted before or after the smiley.
- *
- * @param {Element} element image element
- * @param {boolean} before evaluate previous node
- * @return {boolean} true if a whitespace should be inserted
- */
- function addSmileyPadding(element, before) {
- var target = element[(before ? 'previousSibling' : 'nextSibling')];
- if (target === null || target.nodeType !== Node.TEXT_NODE || !/\s$/.test(target.textContent)) {
- return true;
- }
-
- return false;
- }
-
- /**
- * @module WoltLab/WCF/BBCode/FromHtml
- */
- var BBCodeFromHtml = {
- /**
- * Converts a message containing HTML elements into BBCodes.
- *
- * @param {string} message message containing HTML elements
- * @return {string} message containing BBCodes
- */
- convert: function(message) {
- if (message.length) this._setup();
-
- var container = document.createElement('div');
- container.innerHTML = message;
-
- // convert line breaks
- var elements = container.getElementsByTagName('P');
- while (elements.length) elements[0].outerHTML = elements[0].innerHTML;
-
- elements = container.getElementsByTagName('BR');
- while (elements.length) elements[0].outerHTML = "\n";
-
- // prevent conversion taking place inside source bbcodes
- var sourceElements = this._preserveSourceElements(container);
-
- EventHandler.fire('com.woltlab.wcf.bbcode.fromHtml', 'beforeConvert', { container: container });
-
- for (var i = 0, length = _converter.length; i < length; i++) {
- this._convert(container, _converter[i]);
- }
-
- EventHandler.fire('com.woltlab.wcf.bbcode.fromHtml', 'afterConvert', { container: container });
-
- this._restoreSourceElements(container, sourceElements);
-
- // remove remaining HTML elements
- elements = container.getElementsByTagName('*');
- while (elements.length) elements[0].outerHTML = elements[0].innerHTML;
-
- message = this._convertSpecials(container.innerHTML);
-
- return message;
- },
-
- /**
- * Replaces HTML elements mapping to source BBCodes to avoid
- * them being handled by other converters.
- *
- * @param {Element} container container element
- * @return {array<object>} list of source elements and their placeholder
- */
- _preserveSourceElements: function(container) {
- var elements, sourceElements = [], tmp;
-
- for (var i = 0, length = _sourceConverter.length; i < length; i++) {
- elements = container.querySelectorAll(_sourceConverter[i].selector);
-
- tmp = [];
- for (var j = 0, innerLength = elements.length; j < innerLength; j++) {
- this._preserveSourceElement(elements[j], tmp);
- }
-
- sourceElements.push(tmp);
- }
-
- return sourceElements;
- },
-
- /**
- * Replaces an element with a placeholder.
- *
- * @param {Element} element target element
- * @param {array<object>} list of removed elements and their placeholders
- */
- _preserveSourceElement: function(element, sourceElements) {
- var placeholder = document.createElement('var');
- placeholder.setAttribute('data-source', 'wcf');
- element.parentNode.insertBefore(placeholder, element);
-
- var fragment = document.createDocumentFragment();
- fragment.appendChild(element);
-
- sourceElements.push({
- fragment: fragment,
- placeholder: placeholder
- });
- },
-
- /**
- * Reinserts source elements for parsing.
- *
- * @param {Element} container container element
- * @param {array<object>} sourceElements list of removed elements and their placeholders
- */
- _restoreSourceElements: function(container, sourceElements) {
- var element, elements, placeholder;
- for (var i = 0, length = sourceElements.length; i < length; i++) {
- elements = sourceElements[i];
-
- if (elements.length === 0) {
- continue;
- }
-
- for (var j = 0, innerLength = elements.length; j < innerLength; j++) {
- element = elements[j];
- placeholder = element.placeholder;
-
- placeholder.parentNode.insertBefore(element.fragment, placeholder);
-
- _sourceConverter[i].callback(placeholder.previousElementSibling);
-
- placeholder.parentNode.removeChild(placeholder);
- }
- }
- },
-
- /**
- * Converts special entities.
- *
- * @param {string} message HTML message
- * @return {string} HTML message
- */
- _convertSpecials: function(message) {
- message = message.replace(/&/g, '&');
- message = message.replace(/</g, '<');
- message = message.replace(/>/g, '>');
-
- return message;
- },
-
- /**
- * Sets up converters applied to elements in linear order.
- */
- _setup: function() {
- if (_converter.length) {
- return;
- }
-
- _converter = [
- // simple replacement
- { tagName: 'STRONG', bbcode: 'b' },
- { tagName: 'DEL', bbcode: 's' },
- { tagName: 'EM', bbcode: 'i' },
- { tagName: 'SUB', bbcode: 'sub' },
- { tagName: 'SUP', bbcode: 'sup' },
- { tagName: 'U', bbcode: 'u' },
- { tagName: 'KBD', bbcode: 'tt' },
-
- // callback replacement
- { tagName: 'A', callback: this._convertUrl.bind(this) },
- { tagName: 'IMG', callback: this._convertImage.bind(this) },
- { tagName: 'LI', callback: this._convertListItem.bind(this) },
- { tagName: 'OL', callback: this._convertList.bind(this) },
- { tagName: 'TABLE', callback: this._convertTable.bind(this) },
- { tagName: 'UL', callback: this._convertList.bind(this) },
- { tagName: 'BLOCKQUOTE', callback: this._convertBlockquote.bind(this) },
-
- // convert these last
- { tagName: 'SPAN', callback: this._convertSpan.bind(this) },
- { tagName: 'DIV', callback: this._convertDiv.bind(this) }
- ];
-
- _inlineConverter = {
- span: [
- { style: 'color', callback: this._convertInlineColor.bind(this) },
- { style: 'font-size', callback: this._convertInlineFontSize.bind(this) },
- { style: 'font-family', callback: this._convertInlineFontFamily.bind(this) }
- ],
- div: [
- { style: 'text-align', callback: this._convertInlineTextAlign.bind(this) }
- ]
- };
-
- _sourceConverter = [
- { selector: 'div.codeBox', callback: this._convertSourceCodeBox.bind(this) }
- ];
-
- EventHandler.fire('com.woltlab.wcf.bbcode.fromHtml', 'init', {
- converter: _converter,
- inlineConverter: _inlineConverter,
- sourceConverter: _sourceConverter
- });
- },
-
- /**
- * Converts an element into a raw string.
- *
- * @param {Element} container container element
- * @param {object} converter converter object
- */
- _convert: function(container, converter) {
- if (typeof converter === 'function') {
- converter(container);
- return;
- }
-
- var element, elements = container.getElementsByTagName(converter.tagName);
- while (elements.length) {
- element = elements[0];
-
- if (converter.bbcode) {
- element.outerHTML = '[' + converter.bbcode + ']' + element.innerHTML + '[/' + converter.bbcode + ']';
- }
- else {
- converter.callback(element);
- }
- }
- },
-
- /**
- * Converts <blockquote> into [quote].
- *
- * @param {Element} element target element
- */
- _convertBlockquote: function(element) {
- var author = element.getAttribute('data-author') || '';
- var link = element.getAttribute('cite') || '';
-
- var open = '[quote]';
- if (author) {
- author = StringUtil.escapeHTML(author).replace(/(\\)?'/g, function(match, isEscaped) { return isEscaped ? match : "\\'"; });
- if (link) {
- open = "[quote='" + author + "','" + StringUtil.escapeHTML(link) + "']";
- }
- else {
- open = "[quote='" + author + "']";
- }
- }
-
- var header = DOMTraverse.childByTag(element, 'HEADER');
- if (header !== null) element.removeChild(header);
-
- var divs = DOMTraverse.childrenByTag(element, 'DIV');
- for (var i = 0, length = divs.length; i < length; i++) {
- divs[i].outerHTML = divs[i].innerHTML + '\n';
- }
-
- element.outerHTML = open + element.innerHTML.replace(/^\n*/, '').replace(/\n*$/, '') + '[/quote]\n';
- },
-
- /**
- * Converts <img> into smilies, [attach] or [img].
- *
- * @param {Element} element target element
- */
- _convertImage: function(element) {
- if (element.classList.contains('smiley')) {
- // smiley
- element.outerHTML = (addSmileyPadding(element, true) ? ' ' : '') + element.getAttribute('alt') + (addSmileyPadding(element, false) ? ' ' : '');
- return;
- }
-
- var float = element.style.getPropertyValue('float') || 'none';
- var width = element.style.getPropertyValue('width');
- width = (typeof width === 'string') ? ~~width.replace(/px$/, '') : 0;
-
- if (element.classList.contains('redactorEmbeddedAttachment')) {
- var attachmentId = element.getAttribute('data-attachment-id');
-
- if (width > 0) {
- element.outerHTML = "[attach=" + attachmentId + "," + float + "," + width + "][/attach]";
- }
- else if (float !== 'none') {
- element.outerHTML = "[attach=" + attachmentId + "," + float + "][/attach]";
- }
- else {
- element.outerHTML = "[attach=" + attachmentId + "][/attach]";
- }
- }
- else {
- // regular image
- var source = element.src.trim();
-
- if (width > 0) {
- element.outerHTML = "[img='" + source + "'," + float + "," + width + "][/img]";
- }
- else if (float !== 'none') {
- element.outerHTML = "[img='" + source + "'," + float + "][/img]";
- }
- else {
- element.outerHTML = "[img]" + source + "[/img]";
- }
- }
- },
-
- /**
- * Converts <ol> and <ul> into [list].
- *
- * @param {Element} element target element
- */
- _convertList: function(element) {
- var open;
-
- if (element.nodeName === 'OL') {
- open = '[list=1]';
- }
- else {
- var type = element.style.getPropertyValue('list-style-type') || '';
- if (type === '') {
- open = '[list]';
- }
- else {
- open = '[list=' + (type === 'lower-latin' ? 'a' : type) + ']';
- }
- }
-
- element.outerHTML = open + element.innerHTML + '[/list]';
- },
-
- /**
- * Converts <li> into [*] unless it is not encapsulated in <ol> or <ul>.
- *
- * @param {Element} element target element
- */
- _convertListItem: function(element) {
- if (element.parentNode.nodeName !== 'UL' && element.parentNode.nodeName !== 'OL') {
- element.outerHTML = element.innerHTML;
- }
- else {
- element.outerHTML = '[*]' + element.innerHTML;
- }
- },
-
- /**
- * Converts <span> into a series of BBCodes including [color], [font] and [size].
- *
- * @param {Element} element target element
- */
- _convertSpan: function(element) {
- if (element.style.length || element.className) {
- var converter, value;
- for (var i = 0, length = _inlineConverter.span.length; i < length; i++) {
- converter = _inlineConverter.span[i];
-
- if (converter.style) {
- value = element.style.getPropertyValue(converter.style) || '';
- if (value) {
- converter.callback(element, value);
- }
- }
- else {
- if (element.classList.contains(converter.className)) {
- converter.callback(element);
- }
- }
- }
- }
-
- element.outerHTML = element.innerHTML;
- },
-
- /**
- * Converts <div> into a series of BBCodes including [align].
- *
- * @param {Element} element target element
- */
- _convertDiv: function(element) {
- if (element.className.length || element.style.length) {
- var converter, value;
- for (var i = 0, length = _inlineConverter.div.length; i < length; i++) {
- converter = _inlineConverter.div[i];
-
- if (converter.className && element.classList.contains(converter.className)) {
- converter.callback(element);
- }
- else if (converter.style) {
- value = element.style.getPropertyValue(converter.style) || '';
- if (value) {
- converter.callback(element, value);
- }
- }
- }
- }
-
- element.outerHTML = element.innerHTML;
- },
-
- /**
- * Converts the CSS style `color` into [color].
- *
- * @param {Element} element target element
- */
- _convertInlineColor: function(element, value) {
- if (value.match(/^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/i)) {
- var r = RegExp.$1;
- var g = RegExp.$2;
- var b = RegExp.$3;
-
- var chars = '0123456789ABCDEF';
- value = '#' + (chars.charAt((r - r % 16) / 16) + '' + chars.charAt(r % 16)) + '' + (chars.charAt((g - g % 16) / 16) + '' + chars.charAt(g % 16)) + '' + (chars.charAt((b - b % 16) / 16) + '' + chars.charAt(b % 16));
- }
-
- element.innerHTML = '[color=' + value + ']' + element.innerHTML + '[/color]';
- },
-
- /**
- * Converts the CSS style `font-size` into [size].
- *
- * @param {Element} element target element
- */
- _convertInlineFontSize: function(element, value) {
- if (value.match(/^(\d+)pt$/)) {
- value = RegExp.$1;
- }
- else if (value.match(/^(\d+)(px|em|rem|%)$/)) {
- value = window.getComputedStyle(value).fontSize.replace(/^(\d+).*$/, '$1');
- value = Math.round(value);
- }
- else {
- // unknown or unsupported value, ignore
- value = '';
- }
-
- if (value) {
- // min size is 8 and maximum is 36
- value = Math.min(Math.max(value, 8), 36);
-
- element.innerHTML = '[size=' + value + ']' + element.innerHTML + '[/size]';
- }
- },
-
- /**
- * Converts the CSS style `font-family` into [font].
- *
- * @param {Element} element target element
- */
- _convertInlineFontFamily: function(element, value) {
- element.innerHTML = '[font=' + value.replace(/'/g, '') + ']' + element.innerHTML + '[/font]';
- },
-
- /**
- * Converts the CSS style `text-align` into [align].
- *
- * @param {Element} element target element
- */
- _convertInlineTextAlign: function(element, value) {
- if (['center', 'justify', 'left', 'right'].indexOf(value) !== -1) {
- element.innerHTML = '[align=' + value + ']' + element.innerHTML + '[/align]';
- }
- },
-
- /**
- * Converts tables and their children into BBCodes.
- *
- * @param {Element} element target element
- */
- _convertTable: function(element) {
- var elements = element.getElementsByTagName('TD');
- while (elements.length) {
- elements[0].outerHTML = '[td]' + elements[0].innerHTML + '[/td]\n';
- }
-
- elements = element.getElementsByTagName('TR');
- while (elements.length) {
- elements[0].outerHTML = '\n[tr]\n' + elements[0].innerHTML + '[/tr]';
- }
-
- var tbody = DOMTraverse.childByTag(element, 'TBODY');
- var innerHtml = (tbody === null) ? element.innerHTML : tbody.innerHTML;
- element.outerHTML = '\n[table]' + innerHtml + '\n[/table]\n';
- },
-
- /**
- * Converts <a> into [email] or [url].
- *
- * @param {Element} element target element
- */
- _convertUrl: function(element) {
- var content = element.textContent.trim(), href = element.href.trim(), tagName = 'url';
-
- if (href === '' || content === '') {
- // empty href or content
- element.outerHTML = element.innerHTML;
- return;
- }
-
- if (href.indexOf('mailto:') === 0) {
- href = href.substr(7);
- tagName = 'email';
- }
-
- if (href === content) {
- element.outerHTML = '[' + tagName + ']' + href + '[/' + tagName + ']';
- }
- else {
- element.outerHTML = "[" + tagName + "='" + href + "']" + element.innerHTML + "[/" + tagName + "]";
- }
- },
-
- /**
- * Converts <div class="codeBox"> into [code].
- *
- * @param {Element} element target element
- */
- _convertSourceCodeBox: function(element) {
- var filename = element.getAttribute('data-filename').trim() || '';
- var highlighter = element.getAttribute('data-highlighter') || '';
- window.dtdesign = element;
- var list = DOMTraverse.childByTag(element.children[0], 'OL');
- var lineNumber = ~~list.getAttribute('start') || 1;
-
- var content = '';
- for (var i = 0, length = list.childElementCount; i < length; i++) {
- if (content) content += "\n";
- content += list.children[i].textContent;
- }
-
- var open = "[code='" + highlighter + "'," + lineNumber + ",'" + filename + "']";
-
- element.outerHTML = open + content + '[/code]';
- }
- };
-
- return BBCodeFromHtml;
-});
+++ /dev/null
-/**
- * Versatile BBCode parser based upon the PHP implementation.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/BBCode/Parser
- */
-define([], function() {
- "use strict";
-
- /**
- * @module WoltLab/WCF/BBCode/Parser
- */
- var BBCodeParser = {
- /**
- * Parses a message and returns an XML-conform linear tree.
- *
- * @param {string} message message containing BBCodes
- * @return {array<mixed>} linear tree
- */
- parse: function(message) {
- var stack = this._splitTags(message);
- this._buildLinearTree(stack);
-
- return stack;
- },
-
- /**
- * Splits message into strings and BBCode objects.
- *
- * @param {string} message message containing BBCodes
- * @returns {array<mixed>} linear tree
- */
- _splitTags: function(message) {
- var validTags = __REDACTOR_BBCODES.join('|');
- var pattern = '(\\\[(?:/(?:' + validTags + ')|(?:' + validTags + ')'
- + '(?:='
- + '(?:\\\'[^\\\'\\\\]*(?:\\\\.[^\\\'\\\\]*)*\\\'|[^,\\\]]*)'
- + '(?:,(?:\\\'[^\\\'\\\\]*(?:\\\\.[^\\\'\\\\]*)*\'|[^,\\\]]*))*'
- + ')?)\\\])';
-
- var isBBCode = new RegExp('^' + pattern + '$', 'i');
- var part, parts = message.split(new RegExp(pattern, 'i')), stack = [], tag;
- for (var i = 0, length = parts.length; i < length; i++) {
- part = parts[i];
-
- if (part === '') {
- continue;
- }
- else if (part.match(isBBCode)) {
- tag = { name: '', closing: false, attributes: [], source: part };
-
- if (part[1] === '/') {
- tag.name = part.substring(2, part.length - 1);
- tag.closing = true;
- }
- else if (part.match(/^\[([a-z0-9]+)=?(.*)\]$/i)) {
- tag.name = RegExp.$1;
-
- if (RegExp.$2) {
- tag.attributes = this._parseAttributes(RegExp.$2);
- }
- }
-
- stack.push(tag);
- }
- else {
- stack.push(part);
- }
- }
-
- return stack;
- },
-
- /**
- * Finds pairs and enforces XML-conformity in terms of pairing and proper nesting.
- *
- * @param {array<mixed>} stack linear tree
- */
- _buildLinearTree: function(stack) {
- var item, openTags = [], reopenTags, sourceBBCode = '';
- for (var i = 0; i < stack.length; i++) { // do not cache stack.length, its size is dynamic
- item = stack[i];
-
- if (typeof item === 'object') {
- if (sourceBBCode.length && (item.name !== sourceBBCode || !item.closing)) {
- stack[i] = item.source;
- continue;
- }
-
- if (item.closing) {
- if (this._hasOpenTag(openTags, item.name)) {
- reopenTags = this._closeUnclosedTags(stack, openTags, item.name);
- for (var j = 0, innerLength = reopenTags.length; j < innerLength; j++) {
- stack.splice(i, reopenTags[j]);
- i++;
- }
-
- openTags.pop().pair = i;
- }
- else {
- // tag was never opened, treat as plain text
- stack[i] = item.source;
- }
-
- if (sourceBBCode === item.name) {
- sourceBBCode = '';
- }
- }
- else {
- openTags.push(item);
-
- if (__REDACTOR_SOURCE_BBCODES.indexOf(item.name) !== -1) {
- sourceBBCode = item.name;
- }
- }
- }
- }
-
- // close unclosed tags
- this._closeUnclosedTags(stack, openTags, '');
- },
-
- /**
- * Closes unclosed BBCodes and returns a list of BBCodes in order of appearance that should be
- * opened again to enforce proper nesting.
- *
- * @param {array<mixed>} stack linear tree
- * @param {array<object>} openTags list of unclosed elements
- * @param {string} until tag name to stop at
- * @return {array<mixed>} list of tags to open in order of appearance
- */
- _closeUnclosedTags: function(stack, openTags, until) {
- var item, reopenTags = [], tag;
-
- for (var i = openTags.length - 1; i >= 0; i--) {
- item = openTags[i];
-
- if (item.name === until) {
- break;
- }
-
- tag = { name: item.name, closing: true, attributes: item.attributes.slice(), source: '[/' + item.name + ']' };
- item.pair = stack.length;
-
- stack.push(tag);
-
- openTags.pop();
- reopenTags.push({ name: item.name, closing: false, attributes: item.attributes.slice(), source: item.source });
- }
-
- return reopenTags.reverse();
- },
-
- /**
- * Returns true if given BBCode was opened before.
- *
- * @param {array<object>} openTags list of unclosed elements
- * @param {string} name BBCode to search for
- * @returns {boolean} false if tag was not opened before
- */
- _hasOpenTag: function(openTags, name) {
- for (var i = openTags.length - 1; i >= 0; i--) {
- if (openTags[i].name === name) {
- return true;
- }
- }
-
- return false;
- },
-
- /**
- * Parses the attribute list and returns a list of attributes without enclosing quotes.
- *
- * @param {string} attrString comma separated string with optional quotes per attribute
- * @returns {array<string>} list of attributes
- */
- _parseAttributes: function(attrString) {
- var tmp = attrString.split(/(?:^|,)('[^'\\\\]*(?:\\\\.[^'\\\\]*)*'|[^,]*)/g);
-
- var attribute, attributes = [];
- for (var i = 0, length = tmp.length; i < length; i++) {
- attribute = tmp[i];
-
- if (attribute !== '') {
- if (attribute.charAt(0) === "'" && attribute.substr(-1) === "'") {
- attributes.push(attribute.substring(1, attribute.length - 1).trim());
- }
- else {
- attributes.push(attribute.trim());
- }
- }
- }
-
- return attributes;
- }
- };
-
- return BBCodeParser;
-});
+++ /dev/null
-/**
- * Converts a message containing BBCodes into HTML.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/BBCode/ToHtml
- */
-define(['Core', 'EventHandler', 'Language', 'StringUtil', 'WoltLab/WCF/BBCode/Parser'], function(Core, EventHandler, Language, StringUtil, BBCodeParser) {
- "use strict";
-
- var _bbcodes = null;
- var _options = {};
- var _removeNewlineAfter = [];
- var _removeNewlineBefore = [];
-
- /**
- * Returns true if given value is a non-zero integer.
- *
- * @param {string} value target value
- * @return {boolean} true if `value` is a non-zero integer
- */
- function isNumber(value) {
- return value && value == ~~value;
- }
-
- /**
- * Returns true if given value appears to be a filename, which means that it contains a dot
- * or is neither numeric nor a known highlighter.
- *
- * @param {string} value target value
- * @return {boolean} true if `value` appears to be a filename
- */
- function isFilename(value) {
- return (value.indexOf('.') !== -1) || (!isNumber(value) && !isHighlighter(value));
- }
-
- /**
- * Returns true if given value is a known highlighter.
- *
- * @param {string} value target value
- * @return {boolean} true if `value` is a known highlighter
- */
- function isHighlighter(value) {
- return __REDACTOR_CODE_HIGHLIGHTERS.hasOwnProperty(value);
- }
-
- /**
- * @module WoltLab/WCF/BBCode/ToHtml
- */
- var BBCodeToHtml = {
- /**
- * Converts a message containing BBCodes to HTML.
- *
- * @param {string} message message containing BBCodes
- * @return {string} HTML message
- */
- convert: function(message, options) {
- _options = Core.extend({
- attachments: {
- images: {},
- thumbnailUrl: '',
- url: ''
- }
- }, options);
-
- this._convertSpecials(message);
-
- var stack = BBCodeParser.parse(message);
-
- if (stack.length) {
- this._initBBCodes();
- }
-
- EventHandler.fire('com.woltlab.wcf.bbcode.toHtml', 'beforeConvert', { stack: stack });
-
- var item, value;
- for (var i = 0, length = stack.length; i < length; i++) {
- item = stack[i];
-
- if (typeof item === 'object') {
- value = this._convert(stack, item, i);
- if (Array.isArray(value)) {
- stack[i] = (value[0] === null ? item.source : value[0]);
- stack[item.pair] = (value[1] === null ? stack[item.pair].source : value[1]);
- }
- else {
- stack[i] = value;
- }
- }
- }
-
- EventHandler.fire('com.woltlab.wcf.bbcode.toHtml', 'afterConvert', { stack: stack });
-
- message = stack.join('');
-
- message = message.replace(/\n/g, '<br>');
-
- return message;
- },
-
- /**
- * Converts special characters to their entities.
- *
- * @param {string} message message containing BBCodes
- * @return {string} message with replaced special characters
- */
- _convertSpecials: function(message) {
- message = message.replace(/&/g, '&');
- message = message.replace(/</g, '<');
- message = message.replace(/>/g, '>');
-
- return message;
- },
-
- /**
- * Sets up converters applied to HTML elements.
- */
- _initBBCodes: function() {
- if (_bbcodes !== null) {
- return;
- }
-
- _bbcodes = {
- // simple replacements
- b: 'strong',
- i: 'em',
- u: 'u',
- s: 'del',
- sub: 'sub',
- sup: 'sup',
- table: 'table',
- td: 'td',
- tr: 'tr',
- tt: 'kbd',
-
- // callback replacement
- align: this._convertAlignment.bind(this),
- attach: this._convertAttachment.bind(this),
- color: this._convertColor.bind(this),
- code: this._convertCode.bind(this),
- email: this._convertEmail.bind(this),
- list: this._convertList.bind(this),
- quote: this._convertQuote.bind(this),
- size: this._convertSize.bind(this),
- url: this._convertUrl.bind(this),
- img: this._convertImage.bind(this)
- };
-
- _removeNewlineAfter = ['quote', 'table', 'td', 'tr'];
- _removeNewlineBefore = ['table', 'td', 'tr'];
-
- EventHandler.fire('com.woltlab.wcf.bbcode.toHtml', 'init', {
- bbcodes: _bbcodes,
- removeNewlineAfter: _removeNewlineAfter,
- removeNewlineBefore: _removeNewlineBefore
- });
- },
-
- /**
- * Converts an item from the stack.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @return {(string|array)} string if only the current item should be replaced or an array with
- * the first item used for the opening tag and the second item for the closing tag
- */
- _convert: function(stack, item, index) {
- var replace = _bbcodes[item.name], tmp;
-
- if (replace === undefined) {
- // treat as plain text
- return [null, null];
- }
-
- if (_removeNewlineAfter.indexOf(item.name) !== -1) {
- tmp = stack[index + 1];
- if (typeof tmp === 'string') {
- stack[index + 1] = tmp.replace(/^\n/, '');
- }
-
- if (stack.length > item.pair + 1) {
- tmp = stack[item.pair + 1];
- if (typeof tmp === 'string') {
- stack[item.pair + 1] = tmp.replace(/^\n/, '');
- }
- }
- }
-
- if (_removeNewlineBefore.indexOf(item.name) !== -1) {
- if (index - 1 >= 0) {
- tmp = stack[index - 1];
- if (typeof tmp === 'string') {
- stack[index - 1] = tmp.replace(/\n$/, '');
- }
- }
-
- tmp = stack[item.pair - 1];
- if (typeof tmp === 'string') {
- stack[item.pair - 1] = tmp.replace(/\n$/, '');
- }
- }
-
- // replace smilies
- this._convertSmilies(stack);
-
- if (typeof replace === 'string') {
- return ['<' + replace + '>', '</' + replace + '>'];
- }
- else {
- return replace(stack, item, index);
- }
- },
-
- /**
- * Converts [align] into <div style="text-align: ...">.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @returns {array} first item represents the opening tag, the second the closing one
- */
- _convertAlignment: function(stack, item, index) {
- var align = (item.attributes.length) ? item.attributes[0] : '';
- if (['center', 'justify', 'left', 'right'].indexOf(align) === -1) {
- return [null, null];
- }
-
- return ['<div style="text-align: ' + align + '">', '</div>'];
- },
-
- /**
- * Converts [attach] into an <img> or to plain text if attachment is a non-image.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @returns {array} first item represents the opening tag, the second the closing one
- */
- _convertAttachment: function(stack, item, index) {
- var attachmentId = 0, attributes = item.attributes, length = attributes.length;
- if (!_options.attachments.url) {
- length = 0;
- }
- else if (length > 0) {
- attachmentId = ~~attributes[0];
- if (!_options.attachments.images.hasOwnProperty(attachmentId)) {
- length = 0;
- }
- }
-
- if (length === 0) {
- return [null, null];
- }
-
- var maxHeight = ~~_options.attachments.images[attachmentId].height;
- var maxWidth = ~~_options.attachments.images[attachmentId].width;
- var styles = ['max-height: ' + maxHeight + 'px', 'max-width: ' + maxWidth + 'px'];
-
- if (length > 1) {
- if (item.attributes[1] === 'left' || attributes[1] === 'right') {
- styles.push('float: ' + attributes[1]);
- styles.push('margin: ' + (attributes[1] === 'left' ? '0 15px 7px 0' : '0 0 7px 15px'));
- }
- }
-
- var baseUrl = _options.attachments.thumbnailUrl;
- if (length > 2) {
- width = ~~attributes[2] || 0;
- if (width) {
- if (width > maxWidth) width = maxWidth;
-
- styles.push('width: ' + width + 'px');
- baseUrl = _options.attachments.url;
- }
- }
-
- return [
- '<img src="' + baseUrl.replace(/987654321/, attachmentId) + '" class="redactorEmbeddedAttachment redactorDisableResize" data-attachment-id="' + attachmentId + '"' + (styles.length ? ' style="' + styles.join(';') + '"' : '') + '>',
- ''
- ];
- },
-
- /**
- * Converts [code] to <div class="codeBox">.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @returns {array} first item represents the opening tag, the second the closing one
- */
- _convertCode: function(stack, item, index) {
- var attributes = item.attributes, filename = '', highlighter = 'auto', lineNumber = 0;
-
- // parse arguments
- switch (attributes.length) {
- case 1:
- if (isNumber(attributes[0])) {
- lineNumber = ~~attributes[0];
- }
- else if (isFilename(attributes[0])) {
- filename = attributes[0];
- }
- else if (isHighlighter(attributes[0])) {
- highlighter = attributes[0];
- }
- break;
- case 2:
- if (isNumber(attributes[0])) {
- lineNumber = ~~attributes[0];
-
- if (isHighlighter(attributes[1])) {
- highlighter = attributes[1];
- }
- else if (isFilename(attributes[1])) {
- filename = attributes[1];
- }
- }
- else {
- if (isHighlighter(attributes[0])) highlighter = attributes[0];
- if (isFilename(attributes[1])) filename = attributes[1];
- }
- break;
- case 3:
- if (isHighlighter(attributes[0])) highlighter = attributes[0];
- if (isNumber(attributes[1])) lineNumber = ~~attributes[1];
- if (isFilename(attributes[2])) filename = attributes[2];
- break;
- }
-
- // transform content
- var before = true, content, line, empty = -1;
- for (var i = index + 1; i < item.pair; i++) {
- line = stack[i];
-
- if (line.trim() === '') {
- if (before) {
- stack[i] = '';
- continue;
- }
- else if (empty === -1) {
- empty = i;
- }
- }
- else {
- before = false;
- empty = -1;
- }
-
- content = line.split('\n');
- for (var j = 0, innerLength = content.length; j < innerLength; j++) {
- content[j] = '<li>' + (content[j] ? StringUtil.escapeHTML(content[j]) : '\u200b') + '</li>';
- }
-
- stack[i] = content.join('');
- }
-
- if (!before && empty !== -1) {
- for (var i = item.pair - 1; i >= empty; i--) {
- stack[i] = '';
- }
- }
-
- return [
- '<div class="codeBox container" contenteditable="false" data-highlighter="' + highlighter + '" data-filename="' + (filename ? StringUtil.escapeHTML(filename) : '') + '">'
- + '<div>'
- + '<div>'
- + '<h3>' + __REDACTOR_CODE_HIGHLIGHTERS[highlighter] + (filename ? ': ' + StringUtil.escapeHTML(filename) : '') + '</h3>'
- + '</div>'
- + '<ol start="' + (lineNumber > 1 ? lineNumber : 1) + '">',
- '</ol></div></div>'
- ];
- },
-
- /**
- * Converts [color] to <span style="color: ...">.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @returns {array} first item represents the opening tag, the second the closing one
- */
- _convertColor: function(stack, item, index) {
- if (!item.attributes.length || !item.attributes[0].match(/^[a-z0-9#]+$/i)) {
- return [null, null];
- }
-
- return ['<span style="color: ' + StringUtil.escapeHTML(item.attributes[0]) + '">', '</span>'];
- },
-
- /**
- * Converts [email] to <a href="mailto: ...">.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @returns {array} first item represents the opening tag, the second the closing one
- */
- _convertEmail: function(stack, item, index) {
- var email = '';
- if (item.attributes.length) {
- email = item.attributes[0];
- }
- else {
- var element;
- for (var i = index + 1; i < item.pair; i++) {
- element = stack[i];
-
- if (typeof element === 'object') {
- email = '';
- break;
- }
- else {
- email += element;
- }
- }
-
- // no attribute present and element is empty, handle as plain text
- if (email.trim() === '') {
- return [null, null];
- }
- }
-
- return ['<a href="mailto:' + StringUtil.escapeHTML(email) + '">', '</a>'];
- },
-
- /**
- * Converts [img] to <img>.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @returns {array} first item represents the opening tag, the second the closing one
- */
- _convertImage: function(stack, item, index) {
- var float = 'none', source = '', width = 0;
-
- switch (item.attributes.length) {
- case 0:
- if (index + 1 < item.pair && typeof stack[index + 1] === 'string') {
- source = stack[index + 1];
- stack[index + 1] = '';
- }
- else {
- // [img] without attributes and content, discard
- return '';
- }
- break;
-
- case 1:
- source = item.attributes[0];
- break;
-
- case 2:
- source = item.attributes[0];
- float = item.attributes[1];
- break;
-
- case 3:
- source = item.attributes[0];
- float = item.attributes[1];
- width = ~~item.attributes[2];
- break;
- }
-
- if (float !== 'left' && float !== 'right') float = 'none';
-
- var styles = [];
- if (width > 0) {
- styles.push('width: ' + width + 'px');
- }
-
- if (float !== 'none') {
- styles.push('float: ' + float);
- styles.push('margin: ' + (float === 'left' ? '0 15px 7px 0' : '0 0 7px 15px'));
- }
-
- return ['<img src="' + StringUtil.escapeHTML(source) + '"' + (styles.length ? ' style="' + styles.join(';') + '"' : '') + '>', ''];
- },
-
- /**
- * Converts [list] to <ol> or <ul>.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @returns {array} first item represents the opening tag, the second the closing one
- */
- _convertList: function(stack, item, index) {
- var type = (items.attributes.length) ? item.attributes[0] : '';
-
- // replace list items
- for (var i = index + 1; i < item.pair; i++) {
- if (typeof stack[i] === 'string') {
- stack[i] = stack[i].replace(/\[\*\]/g, '<li>');
- }
- }
-
- if (type == '1' || type === 'decimal') {
- return ['<ol>', '</ol>'];
- }
-
- if (type.length && type.match(/^(?:none|circle|square|disc|decimal|lower-roman|upper-roman|decimal-leading-zero|lower-greek|lower-latin|upper-latin|armenian|georgian)$/)) {
- return ['<ul style="list-style-type: ' + type + '">', '</ul>'];
- }
-
- return ['<ul>', '</ul>'];
- },
-
- /**
- * Converts [quote] to <blockquote>.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @returns {array} first item represents the opening tag, the second the closing one
- */
- _convertQuote: function(stack, item, index) {
- var author = '', link = '';
- if (item.attributes.length > 1) {
- author = item.attributes[0];
- link = item.attributes[1];
- }
- else if (item.attributes.length === 1) {
- author = item.attributes[0];
- }
-
- // get rid of the trailing newline for quote content
- for (var i = item.pair - 1; i > index; i--) {
- if (typeof stack[i] === 'string') {
- stack[i] = stack[i].replace(/\n$/, '');
- break;
- }
- }
-
- var header = '';
- if (author) {
- if (link) header = '<a href="' + StringUtil.escapeHTML(link) + '" tabindex="-1">';
- header += Language.get('wcf.bbcode.quote.title.javascript', { quoteAuthor: author.replace(/\\'/g, "'") });
- if (link) header += '</a>';
- }
- else {
- header = '<small>' + Language.get('wcf.bbcode.quote.title.clickToSet') + '</small>';
- }
-
- return [
- '<blockquote class="quoteBox container containerPadding quoteBoxSimple" cite="' + StringUtil.escapeHTML(link) + '" data-author="' + StringUtil.escapeHTML(author) + '">'
- + '<header contenteditable="false">'
- + '<h3>'
- + header
- + '</h3>'
- + '<a class="redactorQuoteEdit"></a>'
- + '</header>'
- + '<div>\u200b',
- '</div></blockquote>'
- ];
- },
-
- /**
- * Converts smiley codes into <img>.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- */
- _convertSmilies: function(stack) {
- var altValue, item, regexp;
- for (var i = 0, length = stack.length; i < length; i++) {
- item = stack[i];
-
- if (typeof item === 'string') {
- for (var smileyCode in __REDACTOR_SMILIES) {
- if (__REDACTOR_SMILIES.hasOwnProperty(smileyCode)) {
- altValue = smileyCode.replace(/</g, '<').replace(/>/g, '>');
- regexp = new RegExp('(\\s|^)' + StringUtil.escapeRegExp(smileyCode) + '(?=\\s|$)', 'gi');
- item = item.replace(regexp, '$1<img src="' + __REDACTOR_SMILIES[smileyCode] + '" class="smiley" alt="' + altValue + '">');
- }
- }
-
- stack[i] = item;
- }
- else if (__REDACTOR_SOURCE_BBCODES.indexOf(item.name) !== -1) {
- // skip processing content
- i = item.pair;
- }
- }
- },
-
- /**
- * Converts [size] to <span style="font-size: ...">.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @returns {array} first item represents the opening tag, the second the closing one
- */
- _convertSize: function(stack, item, index) {
- if (!item.attributes.length || ~~item.attributes[0] === 0) {
- return [null, null];
- }
-
- return ['<span style="font-size: ' + ~~item.attributes[0] + 'pt">', '</span>'];
- },
-
- /**
- * Converts [url] to <a>.
- *
- * @param {array<mixed>} stack linear list of BBCode tags and regular strings
- * @param {object} item current BBCode tag object
- * @param {integer} index current stack index representing `item`
- * @returns {array} first item represents the opening tag, the second the closing one
- */
- _convertUrl: function(stack, item, index) {
- // ignore url bbcode without arguments
- if (!item.attributes.length) {
- return [null, null];
- }
-
- return ['<a href="' + StringUtil.escapeHTML(item.attributes[0]) + '">', '</a>'];
- }
- };
-
- return BBCodeToHtml;
-});
--- /dev/null
+/**
+ * Converts a message containing HTML tags into BBCodes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/BBCode/FromHtml
+ */
+define(['EventHandler', 'StringUtil', 'DOM/Traverse'], function(EventHandler, StringUtil, DOMTraverse) {
+ "use strict";
+
+ var _converter = [];
+ var _inlineConverter = {};
+ var _sourceConverter = [];
+
+ /**
+ * Returns true if a whitespace should be inserted before or after the smiley.
+ *
+ * @param {Element} element image element
+ * @param {boolean} before evaluate previous node
+ * @return {boolean} true if a whitespace should be inserted
+ */
+ function addSmileyPadding(element, before) {
+ var target = element[(before ? 'previousSibling' : 'nextSibling')];
+ if (target === null || target.nodeType !== Node.TEXT_NODE || !/\s$/.test(target.textContent)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @module WoltLab/WCF/BBCode/FromHtml
+ */
+ var BBCodeFromHtml = {
+ /**
+ * Converts a message containing HTML elements into BBCodes.
+ *
+ * @param {string} message message containing HTML elements
+ * @return {string} message containing BBCodes
+ */
+ convert: function(message) {
+ if (message.length) this._setup();
+
+ var container = document.createElement('div');
+ container.innerHTML = message;
+
+ // convert line breaks
+ var elements = container.getElementsByTagName('P');
+ while (elements.length) elements[0].outerHTML = elements[0].innerHTML;
+
+ elements = container.getElementsByTagName('BR');
+ while (elements.length) elements[0].outerHTML = "\n";
+
+ // prevent conversion taking place inside source bbcodes
+ var sourceElements = this._preserveSourceElements(container);
+
+ EventHandler.fire('com.woltlab.wcf.bbcode.fromHtml', 'beforeConvert', { container: container });
+
+ for (var i = 0, length = _converter.length; i < length; i++) {
+ this._convert(container, _converter[i]);
+ }
+
+ EventHandler.fire('com.woltlab.wcf.bbcode.fromHtml', 'afterConvert', { container: container });
+
+ this._restoreSourceElements(container, sourceElements);
+
+ // remove remaining HTML elements
+ elements = container.getElementsByTagName('*');
+ while (elements.length) elements[0].outerHTML = elements[0].innerHTML;
+
+ message = this._convertSpecials(container.innerHTML);
+
+ return message;
+ },
+
+ /**
+ * Replaces HTML elements mapping to source BBCodes to avoid
+ * them being handled by other converters.
+ *
+ * @param {Element} container container element
+ * @return {array<object>} list of source elements and their placeholder
+ */
+ _preserveSourceElements: function(container) {
+ var elements, sourceElements = [], tmp;
+
+ for (var i = 0, length = _sourceConverter.length; i < length; i++) {
+ elements = container.querySelectorAll(_sourceConverter[i].selector);
+
+ tmp = [];
+ for (var j = 0, innerLength = elements.length; j < innerLength; j++) {
+ this._preserveSourceElement(elements[j], tmp);
+ }
+
+ sourceElements.push(tmp);
+ }
+
+ return sourceElements;
+ },
+
+ /**
+ * Replaces an element with a placeholder.
+ *
+ * @param {Element} element target element
+ * @param {array<object>} list of removed elements and their placeholders
+ */
+ _preserveSourceElement: function(element, sourceElements) {
+ var placeholder = document.createElement('var');
+ placeholder.setAttribute('data-source', 'wcf');
+ element.parentNode.insertBefore(placeholder, element);
+
+ var fragment = document.createDocumentFragment();
+ fragment.appendChild(element);
+
+ sourceElements.push({
+ fragment: fragment,
+ placeholder: placeholder
+ });
+ },
+
+ /**
+ * Reinserts source elements for parsing.
+ *
+ * @param {Element} container container element
+ * @param {array<object>} sourceElements list of removed elements and their placeholders
+ */
+ _restoreSourceElements: function(container, sourceElements) {
+ var element, elements, placeholder;
+ for (var i = 0, length = sourceElements.length; i < length; i++) {
+ elements = sourceElements[i];
+
+ if (elements.length === 0) {
+ continue;
+ }
+
+ for (var j = 0, innerLength = elements.length; j < innerLength; j++) {
+ element = elements[j];
+ placeholder = element.placeholder;
+
+ placeholder.parentNode.insertBefore(element.fragment, placeholder);
+
+ _sourceConverter[i].callback(placeholder.previousElementSibling);
+
+ placeholder.parentNode.removeChild(placeholder);
+ }
+ }
+ },
+
+ /**
+ * Converts special entities.
+ *
+ * @param {string} message HTML message
+ * @return {string} HTML message
+ */
+ _convertSpecials: function(message) {
+ message = message.replace(/&/g, '&');
+ message = message.replace(/</g, '<');
+ message = message.replace(/>/g, '>');
+
+ return message;
+ },
+
+ /**
+ * Sets up converters applied to elements in linear order.
+ */
+ _setup: function() {
+ if (_converter.length) {
+ return;
+ }
+
+ _converter = [
+ // simple replacement
+ { tagName: 'STRONG', bbcode: 'b' },
+ { tagName: 'DEL', bbcode: 's' },
+ { tagName: 'EM', bbcode: 'i' },
+ { tagName: 'SUB', bbcode: 'sub' },
+ { tagName: 'SUP', bbcode: 'sup' },
+ { tagName: 'U', bbcode: 'u' },
+ { tagName: 'KBD', bbcode: 'tt' },
+
+ // callback replacement
+ { tagName: 'A', callback: this._convertUrl.bind(this) },
+ { tagName: 'IMG', callback: this._convertImage.bind(this) },
+ { tagName: 'LI', callback: this._convertListItem.bind(this) },
+ { tagName: 'OL', callback: this._convertList.bind(this) },
+ { tagName: 'TABLE', callback: this._convertTable.bind(this) },
+ { tagName: 'UL', callback: this._convertList.bind(this) },
+ { tagName: 'BLOCKQUOTE', callback: this._convertBlockquote.bind(this) },
+
+ // convert these last
+ { tagName: 'SPAN', callback: this._convertSpan.bind(this) },
+ { tagName: 'DIV', callback: this._convertDiv.bind(this) }
+ ];
+
+ _inlineConverter = {
+ span: [
+ { style: 'color', callback: this._convertInlineColor.bind(this) },
+ { style: 'font-size', callback: this._convertInlineFontSize.bind(this) },
+ { style: 'font-family', callback: this._convertInlineFontFamily.bind(this) }
+ ],
+ div: [
+ { style: 'text-align', callback: this._convertInlineTextAlign.bind(this) }
+ ]
+ };
+
+ _sourceConverter = [
+ { selector: 'div.codeBox', callback: this._convertSourceCodeBox.bind(this) }
+ ];
+
+ EventHandler.fire('com.woltlab.wcf.bbcode.fromHtml', 'init', {
+ converter: _converter,
+ inlineConverter: _inlineConverter,
+ sourceConverter: _sourceConverter
+ });
+ },
+
+ /**
+ * Converts an element into a raw string.
+ *
+ * @param {Element} container container element
+ * @param {object} converter converter object
+ */
+ _convert: function(container, converter) {
+ if (typeof converter === 'function') {
+ converter(container);
+ return;
+ }
+
+ var element, elements = container.getElementsByTagName(converter.tagName);
+ while (elements.length) {
+ element = elements[0];
+
+ if (converter.bbcode) {
+ element.outerHTML = '[' + converter.bbcode + ']' + element.innerHTML + '[/' + converter.bbcode + ']';
+ }
+ else {
+ converter.callback(element);
+ }
+ }
+ },
+
+ /**
+ * Converts <blockquote> into [quote].
+ *
+ * @param {Element} element target element
+ */
+ _convertBlockquote: function(element) {
+ var author = element.getAttribute('data-author') || '';
+ var link = element.getAttribute('cite') || '';
+
+ var open = '[quote]';
+ if (author) {
+ author = StringUtil.escapeHTML(author).replace(/(\\)?'/g, function(match, isEscaped) { return isEscaped ? match : "\\'"; });
+ if (link) {
+ open = "[quote='" + author + "','" + StringUtil.escapeHTML(link) + "']";
+ }
+ else {
+ open = "[quote='" + author + "']";
+ }
+ }
+
+ var header = DOMTraverse.childByTag(element, 'HEADER');
+ if (header !== null) element.removeChild(header);
+
+ var divs = DOMTraverse.childrenByTag(element, 'DIV');
+ for (var i = 0, length = divs.length; i < length; i++) {
+ divs[i].outerHTML = divs[i].innerHTML + '\n';
+ }
+
+ element.outerHTML = open + element.innerHTML.replace(/^\n*/, '').replace(/\n*$/, '') + '[/quote]\n';
+ },
+
+ /**
+ * Converts <img> into smilies, [attach] or [img].
+ *
+ * @param {Element} element target element
+ */
+ _convertImage: function(element) {
+ if (element.classList.contains('smiley')) {
+ // smiley
+ element.outerHTML = (addSmileyPadding(element, true) ? ' ' : '') + element.getAttribute('alt') + (addSmileyPadding(element, false) ? ' ' : '');
+ return;
+ }
+
+ var float = element.style.getPropertyValue('float') || 'none';
+ var width = element.style.getPropertyValue('width');
+ width = (typeof width === 'string') ? ~~width.replace(/px$/, '') : 0;
+
+ if (element.classList.contains('redactorEmbeddedAttachment')) {
+ var attachmentId = element.getAttribute('data-attachment-id');
+
+ if (width > 0) {
+ element.outerHTML = "[attach=" + attachmentId + "," + float + "," + width + "][/attach]";
+ }
+ else if (float !== 'none') {
+ element.outerHTML = "[attach=" + attachmentId + "," + float + "][/attach]";
+ }
+ else {
+ element.outerHTML = "[attach=" + attachmentId + "][/attach]";
+ }
+ }
+ else {
+ // regular image
+ var source = element.src.trim();
+
+ if (width > 0) {
+ element.outerHTML = "[img='" + source + "'," + float + "," + width + "][/img]";
+ }
+ else if (float !== 'none') {
+ element.outerHTML = "[img='" + source + "'," + float + "][/img]";
+ }
+ else {
+ element.outerHTML = "[img]" + source + "[/img]";
+ }
+ }
+ },
+
+ /**
+ * Converts <ol> and <ul> into [list].
+ *
+ * @param {Element} element target element
+ */
+ _convertList: function(element) {
+ var open;
+
+ if (element.nodeName === 'OL') {
+ open = '[list=1]';
+ }
+ else {
+ var type = element.style.getPropertyValue('list-style-type') || '';
+ if (type === '') {
+ open = '[list]';
+ }
+ else {
+ open = '[list=' + (type === 'lower-latin' ? 'a' : type) + ']';
+ }
+ }
+
+ element.outerHTML = open + element.innerHTML + '[/list]';
+ },
+
+ /**
+ * Converts <li> into [*] unless it is not encapsulated in <ol> or <ul>.
+ *
+ * @param {Element} element target element
+ */
+ _convertListItem: function(element) {
+ if (element.parentNode.nodeName !== 'UL' && element.parentNode.nodeName !== 'OL') {
+ element.outerHTML = element.innerHTML;
+ }
+ else {
+ element.outerHTML = '[*]' + element.innerHTML;
+ }
+ },
+
+ /**
+ * Converts <span> into a series of BBCodes including [color], [font] and [size].
+ *
+ * @param {Element} element target element
+ */
+ _convertSpan: function(element) {
+ if (element.style.length || element.className) {
+ var converter, value;
+ for (var i = 0, length = _inlineConverter.span.length; i < length; i++) {
+ converter = _inlineConverter.span[i];
+
+ if (converter.style) {
+ value = element.style.getPropertyValue(converter.style) || '';
+ if (value) {
+ converter.callback(element, value);
+ }
+ }
+ else {
+ if (element.classList.contains(converter.className)) {
+ converter.callback(element);
+ }
+ }
+ }
+ }
+
+ element.outerHTML = element.innerHTML;
+ },
+
+ /**
+ * Converts <div> into a series of BBCodes including [align].
+ *
+ * @param {Element} element target element
+ */
+ _convertDiv: function(element) {
+ if (element.className.length || element.style.length) {
+ var converter, value;
+ for (var i = 0, length = _inlineConverter.div.length; i < length; i++) {
+ converter = _inlineConverter.div[i];
+
+ if (converter.className && element.classList.contains(converter.className)) {
+ converter.callback(element);
+ }
+ else if (converter.style) {
+ value = element.style.getPropertyValue(converter.style) || '';
+ if (value) {
+ converter.callback(element, value);
+ }
+ }
+ }
+ }
+
+ element.outerHTML = element.innerHTML;
+ },
+
+ /**
+ * Converts the CSS style `color` into [color].
+ *
+ * @param {Element} element target element
+ */
+ _convertInlineColor: function(element, value) {
+ if (value.match(/^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/i)) {
+ var r = RegExp.$1;
+ var g = RegExp.$2;
+ var b = RegExp.$3;
+
+ var chars = '0123456789ABCDEF';
+ value = '#' + (chars.charAt((r - r % 16) / 16) + '' + chars.charAt(r % 16)) + '' + (chars.charAt((g - g % 16) / 16) + '' + chars.charAt(g % 16)) + '' + (chars.charAt((b - b % 16) / 16) + '' + chars.charAt(b % 16));
+ }
+
+ element.innerHTML = '[color=' + value + ']' + element.innerHTML + '[/color]';
+ },
+
+ /**
+ * Converts the CSS style `font-size` into [size].
+ *
+ * @param {Element} element target element
+ */
+ _convertInlineFontSize: function(element, value) {
+ if (value.match(/^(\d+)pt$/)) {
+ value = RegExp.$1;
+ }
+ else if (value.match(/^(\d+)(px|em|rem|%)$/)) {
+ value = window.getComputedStyle(value).fontSize.replace(/^(\d+).*$/, '$1');
+ value = Math.round(value);
+ }
+ else {
+ // unknown or unsupported value, ignore
+ value = '';
+ }
+
+ if (value) {
+ // min size is 8 and maximum is 36
+ value = Math.min(Math.max(value, 8), 36);
+
+ element.innerHTML = '[size=' + value + ']' + element.innerHTML + '[/size]';
+ }
+ },
+
+ /**
+ * Converts the CSS style `font-family` into [font].
+ *
+ * @param {Element} element target element
+ */
+ _convertInlineFontFamily: function(element, value) {
+ element.innerHTML = '[font=' + value.replace(/'/g, '') + ']' + element.innerHTML + '[/font]';
+ },
+
+ /**
+ * Converts the CSS style `text-align` into [align].
+ *
+ * @param {Element} element target element
+ */
+ _convertInlineTextAlign: function(element, value) {
+ if (['center', 'justify', 'left', 'right'].indexOf(value) !== -1) {
+ element.innerHTML = '[align=' + value + ']' + element.innerHTML + '[/align]';
+ }
+ },
+
+ /**
+ * Converts tables and their children into BBCodes.
+ *
+ * @param {Element} element target element
+ */
+ _convertTable: function(element) {
+ var elements = element.getElementsByTagName('TD');
+ while (elements.length) {
+ elements[0].outerHTML = '[td]' + elements[0].innerHTML + '[/td]\n';
+ }
+
+ elements = element.getElementsByTagName('TR');
+ while (elements.length) {
+ elements[0].outerHTML = '\n[tr]\n' + elements[0].innerHTML + '[/tr]';
+ }
+
+ var tbody = DOMTraverse.childByTag(element, 'TBODY');
+ var innerHtml = (tbody === null) ? element.innerHTML : tbody.innerHTML;
+ element.outerHTML = '\n[table]' + innerHtml + '\n[/table]\n';
+ },
+
+ /**
+ * Converts <a> into [email] or [url].
+ *
+ * @param {Element} element target element
+ */
+ _convertUrl: function(element) {
+ var content = element.textContent.trim(), href = element.href.trim(), tagName = 'url';
+
+ if (href === '' || content === '') {
+ // empty href or content
+ element.outerHTML = element.innerHTML;
+ return;
+ }
+
+ if (href.indexOf('mailto:') === 0) {
+ href = href.substr(7);
+ tagName = 'email';
+ }
+
+ if (href === content) {
+ element.outerHTML = '[' + tagName + ']' + href + '[/' + tagName + ']';
+ }
+ else {
+ element.outerHTML = "[" + tagName + "='" + href + "']" + element.innerHTML + "[/" + tagName + "]";
+ }
+ },
+
+ /**
+ * Converts <div class="codeBox"> into [code].
+ *
+ * @param {Element} element target element
+ */
+ _convertSourceCodeBox: function(element) {
+ var filename = element.getAttribute('data-filename').trim() || '';
+ var highlighter = element.getAttribute('data-highlighter') || '';
+ window.dtdesign = element;
+ var list = DOMTraverse.childByTag(element.children[0], 'OL');
+ var lineNumber = ~~list.getAttribute('start') || 1;
+
+ var content = '';
+ for (var i = 0, length = list.childElementCount; i < length; i++) {
+ if (content) content += "\n";
+ content += list.children[i].textContent;
+ }
+
+ var open = "[code='" + highlighter + "'," + lineNumber + ",'" + filename + "']";
+
+ element.outerHTML = open + content + '[/code]';
+ }
+ };
+
+ return BBCodeFromHtml;
+});
--- /dev/null
+/**
+ * Versatile BBCode parser based upon the PHP implementation.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/BBCode/Parser
+ */
+define([], function() {
+ "use strict";
+
+ /**
+ * @module WoltLab/WCF/BBCode/Parser
+ */
+ var BBCodeParser = {
+ /**
+ * Parses a message and returns an XML-conform linear tree.
+ *
+ * @param {string} message message containing BBCodes
+ * @return {array<mixed>} linear tree
+ */
+ parse: function(message) {
+ var stack = this._splitTags(message);
+ this._buildLinearTree(stack);
+
+ return stack;
+ },
+
+ /**
+ * Splits message into strings and BBCode objects.
+ *
+ * @param {string} message message containing BBCodes
+ * @returns {array<mixed>} linear tree
+ */
+ _splitTags: function(message) {
+ var validTags = __REDACTOR_BBCODES.join('|');
+ var pattern = '(\\\[(?:/(?:' + validTags + ')|(?:' + validTags + ')'
+ + '(?:='
+ + '(?:\\\'[^\\\'\\\\]*(?:\\\\.[^\\\'\\\\]*)*\\\'|[^,\\\]]*)'
+ + '(?:,(?:\\\'[^\\\'\\\\]*(?:\\\\.[^\\\'\\\\]*)*\'|[^,\\\]]*))*'
+ + ')?)\\\])';
+
+ var isBBCode = new RegExp('^' + pattern + '$', 'i');
+ var part, parts = message.split(new RegExp(pattern, 'i')), stack = [], tag;
+ for (var i = 0, length = parts.length; i < length; i++) {
+ part = parts[i];
+
+ if (part === '') {
+ continue;
+ }
+ else if (part.match(isBBCode)) {
+ tag = { name: '', closing: false, attributes: [], source: part };
+
+ if (part[1] === '/') {
+ tag.name = part.substring(2, part.length - 1);
+ tag.closing = true;
+ }
+ else if (part.match(/^\[([a-z0-9]+)=?(.*)\]$/i)) {
+ tag.name = RegExp.$1;
+
+ if (RegExp.$2) {
+ tag.attributes = this._parseAttributes(RegExp.$2);
+ }
+ }
+
+ stack.push(tag);
+ }
+ else {
+ stack.push(part);
+ }
+ }
+
+ return stack;
+ },
+
+ /**
+ * Finds pairs and enforces XML-conformity in terms of pairing and proper nesting.
+ *
+ * @param {array<mixed>} stack linear tree
+ */
+ _buildLinearTree: function(stack) {
+ var item, openTags = [], reopenTags, sourceBBCode = '';
+ for (var i = 0; i < stack.length; i++) { // do not cache stack.length, its size is dynamic
+ item = stack[i];
+
+ if (typeof item === 'object') {
+ if (sourceBBCode.length && (item.name !== sourceBBCode || !item.closing)) {
+ stack[i] = item.source;
+ continue;
+ }
+
+ if (item.closing) {
+ if (this._hasOpenTag(openTags, item.name)) {
+ reopenTags = this._closeUnclosedTags(stack, openTags, item.name);
+ for (var j = 0, innerLength = reopenTags.length; j < innerLength; j++) {
+ stack.splice(i, reopenTags[j]);
+ i++;
+ }
+
+ openTags.pop().pair = i;
+ }
+ else {
+ // tag was never opened, treat as plain text
+ stack[i] = item.source;
+ }
+
+ if (sourceBBCode === item.name) {
+ sourceBBCode = '';
+ }
+ }
+ else {
+ openTags.push(item);
+
+ if (__REDACTOR_SOURCE_BBCODES.indexOf(item.name) !== -1) {
+ sourceBBCode = item.name;
+ }
+ }
+ }
+ }
+
+ // close unclosed tags
+ this._closeUnclosedTags(stack, openTags, '');
+ },
+
+ /**
+ * Closes unclosed BBCodes and returns a list of BBCodes in order of appearance that should be
+ * opened again to enforce proper nesting.
+ *
+ * @param {array<mixed>} stack linear tree
+ * @param {array<object>} openTags list of unclosed elements
+ * @param {string} until tag name to stop at
+ * @return {array<mixed>} list of tags to open in order of appearance
+ */
+ _closeUnclosedTags: function(stack, openTags, until) {
+ var item, reopenTags = [], tag;
+
+ for (var i = openTags.length - 1; i >= 0; i--) {
+ item = openTags[i];
+
+ if (item.name === until) {
+ break;
+ }
+
+ tag = { name: item.name, closing: true, attributes: item.attributes.slice(), source: '[/' + item.name + ']' };
+ item.pair = stack.length;
+
+ stack.push(tag);
+
+ openTags.pop();
+ reopenTags.push({ name: item.name, closing: false, attributes: item.attributes.slice(), source: item.source });
+ }
+
+ return reopenTags.reverse();
+ },
+
+ /**
+ * Returns true if given BBCode was opened before.
+ *
+ * @param {array<object>} openTags list of unclosed elements
+ * @param {string} name BBCode to search for
+ * @returns {boolean} false if tag was not opened before
+ */
+ _hasOpenTag: function(openTags, name) {
+ for (var i = openTags.length - 1; i >= 0; i--) {
+ if (openTags[i].name === name) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Parses the attribute list and returns a list of attributes without enclosing quotes.
+ *
+ * @param {string} attrString comma separated string with optional quotes per attribute
+ * @returns {array<string>} list of attributes
+ */
+ _parseAttributes: function(attrString) {
+ var tmp = attrString.split(/(?:^|,)('[^'\\\\]*(?:\\\\.[^'\\\\]*)*'|[^,]*)/g);
+
+ var attribute, attributes = [];
+ for (var i = 0, length = tmp.length; i < length; i++) {
+ attribute = tmp[i];
+
+ if (attribute !== '') {
+ if (attribute.charAt(0) === "'" && attribute.substr(-1) === "'") {
+ attributes.push(attribute.substring(1, attribute.length - 1).trim());
+ }
+ else {
+ attributes.push(attribute.trim());
+ }
+ }
+ }
+
+ return attributes;
+ }
+ };
+
+ return BBCodeParser;
+});
--- /dev/null
+/**
+ * Converts a message containing BBCodes into HTML.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/BBCode/ToHtml
+ */
+define(['Core', 'EventHandler', 'Language', 'StringUtil', 'WoltLab/WCF/BBCode/Parser'], function(Core, EventHandler, Language, StringUtil, BBCodeParser) {
+ "use strict";
+
+ var _bbcodes = null;
+ var _options = {};
+ var _removeNewlineAfter = [];
+ var _removeNewlineBefore = [];
+
+ /**
+ * Returns true if given value is a non-zero integer.
+ *
+ * @param {string} value target value
+ * @return {boolean} true if `value` is a non-zero integer
+ */
+ function isNumber(value) {
+ return value && value == ~~value;
+ }
+
+ /**
+ * Returns true if given value appears to be a filename, which means that it contains a dot
+ * or is neither numeric nor a known highlighter.
+ *
+ * @param {string} value target value
+ * @return {boolean} true if `value` appears to be a filename
+ */
+ function isFilename(value) {
+ return (value.indexOf('.') !== -1) || (!isNumber(value) && !isHighlighter(value));
+ }
+
+ /**
+ * Returns true if given value is a known highlighter.
+ *
+ * @param {string} value target value
+ * @return {boolean} true if `value` is a known highlighter
+ */
+ function isHighlighter(value) {
+ return __REDACTOR_CODE_HIGHLIGHTERS.hasOwnProperty(value);
+ }
+
+ /**
+ * @module WoltLab/WCF/BBCode/ToHtml
+ */
+ var BBCodeToHtml = {
+ /**
+ * Converts a message containing BBCodes to HTML.
+ *
+ * @param {string} message message containing BBCodes
+ * @return {string} HTML message
+ */
+ convert: function(message, options) {
+ _options = Core.extend({
+ attachments: {
+ images: {},
+ thumbnailUrl: '',
+ url: ''
+ }
+ }, options);
+
+ this._convertSpecials(message);
+
+ var stack = BBCodeParser.parse(message);
+
+ if (stack.length) {
+ this._initBBCodes();
+ }
+
+ EventHandler.fire('com.woltlab.wcf.bbcode.toHtml', 'beforeConvert', { stack: stack });
+
+ var item, value;
+ for (var i = 0, length = stack.length; i < length; i++) {
+ item = stack[i];
+
+ if (typeof item === 'object') {
+ value = this._convert(stack, item, i);
+ if (Array.isArray(value)) {
+ stack[i] = (value[0] === null ? item.source : value[0]);
+ stack[item.pair] = (value[1] === null ? stack[item.pair].source : value[1]);
+ }
+ else {
+ stack[i] = value;
+ }
+ }
+ }
+
+ EventHandler.fire('com.woltlab.wcf.bbcode.toHtml', 'afterConvert', { stack: stack });
+
+ message = stack.join('');
+
+ message = message.replace(/\n/g, '<br>');
+
+ return message;
+ },
+
+ /**
+ * Converts special characters to their entities.
+ *
+ * @param {string} message message containing BBCodes
+ * @return {string} message with replaced special characters
+ */
+ _convertSpecials: function(message) {
+ message = message.replace(/&/g, '&');
+ message = message.replace(/</g, '<');
+ message = message.replace(/>/g, '>');
+
+ return message;
+ },
+
+ /**
+ * Sets up converters applied to HTML elements.
+ */
+ _initBBCodes: function() {
+ if (_bbcodes !== null) {
+ return;
+ }
+
+ _bbcodes = {
+ // simple replacements
+ b: 'strong',
+ i: 'em',
+ u: 'u',
+ s: 'del',
+ sub: 'sub',
+ sup: 'sup',
+ table: 'table',
+ td: 'td',
+ tr: 'tr',
+ tt: 'kbd',
+
+ // callback replacement
+ align: this._convertAlignment.bind(this),
+ attach: this._convertAttachment.bind(this),
+ color: this._convertColor.bind(this),
+ code: this._convertCode.bind(this),
+ email: this._convertEmail.bind(this),
+ list: this._convertList.bind(this),
+ quote: this._convertQuote.bind(this),
+ size: this._convertSize.bind(this),
+ url: this._convertUrl.bind(this),
+ img: this._convertImage.bind(this)
+ };
+
+ _removeNewlineAfter = ['quote', 'table', 'td', 'tr'];
+ _removeNewlineBefore = ['table', 'td', 'tr'];
+
+ EventHandler.fire('com.woltlab.wcf.bbcode.toHtml', 'init', {
+ bbcodes: _bbcodes,
+ removeNewlineAfter: _removeNewlineAfter,
+ removeNewlineBefore: _removeNewlineBefore
+ });
+ },
+
+ /**
+ * Converts an item from the stack.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @return {(string|array)} string if only the current item should be replaced or an array with
+ * the first item used for the opening tag and the second item for the closing tag
+ */
+ _convert: function(stack, item, index) {
+ var replace = _bbcodes[item.name], tmp;
+
+ if (replace === undefined) {
+ // treat as plain text
+ return [null, null];
+ }
+
+ if (_removeNewlineAfter.indexOf(item.name) !== -1) {
+ tmp = stack[index + 1];
+ if (typeof tmp === 'string') {
+ stack[index + 1] = tmp.replace(/^\n/, '');
+ }
+
+ if (stack.length > item.pair + 1) {
+ tmp = stack[item.pair + 1];
+ if (typeof tmp === 'string') {
+ stack[item.pair + 1] = tmp.replace(/^\n/, '');
+ }
+ }
+ }
+
+ if (_removeNewlineBefore.indexOf(item.name) !== -1) {
+ if (index - 1 >= 0) {
+ tmp = stack[index - 1];
+ if (typeof tmp === 'string') {
+ stack[index - 1] = tmp.replace(/\n$/, '');
+ }
+ }
+
+ tmp = stack[item.pair - 1];
+ if (typeof tmp === 'string') {
+ stack[item.pair - 1] = tmp.replace(/\n$/, '');
+ }
+ }
+
+ // replace smilies
+ this._convertSmilies(stack);
+
+ if (typeof replace === 'string') {
+ return ['<' + replace + '>', '</' + replace + '>'];
+ }
+ else {
+ return replace(stack, item, index);
+ }
+ },
+
+ /**
+ * Converts [align] into <div style="text-align: ...">.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @returns {array} first item represents the opening tag, the second the closing one
+ */
+ _convertAlignment: function(stack, item, index) {
+ var align = (item.attributes.length) ? item.attributes[0] : '';
+ if (['center', 'justify', 'left', 'right'].indexOf(align) === -1) {
+ return [null, null];
+ }
+
+ return ['<div style="text-align: ' + align + '">', '</div>'];
+ },
+
+ /**
+ * Converts [attach] into an <img> or to plain text if attachment is a non-image.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @returns {array} first item represents the opening tag, the second the closing one
+ */
+ _convertAttachment: function(stack, item, index) {
+ var attachmentId = 0, attributes = item.attributes, length = attributes.length;
+ if (!_options.attachments.url) {
+ length = 0;
+ }
+ else if (length > 0) {
+ attachmentId = ~~attributes[0];
+ if (!_options.attachments.images.hasOwnProperty(attachmentId)) {
+ length = 0;
+ }
+ }
+
+ if (length === 0) {
+ return [null, null];
+ }
+
+ var maxHeight = ~~_options.attachments.images[attachmentId].height;
+ var maxWidth = ~~_options.attachments.images[attachmentId].width;
+ var styles = ['max-height: ' + maxHeight + 'px', 'max-width: ' + maxWidth + 'px'];
+
+ if (length > 1) {
+ if (item.attributes[1] === 'left' || attributes[1] === 'right') {
+ styles.push('float: ' + attributes[1]);
+ styles.push('margin: ' + (attributes[1] === 'left' ? '0 15px 7px 0' : '0 0 7px 15px'));
+ }
+ }
+
+ var baseUrl = _options.attachments.thumbnailUrl;
+ if (length > 2) {
+ width = ~~attributes[2] || 0;
+ if (width) {
+ if (width > maxWidth) width = maxWidth;
+
+ styles.push('width: ' + width + 'px');
+ baseUrl = _options.attachments.url;
+ }
+ }
+
+ return [
+ '<img src="' + baseUrl.replace(/987654321/, attachmentId) + '" class="redactorEmbeddedAttachment redactorDisableResize" data-attachment-id="' + attachmentId + '"' + (styles.length ? ' style="' + styles.join(';') + '"' : '') + '>',
+ ''
+ ];
+ },
+
+ /**
+ * Converts [code] to <div class="codeBox">.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @returns {array} first item represents the opening tag, the second the closing one
+ */
+ _convertCode: function(stack, item, index) {
+ var attributes = item.attributes, filename = '', highlighter = 'auto', lineNumber = 0;
+
+ // parse arguments
+ switch (attributes.length) {
+ case 1:
+ if (isNumber(attributes[0])) {
+ lineNumber = ~~attributes[0];
+ }
+ else if (isFilename(attributes[0])) {
+ filename = attributes[0];
+ }
+ else if (isHighlighter(attributes[0])) {
+ highlighter = attributes[0];
+ }
+ break;
+ case 2:
+ if (isNumber(attributes[0])) {
+ lineNumber = ~~attributes[0];
+
+ if (isHighlighter(attributes[1])) {
+ highlighter = attributes[1];
+ }
+ else if (isFilename(attributes[1])) {
+ filename = attributes[1];
+ }
+ }
+ else {
+ if (isHighlighter(attributes[0])) highlighter = attributes[0];
+ if (isFilename(attributes[1])) filename = attributes[1];
+ }
+ break;
+ case 3:
+ if (isHighlighter(attributes[0])) highlighter = attributes[0];
+ if (isNumber(attributes[1])) lineNumber = ~~attributes[1];
+ if (isFilename(attributes[2])) filename = attributes[2];
+ break;
+ }
+
+ // transform content
+ var before = true, content, line, empty = -1;
+ for (var i = index + 1; i < item.pair; i++) {
+ line = stack[i];
+
+ if (line.trim() === '') {
+ if (before) {
+ stack[i] = '';
+ continue;
+ }
+ else if (empty === -1) {
+ empty = i;
+ }
+ }
+ else {
+ before = false;
+ empty = -1;
+ }
+
+ content = line.split('\n');
+ for (var j = 0, innerLength = content.length; j < innerLength; j++) {
+ content[j] = '<li>' + (content[j] ? StringUtil.escapeHTML(content[j]) : '\u200b') + '</li>';
+ }
+
+ stack[i] = content.join('');
+ }
+
+ if (!before && empty !== -1) {
+ for (var i = item.pair - 1; i >= empty; i--) {
+ stack[i] = '';
+ }
+ }
+
+ return [
+ '<div class="codeBox container" contenteditable="false" data-highlighter="' + highlighter + '" data-filename="' + (filename ? StringUtil.escapeHTML(filename) : '') + '">'
+ + '<div>'
+ + '<div>'
+ + '<h3>' + __REDACTOR_CODE_HIGHLIGHTERS[highlighter] + (filename ? ': ' + StringUtil.escapeHTML(filename) : '') + '</h3>'
+ + '</div>'
+ + '<ol start="' + (lineNumber > 1 ? lineNumber : 1) + '">',
+ '</ol></div></div>'
+ ];
+ },
+
+ /**
+ * Converts [color] to <span style="color: ...">.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @returns {array} first item represents the opening tag, the second the closing one
+ */
+ _convertColor: function(stack, item, index) {
+ if (!item.attributes.length || !item.attributes[0].match(/^[a-z0-9#]+$/i)) {
+ return [null, null];
+ }
+
+ return ['<span style="color: ' + StringUtil.escapeHTML(item.attributes[0]) + '">', '</span>'];
+ },
+
+ /**
+ * Converts [email] to <a href="mailto: ...">.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @returns {array} first item represents the opening tag, the second the closing one
+ */
+ _convertEmail: function(stack, item, index) {
+ var email = '';
+ if (item.attributes.length) {
+ email = item.attributes[0];
+ }
+ else {
+ var element;
+ for (var i = index + 1; i < item.pair; i++) {
+ element = stack[i];
+
+ if (typeof element === 'object') {
+ email = '';
+ break;
+ }
+ else {
+ email += element;
+ }
+ }
+
+ // no attribute present and element is empty, handle as plain text
+ if (email.trim() === '') {
+ return [null, null];
+ }
+ }
+
+ return ['<a href="mailto:' + StringUtil.escapeHTML(email) + '">', '</a>'];
+ },
+
+ /**
+ * Converts [img] to <img>.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @returns {array} first item represents the opening tag, the second the closing one
+ */
+ _convertImage: function(stack, item, index) {
+ var float = 'none', source = '', width = 0;
+
+ switch (item.attributes.length) {
+ case 0:
+ if (index + 1 < item.pair && typeof stack[index + 1] === 'string') {
+ source = stack[index + 1];
+ stack[index + 1] = '';
+ }
+ else {
+ // [img] without attributes and content, discard
+ return '';
+ }
+ break;
+
+ case 1:
+ source = item.attributes[0];
+ break;
+
+ case 2:
+ source = item.attributes[0];
+ float = item.attributes[1];
+ break;
+
+ case 3:
+ source = item.attributes[0];
+ float = item.attributes[1];
+ width = ~~item.attributes[2];
+ break;
+ }
+
+ if (float !== 'left' && float !== 'right') float = 'none';
+
+ var styles = [];
+ if (width > 0) {
+ styles.push('width: ' + width + 'px');
+ }
+
+ if (float !== 'none') {
+ styles.push('float: ' + float);
+ styles.push('margin: ' + (float === 'left' ? '0 15px 7px 0' : '0 0 7px 15px'));
+ }
+
+ return ['<img src="' + StringUtil.escapeHTML(source) + '"' + (styles.length ? ' style="' + styles.join(';') + '"' : '') + '>', ''];
+ },
+
+ /**
+ * Converts [list] to <ol> or <ul>.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @returns {array} first item represents the opening tag, the second the closing one
+ */
+ _convertList: function(stack, item, index) {
+ var type = (items.attributes.length) ? item.attributes[0] : '';
+
+ // replace list items
+ for (var i = index + 1; i < item.pair; i++) {
+ if (typeof stack[i] === 'string') {
+ stack[i] = stack[i].replace(/\[\*\]/g, '<li>');
+ }
+ }
+
+ if (type == '1' || type === 'decimal') {
+ return ['<ol>', '</ol>'];
+ }
+
+ if (type.length && type.match(/^(?:none|circle|square|disc|decimal|lower-roman|upper-roman|decimal-leading-zero|lower-greek|lower-latin|upper-latin|armenian|georgian)$/)) {
+ return ['<ul style="list-style-type: ' + type + '">', '</ul>'];
+ }
+
+ return ['<ul>', '</ul>'];
+ },
+
+ /**
+ * Converts [quote] to <blockquote>.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @returns {array} first item represents the opening tag, the second the closing one
+ */
+ _convertQuote: function(stack, item, index) {
+ var author = '', link = '';
+ if (item.attributes.length > 1) {
+ author = item.attributes[0];
+ link = item.attributes[1];
+ }
+ else if (item.attributes.length === 1) {
+ author = item.attributes[0];
+ }
+
+ // get rid of the trailing newline for quote content
+ for (var i = item.pair - 1; i > index; i--) {
+ if (typeof stack[i] === 'string') {
+ stack[i] = stack[i].replace(/\n$/, '');
+ break;
+ }
+ }
+
+ var header = '';
+ if (author) {
+ if (link) header = '<a href="' + StringUtil.escapeHTML(link) + '" tabindex="-1">';
+ header += Language.get('wcf.bbcode.quote.title.javascript', { quoteAuthor: author.replace(/\\'/g, "'") });
+ if (link) header += '</a>';
+ }
+ else {
+ header = '<small>' + Language.get('wcf.bbcode.quote.title.clickToSet') + '</small>';
+ }
+
+ return [
+ '<blockquote class="quoteBox container containerPadding quoteBoxSimple" cite="' + StringUtil.escapeHTML(link) + '" data-author="' + StringUtil.escapeHTML(author) + '">'
+ + '<header contenteditable="false">'
+ + '<h3>'
+ + header
+ + '</h3>'
+ + '<a class="redactorQuoteEdit"></a>'
+ + '</header>'
+ + '<div>\u200b',
+ '</div></blockquote>'
+ ];
+ },
+
+ /**
+ * Converts smiley codes into <img>.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ */
+ _convertSmilies: function(stack) {
+ var altValue, item, regexp;
+ for (var i = 0, length = stack.length; i < length; i++) {
+ item = stack[i];
+
+ if (typeof item === 'string') {
+ for (var smileyCode in __REDACTOR_SMILIES) {
+ if (__REDACTOR_SMILIES.hasOwnProperty(smileyCode)) {
+ altValue = smileyCode.replace(/</g, '<').replace(/>/g, '>');
+ regexp = new RegExp('(\\s|^)' + StringUtil.escapeRegExp(smileyCode) + '(?=\\s|$)', 'gi');
+ item = item.replace(regexp, '$1<img src="' + __REDACTOR_SMILIES[smileyCode] + '" class="smiley" alt="' + altValue + '">');
+ }
+ }
+
+ stack[i] = item;
+ }
+ else if (__REDACTOR_SOURCE_BBCODES.indexOf(item.name) !== -1) {
+ // skip processing content
+ i = item.pair;
+ }
+ }
+ },
+
+ /**
+ * Converts [size] to <span style="font-size: ...">.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @returns {array} first item represents the opening tag, the second the closing one
+ */
+ _convertSize: function(stack, item, index) {
+ if (!item.attributes.length || ~~item.attributes[0] === 0) {
+ return [null, null];
+ }
+
+ return ['<span style="font-size: ' + ~~item.attributes[0] + 'pt">', '</span>'];
+ },
+
+ /**
+ * Converts [url] to <a>.
+ *
+ * @param {array<mixed>} stack linear list of BBCode tags and regular strings
+ * @param {object} item current BBCode tag object
+ * @param {integer} index current stack index representing `item`
+ * @returns {array} first item represents the opening tag, the second the closing one
+ */
+ _convertUrl: function(stack, item, index) {
+ // ignore url bbcode without arguments
+ if (!item.attributes.length) {
+ return [null, null];
+ }
+
+ return ['<a href="' + StringUtil.escapeHTML(item.attributes[0]) + '">', '</a>'];
+ }
+ };
+
+ return BBCodeToHtml;
+});
+++ /dev/null
-/**
- * Allows to be informed when the DOM may have changed and
- * new elements that are relevant to you may have been added.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/DOM/Change/Listener
- */
-define(['CallbackList'], function(CallbackList) {
- "use strict";
-
- var _callbackList = new CallbackList();
- var _hot = false;
-
- /**
- * @exports WoltLab/WCF/DOM/Change/Listener
- */
- var Listener = {
- /**
- * @see WoltLab/WCF/CallbackList#add
- */
- add: _callbackList.add.bind(_callbackList),
-
- /**
- * @see WoltLab/WCF/CallbackList#remove
- */
- remove: _callbackList.remove.bind(_callbackList),
-
- /**
- * Triggers the execution of all the listeners.
- * Use this function when you added new elements to the DOM that might
- * be relevant to others.
- * While this function is in progress further calls to it will be ignored.
- */
- trigger: function() {
- if (_hot) return;
-
- try {
- _hot = true;
- _callbackList.forEach(null, function(callback) {
- callback();
- });
- }
- finally {
- _hot = false;
- }
- }
- };
-
- return Listener;
-});
+++ /dev/null
-/**
- * Provides helper functions to traverse the DOM.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/DOM/Traverse
- */
-define(['DOM/Util'], function(DOMUtil) {
- "use strict";
-
- /** @const */ var NONE = 0;
- /** @const */ var SELECTOR = 1;
- /** @const */ var CLASS_NAME = 2;
- /** @const */ var TAG_NAME = 3;
-
- var _probe = [
- function(el, none) { return true; },
- function(el, selector) { return DOMUtil.matches(el, selector); },
- function(el, className) { return el.classList.contains(className); },
- function(el, tagName) { return el.nodeName === tagName; }
- ];
-
- var _children = function(el, type, value) {
- if (!(el instanceof Element)) {
- throw new TypeError("Expected a valid element as first argument.");
- }
-
- var children = [];
-
- for (var i = 0; i < el.childElementCount; i++) {
- if (_probe[type](el.children[i], value)) {
- children.push(el.children[i]);
- }
- }
-
- return children;
- };
-
- var _parent = function(el, type, value, untilElement) {
- if (!(el instanceof Element)) {
- throw new TypeError("Expected a valid element as first argument.");
- }
-
- el = el.parentNode;
-
- while (el instanceof Element) {
- if (el === untilElement) {
- return null;
- }
-
- if (_probe[type](el, value)) {
- return el;
- }
-
- el = el.parentNode;
- }
-
- return null;
- };
-
- var _sibling = function(el, siblingType, type, value) {
- if (!(el instanceof Element)) {
- throw new TypeError("Expected a valid element as first argument.");
- }
-
- if (el instanceof Element) {
- if (el[siblingType] !== null && _probe[type](el[siblingType], value)) {
- return el[siblingType];
- }
- }
-
- return null;
- };
-
- /**
- * @exports WoltLab/WCF/DOM/Traverse
- */
- var DOMTraverse = {
- /**
- * Examines child elements and returns the first child matching the given selector.
- *
- * @param {Element} el element
- * @param {string} selector CSS selector to match child elements against
- * @return {(Element|null)} null if there is no child node matching the selector
- */
- childBySel: function(el, selector) {
- return _children(el, SELECTOR, selector)[0] || null;
- },
-
- /**
- * Examines child elements and returns the first child that has the given CSS class set.
- *
- * @param {Element} el element
- * @param {string} className CSS class name
- * @return {(Element|null)} null if there is no child node with given CSS class
- */
- childByClass: function(el, className) {
- return _children(el, CLASS_NAME, className)[0] || null;
- },
-
- /**
- * Examines child elements and returns the first child which equals the given tag.
- *
- * @param {Element} el element
- * @param {string} tagName element tag name
- * @return {(Element|null)} null if there is no child node which equals given tag
- */
- childByTag: function(el, tagName) {
- return _children(el, TAG_NAME, tagName)[0] || null;
- },
-
- /**
- * Examines child elements and returns all children matching the given selector.
- *
- * @param {Element} el element
- * @param {string} selector CSS selector to match child elements against
- * @return {array<Element>} list of children matching the selector
- */
- childrenBySel: function(el, selector) {
- return _children(el, SELECTOR, selector);
- },
-
- /**
- * Examines child elements and returns all children that have the given CSS class set.
- *
- * @param {Element} el element
- * @param {string} className CSS class name
- * @return {array<Element>} list of children with the given class
- */
- childrenByClass: function(el, className) {
- return _children(el, CLASS_NAME, className);
- },
-
- /**
- * Examines child elements and returns all children which equal the given tag.
- *
- * @param {Element} el element
- * @param {string} tagName element tag name
- * @return {array<Element>} list of children equaling the tag name
- */
- childrenByTag: function(el, tagName) {
- return _children(el, TAG_NAME, tagName);
- },
-
- /**
- * Examines parent nodes and returns the first parent that matches the given selector.
- *
- * @param {Element} el child element
- * @param {string} selector CSS selector to match parent nodes against
- * @param {Element=} untilElement stop when reaching this element
- * @return {(Element|null)} null if no parent node matched the selector
- */
- parentBySel: function(el, selector, untilElement) {
- return _parent(el, SELECTOR, selector, untilElement);
- },
-
- /**
- * Examines parent nodes and returns the first parent that has the given CSS class set.
- *
- * @param {Element} el child element
- * @param {string} className CSS class name
- * @param {Element=} untilElement stop when reaching this element
- * @return {(Element|null)} null if there is no parent node with given class
- */
- parentByClass: function(el, className, untilElement) {
- return _parent(el, CLASS_NAME, className, untilElement);
- },
-
- /**
- * Examines parent nodes and returns the first parent which equals the given tag.
- *
- * @param {Element} el child element
- * @param {string} tagName element tag name
- * @param {Element=} untilElement stop when reaching this element
- * @return {(Element|null)} null if there is no parent node of given tag type
- */
- parentByTag: function(el, tagName, untilElement) {
- return _parent(el, TAG_NAME, tagName, untilElement);
- },
-
- /**
- * Returns the next element sibling.
- *
- * @param {Element} el element
- * @return {(Element|null)} null if there is no next sibling element
- */
- next: function(el) {
- return _sibling(el, 'nextElementSibling', NONE, null);
- },
-
- /**
- * Returns the next element sibling that matches the given selector.
- *
- * @param {Element} el element
- * @param {string} selector CSS selector to match parent nodes against
- * @return {(Element|null)} null if there is no next sibling element or it does not match the selector
- */
- nextBySel: function(el, selector) {
- return _sibling(el, 'nextElementSibling', SELECTOR, selector);
- },
-
- /**
- * Returns the next element sibling with given CSS class.
- *
- * @param {Element} el element
- * @param {string} className CSS class name
- * @return {(Element|null)} null if there is no next sibling element or it does not have the class set
- */
- nextByClass: function(el, className) {
- return _sibling(el, 'nextElementSibling', CLASS_NAME, className);
- },
-
- /**
- * Returns the next element sibling with given CSS class.
- *
- * @param {Element} el element
- * @param {string} className CSS class name
- * @return {(Element|null)} null if there is no next sibling element or it does not have the class set
- */
- nextByTag: function(el, tagName) {
- return _sibling(el, 'nextElementSibling', CLASS_NAME, className);
- },
-
- /**
- * Returns the previous element sibling.
- *
- * @param {Element} el element
- * @return {(Element|null)} null if there is no previous sibling element
- */
- prev: function(el) {
- return _sibling(el, 'previousElementSibling', NONE, null);
- },
-
- /**
- * Returns the previous element sibling that matches the given selector.
- *
- * @param {Element} el element
- * @param {string} selector CSS selector to match parent nodes against
- * @return {(Element|null)} null if there is no previous sibling element or it does not match the selector
- */
- prevBySel: function(el, selector) {
- return _sibling(el, 'previousElementSibling', SELECTOR, selector);
- },
-
- /**
- * Returns the previous element sibling with given CSS class.
- *
- * @param {Element} el element
- * @param {string} className CSS class name
- * @return {(Element|null)} null if there is no previous sibling element or it does not have the class set
- */
- prevByClass: function(el, className) {
- return _sibling(el, 'previousElementSibling', CLASS_NAME, className);
- },
-
- /**
- * Returns the previous element sibling with given CSS class.
- *
- * @param {Element} el element
- * @param {string} className CSS class name
- * @return {(Element|null)} null if there is no previous sibling element or it does not have the class set
- */
- prevByTag: function(el, tagName) {
- return _sibling(el, 'previousElementSibling', CLASS_NAME, className);
- }
- };
-
- return DOMTraverse;
-});
+++ /dev/null
-/**
- * Provides helper functions to work with DOM nodes.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/DOM/Util
- */
-define([], function() {
- "use strict";
-
- var _matchesSelectorFunction = '';
- var _possibleFunctions = ['matches', 'webkitMatchesSelector', 'mozMatchesSelector', 'msMatchesSelector'];
- for (var i = 0; i < 4; i++) {
- if (Element.prototype.hasOwnProperty(_possibleFunctions[i])) {
- _matchesSelectorFunction = _possibleFunctions[i];
- break;
- }
- }
-
- var _idCounter = 0;
-
- /**
- * @exports WoltLab/WCF/DOM/Util
- */
- var DOMUtil = {
- /**
- * Returns a DocumentFragment containing the provided HTML string as DOM nodes.
- *
- * @param {string} html HTML string
- * @return {DocumentFragment} fragment containing DOM nodes
- */
- createFragmentFromHtml: function(html) {
- var tmp = document.createElement('div');
- tmp.innerHTML = html;
-
- var fragment = document.createDocumentFragment();
- while (tmp.childNodes.length) {
- fragment.appendChild(tmp.childNodes[0]);
- }
-
- return fragment;
- },
-
- /**
- * Returns a unique element id.
- *
- * @return {string} unique id
- */
- getUniqueId: function() {
- var elementId;
-
- do {
- elementId = 'wcf' + _idCounter++;
- }
- while (document.getElementById(elementId) !== null);
-
- return elementId;
- },
-
- /**
- * Returns the element's id. If there is no id set, a unique id will be
- * created and assigned.
- *
- * @param {Element} el element
- * @return {string} element id
- */
- identify: function(el) {
- if (!el || !(el instanceof Element)) {
- return null;
- }
-
- var id = el.getAttribute('id');
- if (!id) {
- id = this.getUniqueId();
- el.setAttribute('id', id);
- }
-
- return id;
- },
-
- /**
- * Returns true if element matches given CSS selector.
- *
- * @param {Element} el element
- * @param {string} selector CSS selector
- * @return {boolean} true if element matches selector
- */
- matches: function(el, selector) {
- return el[_matchesSelectorFunction](selector);
- },
-
- /**
- * Returns the outer height of an element including margins.
- *
- * @param {Element} el element
- * @param {CSSStyleDeclaration=} styles result of window.getComputedStyle()
- * @return {integer} outer height in px
- */
- outerHeight: function(el, styles) {
- styles = styles || window.getComputedStyle(el);
-
- var height = el.offsetHeight;
- height += ~~styles.marginTop + ~~styles.marginBottom;
-
- return height;
- },
-
- /**
- * Returns the outer width of an element including margins.
- *
- * @param {Element} el element
- * @param {CSSStyleDeclaration=} styles result of window.getComputedStyle()
- * @return {integer} outer width in px
- */
- outerWidth: function(el, styles) {
- styles = styles || window.getComputedStyle(el);
-
- var width = el.offsetWidth;
- width += ~~styles.marginLeft + ~~styles.marginRight;
-
- return width;
- },
-
- /**
- * Returns the outer dimensions of an element including margins.
- *
- * @param {Element} el element
- * @return {{height: integer, width: integer}} dimensions in px
- */
- outerDimensions: function(el) {
- var styles = window.getComputedStyle(el);
-
- return {
- height: this.outerHeight(el, styles),
- width: this.outerWidth(el, styles)
- };
- },
-
- /**
- * Returns the element's offset relative to the document's top left corner.
- *
- * @param {Element} el element
- * @return {{left: integer, top: integer}} offset relative to top left corner
- */
- offset: function(el) {
- var rect = el.getBoundingClientRect();
-
- return {
- top: rect.top + document.body.scrollTop,
- left: rect.left + document.body.scrollLeft
- };
- },
-
- /**
- * Prepends an element to a parent element.
- *
- * @param {Element} el element to prepend
- * @param {Element} parentEl future containing element
- */
- prepend: function(el, parentEl) {
- if (parentEl.childElementCount === 0) {
- parentEl.appendChild(el);
- }
- else {
- parentEl.insertBefore(el, parentEl.children[0]);
- }
- },
-
- /**
- * Inserts an element after an existing element.
- *
- * @param {Element} newEl element to insert
- * @param {Element} el reference element
- */
- insertAfter: function(newEl, el) {
- if (el.nextElementSibling !== null) {
- el.parentNode.insertBefore(newEl, el.nextElementSibling);
- }
- else {
- el.parentNode.appendChild(newEl);
- }
- },
-
- /**
- * Applies a list of CSS properties to an element.
- *
- * @param {Element} el element
- * @param {Object<string, mixed>} styles list of CSS styles
- */
- setStyles: function(el, styles) {
- for (var property in styles) {
- if (styles.hasOwnProperty(property)) {
- el.style.setProperty(property, styles[property]);
- }
- }
- },
-
- /**
- * Returns a style property value as integer.
- *
- * The behavior of this method is undefined for properties that are not considered
- * to have a "numeric" value, e.g. "background-image".
- *
- * @param {CSSStyleDeclaration} styles result of window.getComputedStyle()
- * @param {string} propertyName property name
- * @return {integer} property value as integer
- */
- styleAsInt: function(styles, propertyName) {
- var value = styles.getPropertyValue(propertyName);
- if (value === null) {
- return 0;
- }
-
- return parseInt(value);
- }
- };
-
- // expose on window object for backward compatibility
- window.bc_wcfDOMUtil = DOMUtil;
-
- return DOMUtil;
-});
--- /dev/null
+/**
+ * Allows to be informed when the DOM may have changed and
+ * new elements that are relevant to you may have been added.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/DOM/Change/Listener
+ */
+define(['CallbackList'], function(CallbackList) {
+ "use strict";
+
+ var _callbackList = new CallbackList();
+ var _hot = false;
+
+ /**
+ * @exports WoltLab/WCF/DOM/Change/Listener
+ */
+ var Listener = {
+ /**
+ * @see WoltLab/WCF/CallbackList#add
+ */
+ add: _callbackList.add.bind(_callbackList),
+
+ /**
+ * @see WoltLab/WCF/CallbackList#remove
+ */
+ remove: _callbackList.remove.bind(_callbackList),
+
+ /**
+ * Triggers the execution of all the listeners.
+ * Use this function when you added new elements to the DOM that might
+ * be relevant to others.
+ * While this function is in progress further calls to it will be ignored.
+ */
+ trigger: function() {
+ if (_hot) return;
+
+ try {
+ _hot = true;
+ _callbackList.forEach(null, function(callback) {
+ callback();
+ });
+ }
+ finally {
+ _hot = false;
+ }
+ }
+ };
+
+ return Listener;
+});
--- /dev/null
+/**
+ * Provides helper functions to traverse the DOM.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/DOM/Traverse
+ */
+define(['DOM/Util'], function(DOMUtil) {
+ "use strict";
+
+ /** @const */ var NONE = 0;
+ /** @const */ var SELECTOR = 1;
+ /** @const */ var CLASS_NAME = 2;
+ /** @const */ var TAG_NAME = 3;
+
+ var _probe = [
+ function(el, none) { return true; },
+ function(el, selector) { return DOMUtil.matches(el, selector); },
+ function(el, className) { return el.classList.contains(className); },
+ function(el, tagName) { return el.nodeName === tagName; }
+ ];
+
+ var _children = function(el, type, value) {
+ if (!(el instanceof Element)) {
+ throw new TypeError("Expected a valid element as first argument.");
+ }
+
+ var children = [];
+
+ for (var i = 0; i < el.childElementCount; i++) {
+ if (_probe[type](el.children[i], value)) {
+ children.push(el.children[i]);
+ }
+ }
+
+ return children;
+ };
+
+ var _parent = function(el, type, value, untilElement) {
+ if (!(el instanceof Element)) {
+ throw new TypeError("Expected a valid element as first argument.");
+ }
+
+ el = el.parentNode;
+
+ while (el instanceof Element) {
+ if (el === untilElement) {
+ return null;
+ }
+
+ if (_probe[type](el, value)) {
+ return el;
+ }
+
+ el = el.parentNode;
+ }
+
+ return null;
+ };
+
+ var _sibling = function(el, siblingType, type, value) {
+ if (!(el instanceof Element)) {
+ throw new TypeError("Expected a valid element as first argument.");
+ }
+
+ if (el instanceof Element) {
+ if (el[siblingType] !== null && _probe[type](el[siblingType], value)) {
+ return el[siblingType];
+ }
+ }
+
+ return null;
+ };
+
+ /**
+ * @exports WoltLab/WCF/DOM/Traverse
+ */
+ var DOMTraverse = {
+ /**
+ * Examines child elements and returns the first child matching the given selector.
+ *
+ * @param {Element} el element
+ * @param {string} selector CSS selector to match child elements against
+ * @return {(Element|null)} null if there is no child node matching the selector
+ */
+ childBySel: function(el, selector) {
+ return _children(el, SELECTOR, selector)[0] || null;
+ },
+
+ /**
+ * Examines child elements and returns the first child that has the given CSS class set.
+ *
+ * @param {Element} el element
+ * @param {string} className CSS class name
+ * @return {(Element|null)} null if there is no child node with given CSS class
+ */
+ childByClass: function(el, className) {
+ return _children(el, CLASS_NAME, className)[0] || null;
+ },
+
+ /**
+ * Examines child elements and returns the first child which equals the given tag.
+ *
+ * @param {Element} el element
+ * @param {string} tagName element tag name
+ * @return {(Element|null)} null if there is no child node which equals given tag
+ */
+ childByTag: function(el, tagName) {
+ return _children(el, TAG_NAME, tagName)[0] || null;
+ },
+
+ /**
+ * Examines child elements and returns all children matching the given selector.
+ *
+ * @param {Element} el element
+ * @param {string} selector CSS selector to match child elements against
+ * @return {array<Element>} list of children matching the selector
+ */
+ childrenBySel: function(el, selector) {
+ return _children(el, SELECTOR, selector);
+ },
+
+ /**
+ * Examines child elements and returns all children that have the given CSS class set.
+ *
+ * @param {Element} el element
+ * @param {string} className CSS class name
+ * @return {array<Element>} list of children with the given class
+ */
+ childrenByClass: function(el, className) {
+ return _children(el, CLASS_NAME, className);
+ },
+
+ /**
+ * Examines child elements and returns all children which equal the given tag.
+ *
+ * @param {Element} el element
+ * @param {string} tagName element tag name
+ * @return {array<Element>} list of children equaling the tag name
+ */
+ childrenByTag: function(el, tagName) {
+ return _children(el, TAG_NAME, tagName);
+ },
+
+ /**
+ * Examines parent nodes and returns the first parent that matches the given selector.
+ *
+ * @param {Element} el child element
+ * @param {string} selector CSS selector to match parent nodes against
+ * @param {Element=} untilElement stop when reaching this element
+ * @return {(Element|null)} null if no parent node matched the selector
+ */
+ parentBySel: function(el, selector, untilElement) {
+ return _parent(el, SELECTOR, selector, untilElement);
+ },
+
+ /**
+ * Examines parent nodes and returns the first parent that has the given CSS class set.
+ *
+ * @param {Element} el child element
+ * @param {string} className CSS class name
+ * @param {Element=} untilElement stop when reaching this element
+ * @return {(Element|null)} null if there is no parent node with given class
+ */
+ parentByClass: function(el, className, untilElement) {
+ return _parent(el, CLASS_NAME, className, untilElement);
+ },
+
+ /**
+ * Examines parent nodes and returns the first parent which equals the given tag.
+ *
+ * @param {Element} el child element
+ * @param {string} tagName element tag name
+ * @param {Element=} untilElement stop when reaching this element
+ * @return {(Element|null)} null if there is no parent node of given tag type
+ */
+ parentByTag: function(el, tagName, untilElement) {
+ return _parent(el, TAG_NAME, tagName, untilElement);
+ },
+
+ /**
+ * Returns the next element sibling.
+ *
+ * @param {Element} el element
+ * @return {(Element|null)} null if there is no next sibling element
+ */
+ next: function(el) {
+ return _sibling(el, 'nextElementSibling', NONE, null);
+ },
+
+ /**
+ * Returns the next element sibling that matches the given selector.
+ *
+ * @param {Element} el element
+ * @param {string} selector CSS selector to match parent nodes against
+ * @return {(Element|null)} null if there is no next sibling element or it does not match the selector
+ */
+ nextBySel: function(el, selector) {
+ return _sibling(el, 'nextElementSibling', SELECTOR, selector);
+ },
+
+ /**
+ * Returns the next element sibling with given CSS class.
+ *
+ * @param {Element} el element
+ * @param {string} className CSS class name
+ * @return {(Element|null)} null if there is no next sibling element or it does not have the class set
+ */
+ nextByClass: function(el, className) {
+ return _sibling(el, 'nextElementSibling', CLASS_NAME, className);
+ },
+
+ /**
+ * Returns the next element sibling with given CSS class.
+ *
+ * @param {Element} el element
+ * @param {string} className CSS class name
+ * @return {(Element|null)} null if there is no next sibling element or it does not have the class set
+ */
+ nextByTag: function(el, tagName) {
+ return _sibling(el, 'nextElementSibling', CLASS_NAME, className);
+ },
+
+ /**
+ * Returns the previous element sibling.
+ *
+ * @param {Element} el element
+ * @return {(Element|null)} null if there is no previous sibling element
+ */
+ prev: function(el) {
+ return _sibling(el, 'previousElementSibling', NONE, null);
+ },
+
+ /**
+ * Returns the previous element sibling that matches the given selector.
+ *
+ * @param {Element} el element
+ * @param {string} selector CSS selector to match parent nodes against
+ * @return {(Element|null)} null if there is no previous sibling element or it does not match the selector
+ */
+ prevBySel: function(el, selector) {
+ return _sibling(el, 'previousElementSibling', SELECTOR, selector);
+ },
+
+ /**
+ * Returns the previous element sibling with given CSS class.
+ *
+ * @param {Element} el element
+ * @param {string} className CSS class name
+ * @return {(Element|null)} null if there is no previous sibling element or it does not have the class set
+ */
+ prevByClass: function(el, className) {
+ return _sibling(el, 'previousElementSibling', CLASS_NAME, className);
+ },
+
+ /**
+ * Returns the previous element sibling with given CSS class.
+ *
+ * @param {Element} el element
+ * @param {string} className CSS class name
+ * @return {(Element|null)} null if there is no previous sibling element or it does not have the class set
+ */
+ prevByTag: function(el, tagName) {
+ return _sibling(el, 'previousElementSibling', CLASS_NAME, className);
+ }
+ };
+
+ return DOMTraverse;
+});
--- /dev/null
+/**
+ * Provides helper functions to work with DOM nodes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/DOM/Util
+ */
+define([], function() {
+ "use strict";
+
+ var _matchesSelectorFunction = '';
+ var _possibleFunctions = ['matches', 'webkitMatchesSelector', 'mozMatchesSelector', 'msMatchesSelector'];
+ for (var i = 0; i < 4; i++) {
+ if (Element.prototype.hasOwnProperty(_possibleFunctions[i])) {
+ _matchesSelectorFunction = _possibleFunctions[i];
+ break;
+ }
+ }
+
+ var _idCounter = 0;
+
+ /**
+ * @exports WoltLab/WCF/DOM/Util
+ */
+ var DOMUtil = {
+ /**
+ * Returns a DocumentFragment containing the provided HTML string as DOM nodes.
+ *
+ * @param {string} html HTML string
+ * @return {DocumentFragment} fragment containing DOM nodes
+ */
+ createFragmentFromHtml: function(html) {
+ var tmp = document.createElement('div');
+ tmp.innerHTML = html;
+
+ var fragment = document.createDocumentFragment();
+ while (tmp.childNodes.length) {
+ fragment.appendChild(tmp.childNodes[0]);
+ }
+
+ return fragment;
+ },
+
+ /**
+ * Returns a unique element id.
+ *
+ * @return {string} unique id
+ */
+ getUniqueId: function() {
+ var elementId;
+
+ do {
+ elementId = 'wcf' + _idCounter++;
+ }
+ while (document.getElementById(elementId) !== null);
+
+ return elementId;
+ },
+
+ /**
+ * Returns the element's id. If there is no id set, a unique id will be
+ * created and assigned.
+ *
+ * @param {Element} el element
+ * @return {string} element id
+ */
+ identify: function(el) {
+ if (!el || !(el instanceof Element)) {
+ return null;
+ }
+
+ var id = el.getAttribute('id');
+ if (!id) {
+ id = this.getUniqueId();
+ el.setAttribute('id', id);
+ }
+
+ return id;
+ },
+
+ /**
+ * Returns true if element matches given CSS selector.
+ *
+ * @param {Element} el element
+ * @param {string} selector CSS selector
+ * @return {boolean} true if element matches selector
+ */
+ matches: function(el, selector) {
+ return el[_matchesSelectorFunction](selector);
+ },
+
+ /**
+ * Returns the outer height of an element including margins.
+ *
+ * @param {Element} el element
+ * @param {CSSStyleDeclaration=} styles result of window.getComputedStyle()
+ * @return {integer} outer height in px
+ */
+ outerHeight: function(el, styles) {
+ styles = styles || window.getComputedStyle(el);
+
+ var height = el.offsetHeight;
+ height += ~~styles.marginTop + ~~styles.marginBottom;
+
+ return height;
+ },
+
+ /**
+ * Returns the outer width of an element including margins.
+ *
+ * @param {Element} el element
+ * @param {CSSStyleDeclaration=} styles result of window.getComputedStyle()
+ * @return {integer} outer width in px
+ */
+ outerWidth: function(el, styles) {
+ styles = styles || window.getComputedStyle(el);
+
+ var width = el.offsetWidth;
+ width += ~~styles.marginLeft + ~~styles.marginRight;
+
+ return width;
+ },
+
+ /**
+ * Returns the outer dimensions of an element including margins.
+ *
+ * @param {Element} el element
+ * @return {{height: integer, width: integer}} dimensions in px
+ */
+ outerDimensions: function(el) {
+ var styles = window.getComputedStyle(el);
+
+ return {
+ height: this.outerHeight(el, styles),
+ width: this.outerWidth(el, styles)
+ };
+ },
+
+ /**
+ * Returns the element's offset relative to the document's top left corner.
+ *
+ * @param {Element} el element
+ * @return {{left: integer, top: integer}} offset relative to top left corner
+ */
+ offset: function(el) {
+ var rect = el.getBoundingClientRect();
+
+ return {
+ top: rect.top + document.body.scrollTop,
+ left: rect.left + document.body.scrollLeft
+ };
+ },
+
+ /**
+ * Prepends an element to a parent element.
+ *
+ * @param {Element} el element to prepend
+ * @param {Element} parentEl future containing element
+ */
+ prepend: function(el, parentEl) {
+ if (parentEl.childElementCount === 0) {
+ parentEl.appendChild(el);
+ }
+ else {
+ parentEl.insertBefore(el, parentEl.children[0]);
+ }
+ },
+
+ /**
+ * Inserts an element after an existing element.
+ *
+ * @param {Element} newEl element to insert
+ * @param {Element} el reference element
+ */
+ insertAfter: function(newEl, el) {
+ if (el.nextElementSibling !== null) {
+ el.parentNode.insertBefore(newEl, el.nextElementSibling);
+ }
+ else {
+ el.parentNode.appendChild(newEl);
+ }
+ },
+
+ /**
+ * Applies a list of CSS properties to an element.
+ *
+ * @param {Element} el element
+ * @param {Object<string, mixed>} styles list of CSS styles
+ */
+ setStyles: function(el, styles) {
+ for (var property in styles) {
+ if (styles.hasOwnProperty(property)) {
+ el.style.setProperty(property, styles[property]);
+ }
+ }
+ },
+
+ /**
+ * Returns a style property value as integer.
+ *
+ * The behavior of this method is undefined for properties that are not considered
+ * to have a "numeric" value, e.g. "background-image".
+ *
+ * @param {CSSStyleDeclaration} styles result of window.getComputedStyle()
+ * @param {string} propertyName property name
+ * @return {integer} property value as integer
+ */
+ styleAsInt: function(styles, propertyName) {
+ var value = styles.getPropertyValue(propertyName);
+ if (value === null) {
+ return 0;
+ }
+
+ return parseInt(value);
+ }
+ };
+
+ // expose on window object for backward compatibility
+ window.bc_wcfDOMUtil = DOMUtil;
+
+ return DOMUtil;
+});
+++ /dev/null
-/**
- * Utility class to align elements relatively to another.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/Alignment
- */
-define(['Core', 'Language', 'DOM/Traverse', 'DOM/Util'], function(Core, Language, DOMTraverse, DOMUtil) {
- "use strict";
-
- /**
- * @exports WoltLab/WCF/UI/Alignment
- */
- var UIAlignment = {
- /**
- * Sets the alignment for target element relatively to the reference element.
- *
- * @param {Element} el target element
- * @param {Element} ref reference element
- * @param {object<string, *>} options list of options to alter the behavior
- */
- set: function(el, ref, options) {
- options = Core.extend({
- // offset to reference element
- verticalOffset: 7,
-
- // align the pointer element, expects .elementPointer as a direct child of given element
- pointer: false,
-
- // offset from/left side, ignored for center alignment
- pointerOffset: 4,
-
- // use static pointer positions, expects two items: class to move it to the bottom and the second to move it to the right
- pointerClassNames: [],
-
- // alternate element used to calculate dimensions
- refDimensionsElement: null,
-
- // preferred alignment, possible values: left/right/center and top/bottom
- horizontal: 'left',
- vertical: 'bottom',
-
- // allow flipping over axis, possible values: both, horizontal, vertical and none
- allowFlip: 'both'
- }, options);
-
- if (!Array.isArray(options.pointerClassNames) || options.pointerClassNames.length !== (options.pointer ? 1 : 2)) options.pointerClassNames = [];
- if (['left', 'right', 'center'].indexOf(options.horizontal) === -1) options.horizontal = 'left';
- if (options.vertical !== 'bottom') options.vertical = 'top';
- if (['both', 'horizontal', 'vertical', 'none'].indexOf(options.allowFlip) === -1) options.allowFlip = 'both';
-
- // place element in the upper left corner to prevent calculation issues due to possible scrollbars
- DOMUtil.setStyles(el, {
- bottom: 'auto !important',
- left: '0 !important',
- right: 'auto !important',
- top: '0 !important'
- });
-
- var elDimensions = DOMUtil.outerDimensions(el);
- var refDimensions = DOMUtil.outerDimensions((options.refDimensionsElement instanceof Element ? options.refDimensionsElement : ref));
- var refOffsets = DOMUtil.offset(ref);
- var windowHeight = window.innerHeight;
- var windowWidth = document.body.clientWidth;
-
- var horizontal = { result: null };
- var alignCenter = false;
- if (options.horizontal === 'center') {
- alignCenter = true;
- horizontal = this._tryAlignmentHorizontal(options.horizontal, elDimensions, refDimensions, refOffsets, windowWidth);
-
- if (!horizontal.result) {
- if (options.allowFlip === 'both' || options.allowFlip === 'horizontal') {
- options.horizontal = 'left';
- }
- else {
- horizontal.result = true;
- }
- }
- }
-
- // in rtl languages we simply swap the value for 'horizontal'
- if (Language.get('wcf.global.pageDirection') === 'rtl') {
- options.horizontal = (options.horizontal === 'left') ? 'right' : 'left';
- }
-
- if (!horizontal.result) {
- var horizontalCenter = horizontal;
- horizontal = this._tryAlignmentHorizontal(options.horizontal, elDimensions, refDimensions, refOffsets, windowWidth);
- if (!horizontal.result && (options.allowFlip === 'both' || options.allowFlip === 'horizontal')) {
- var horizontalFlipped = this._tryAlignmentHorizontal((options.horizontal === 'left' ? 'right' : 'left'), elDimensions, refDimensions, refOffsets, windowWidth);
- // only use these results if it fits into the boundaries, otherwise both directions exceed and we honor the demanded direction
- if (horizontalFlipped.result) {
- horizontal = horizontalFlipped;
- }
- else if (alignCenter) {
- horizontal = horizontalCenter;
- }
- }
- }
-
- var left = horizontal.left;
- var right = horizontal.right;
-
- var vertical = this._tryAlignmentVertical(options.vertical, elDimensions, refDimensions, refOffsets, windowHeight, options.verticalOffset);
- if (!vertical.result && (options.allowFlip === 'both' || options.allowFlip === 'vertical')) {
- var verticalFlipped = this._tryAlignmentVertical((options.vertical === 'top' ? 'bottom' : 'top'), elDimensions, refDimensions, refOffsets, windowHeight, options.verticalOffset);
- // only use these results if it fits into the boundaries, otherwise both directions exceed and we honor the demanded direction
- if (verticalFlipped.result) {
- vertical = verticalFlipped;
- }
- }
-
- var bottom = vertical.bottom;
- var top = vertical.top;
-
- // set pointer position
- if (options.pointer) {
- var pointer = DOMTraverse.childrenByClass(el, 'elementPointer');
- pointer = pointer[0] || null;
- if (pointer === null) {
- throw new Error("Expected the .elementPointer element to be a direct children.");
- }
-
- if (horizontal.align === 'center') {
- pointer.classList.add('center');
-
- pointer.classList.remove('left');
- pointer.classList.remove('right');
- }
- else {
- pointer.classList.add(horizontal.align);
-
- pointer.classList.remove('center');
- pointer.classList.remove(horizontal.align === 'left' ? 'right' : 'left');
- }
-
- if (vertical.align === 'top') {
- pointer.classList.add('flipVertical');
- }
- else {
- pointer.classList.remove('flipVertical');
- }
- }
- else if (options.pointerClassNames.length === 2) {
- var pointerRight = 0;
- var pointerBottom = 1;
-
- el.classList[(top === 'auto' ? 'add' : 'remove')](options.pointerClassNames[pointerBottom]);
- el.classList[(left === 'auto' ? 'add' : 'remove')](options.pointerClassNames[pointerRight]);
- }
-
- DOMUtil.setStyles(el, {
- bottom: bottom + (bottom !== 'auto' ? 'px' : ''),
- left: left + (left !== 'auto' ? 'px' : ''),
- right: right + (right !== 'auto' ? 'px' : ''),
- top: top + (top !== 'auto' ? 'px' : '')
- });
- },
-
- /**
- * Calculates left/right position and verifys if the element would be still within the page's boundaries.
- *
- * @param {string} align align to this side of the reference element
- * @param {object<string, integer>} elDimensions element dimensions
- * @param {object<string, integer>} refDimensions reference element dimensions
- * @param {object<string, integer>} refOffsets position of reference element relative to the document
- * @param {integer} windowWidth window width
- * @returns {object<string, *>} calculation results
- */
- _tryAlignmentHorizontal: function(align, elDimensions, refDimensions, refOffsets, windowWidth) {
- var left = 'auto';
- var right = 'auto';
- var result = true;
-
- if (align === 'left') {
- left = refOffsets.left;
- if (left + elDimensions.width > windowWidth) {
- result = false;
- }
- }
- else if (align === 'right') {
- right = windowWidth - (refOffsets.left + refDimensions.width);
- if (right < 0) {
- result = false;
- }
- }
- else {
- left = refOffsets.left + (refDimensions.width / 2) - (elDimensions.width / 2);
- left = ~~left;
-
- if (left < 0 || left + elDimensions.width > windowWidth) {
- result = false;
- }
- }
-
- return {
- align: align,
- left: left,
- right: right,
- result: result
- };
- },
-
- /**
- * Calculates top/bottom position and verifys if the element would be still within the page's boundaries.
- *
- * @param {string} align align to this side of the reference element
- * @param {object<string, integer>} elDimensions element dimensions
- * @param {object<string, integer>} refDimensions reference element dimensions
- * @param {object<string, integer>} refOffsets position of reference element relative to the document
- * @param {integer} windowHeight window height
- * @param {integer} verticalOffset desired gap between element and reference element
- * @returns {object<string, *>} calculation results
- */
- _tryAlignmentVertical: function(align, elDimensions, refDimensions, refOffsets, windowHeight, verticalOffset) {
- var bottom = 'auto';
- var top = 'auto';
- var result = true;
-
- if (align === 'top') {
- var bodyHeight = document.body.clientHeight;
- bottom = (bodyHeight - refOffsets.top) + verticalOffset;
- if (bodyHeight - (bottom + elDimensions.height) < document.body.scrollTop) {
- result = false;
- }
- }
- else {
- top = refOffsets.top + refDimensions.height + verticalOffset;
- if (top + elDimensions.height > windowHeight) {
- result = false;
- }
- }
-
- return {
- align: align,
- bottom: bottom,
- top: top,
- result: result
- };
- }
- };
-
- return UIAlignment;
-});
+++ /dev/null
-/**
- * Allows to be informed when a click event bubbled up to the document's body.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/CloseOlveray
- */
-define(['CallbackList'], function(CallbackList) {
- "use strict";
-
- var _callbackList = new CallbackList();
-
- /**
- * @exports WoltLab/WCF/UI/CloseOverlay
- */
- var UICloseOverlay = {
- /**
- * Sets up global event listener for bubbled clicks events.
- */
- setup: function() {
- document.body.addEventListener('click', this.execute.bind(this));
- },
-
- /**
- * @see WoltLab/WCF/CallbackList#add
- */
- add: _callbackList.add.bind(_callbackList),
-
- /**
- * @see WoltLab/WCF/CallbackList#remove
- */
- remove: _callbackList.remove.bind(_callbackList),
-
- /**
- * Invokes all registered callbacks.
- */
- execute: function() {
- _callbackList.forEach(null, function(callback) {
- callback();
- });
- }
- };
-
- UICloseOverlay.setup();
-
- return UICloseOverlay;
-});
+++ /dev/null
-/**
- * Provides the sidebar toggle button.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/Collapsible/Sidebar
- */
-define(['Ajax', 'Language', 'DOM/Util'], function(Ajax, Language, DOMUtil) {
- "use strict";
-
- var _isOpen = false;
- var _main = null;
- var _name = '';
-
- /**
- * @module WoltLab/WCF/UI/Collapsible/Sidebar
- */
- var UICollapsibleSidebar = {
- /**
- * Sets up the toggle button.
- */
- setup: function() {
- var sidebar = document.querySelector('.sidebar');
- if (sidebar === null) {
- return;
- }
-
- _isOpen = (sidebar.getAttribute('data-is-open') === 'true');
- _main = document.getElementById('main');
- _name = sidebar.getAttribute('data-sidebar-name');
-
- this._createUI(sidebar);
-
- _main.classList[(_isOpen ? 'remove' : 'add')]('sidebarCollapsed');
- },
-
- /**
- * Creates the toggle button.
- *
- * @param {Element} sidebar sidebar element
- */
- _createUI: function(sidebar) {
- var button = document.createElement('a');
- button.href = '#';
- button.className = 'collapsibleButton jsTooltip';
- button.setAttribute('title', Language.get('wcf.global.button.collapsible'));
-
- var span = document.createElement('span');
- span.appendChild(button);
- DOMUtil.prepend(span, sidebar);
-
- button.addEventListener('click', this._click.bind(this));
- },
-
- /**
- * Toggles the sidebar on click.
- *
- * @param {object} event event object
- */
- _click: function(event) {
- event.preventDefault();
-
- _isOpen = (_isOpen === false);
-
- Ajax.api(this, {
- isOpen: ~~_isOpen
- });
- },
-
- _ajaxSetup: function() {
- return {
- data: {
- actionName: 'toggle',
- className: 'wcf\\system\\user\\collapsible\\content\\UserCollapsibleSidebarHandler',
- sidebarName: _name
- },
- url: 'index.php/AJAXInvoke/?t=' + SECURITY_TOKEN + SID_ARG_2ND
- };
- },
-
- _ajaxSuccess: function(data) {
- _main.classList[(_isOpen ? 'remove' : 'add')]('sidebarCollapsed');
- }
- };
-
- return UICollapsibleSidebar;
-});
+++ /dev/null
-/**
- * Provides the confirmation dialog overlay.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/Confirmation
- */
-define(['Core', 'Language', 'UI/Dialog'], function(Core, Language, UIDialog) {
- "use strict";
-
- var _active = false;
- var _confirmButton = null;
- var _content = null;
- var _options = {};
- var _text = null;
-
- /**
- * Confirmation dialog overlay.
- *
- * @exports WoltLab/WCF/UI/Confirmation
- */
- var UIConfirmation = {
- /**
- * Shows the confirmation dialog.
- *
- * Possible options:
- * - cancel: callback if user cancels the dialog
- * - confirm: callback if user confirm the dialog
- * - legacyCallback: WCF 2.0/2.1 compatible callback with string parameter
- * - message: displayed confirmation message
- * - parameters: list of parameters passed to the callback on confirm
- * - template: optional HTML string to be inserted below the `message`
- *
- * @param {object<string, *>} options confirmation options
- */
- show: function(options) {
- if (UIDialog === undefined) UIDialog = require('UI/Dialog');
-
- if (_active) {
- return;
- }
-
- _options = Core.extend({
- cancel: null,
- confirm: null,
- legacyCallback: null,
- message: '',
- parameters: {},
- template: ''
- }, options);
-
- _options.message = (typeof _options.message === 'string') ? _options.message.trim() : '';
- if (!_options.message.length) {
- throw new Error("Expected a non-empty string for option 'message'.");
- }
-
- if (typeof _options.confirm !== 'function' && typeof _options.legacyCallback !== 'function') {
- throw new TypeError("Expected a valid callback for option 'confirm'.");
- }
-
- if (_content === null) {
- this._createDialog();
- }
-
- _content.innerHTML = (typeof options.template === 'string') ? options.template.trim() : '';
- _text.textContent = _options.message;
-
- _active = true;
-
- UIDialog.open(this);
- },
-
- _dialogSetup: function() {
- return {
- id: 'wcfSystemConfirmation',
- options: {
- onClose: this._onClose.bind(this),
- onShow: this._onShow.bind(this),
- title: Language.get('wcf.global.confirmation.title')
- }
- };
- },
-
- /**
- * Returns content container element.
- *
- * @return {Element} content container element
- */
- getContentElement: function() {
- return _content;
- },
-
- /**
- * Creates the dialog DOM elements.
- */
- _createDialog: function() {
- var dialog = document.createElement('div');
- dialog.setAttribute('id', 'wcfSystemConfirmation');
- dialog.classList.add('systemConfirmation');
-
- _text = document.createElement('p');
- dialog.appendChild(_text);
-
- _content = document.createElement('div');
- _content.setAttribute('id', 'wcfSystemConfirmationContent');
- dialog.appendChild(_content);
-
- var formSubmit = document.createElement('div');
- formSubmit.classList.add('formSubmit');
- dialog.appendChild(formSubmit);
-
- _confirmButton = document.createElement('button');
- _confirmButton.classList.add('buttonPrimary');
- _confirmButton.textContent = Language.get('wcf.global.confirmation.confirm');
- _confirmButton.addEventListener('click', this._confirm.bind(this));
- formSubmit.appendChild(_confirmButton);
-
- var cancelButton = document.createElement('button');
- cancelButton.textContent = Language.get('wcf.global.confirmation.cancel');
- cancelButton.addEventListener('click', function() { UIDialog.close('wcfSystemConfirmation'); });
- formSubmit.appendChild(cancelButton);
-
- document.body.appendChild(dialog);
- },
-
- /**
- * Invoked if the user confirms the dialog.
- */
- _confirm: function() {
- if (typeof _options.legacyCallback === 'function') {
- _options.legacyCallback('confirm', _options.parameters);
- }
- else {
- _options.confirm(_options.parameters);
- }
-
- _active = false;
- UIDialog.close('wcfSystemConfirmation');
- },
-
- /**
- * Invoked on dialog close or if user cancels the dialog.
- */
- _onClose: function() {
- if (_active) {
- _confirmButton.blur();
- _active = false;
-
- if (typeof _options.legacyCallback === 'function') {
- _options.legacyCallback('cancel', _options.parameters);
- }
- else if (typeof _options.cancel === 'function') {
- _options.cancel(_options.parameters);
- }
- }
- },
-
- /**
- * Sets the focus on the confirm button on dialog open for proper keyboard support.
- */
- _onShow: function() {
- _confirmButton.blur();
- _confirmButton.focus();
- }
- };
-
- return UIConfirmation;
-});
+++ /dev/null
-/**
- * Modal dialog handler.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/Dialog
- */
-define(
- [
- 'enquire', 'Ajax', 'Core', 'Dictionary',
- 'Environment', 'Language', 'ObjectMap', 'DOM/ChangeListener',
- 'DOM/Util', 'UI/Confirmation'
- ],
- function(
- enquire, Ajax, Core, Dictionary,
- Environment, Language, ObjectMap, DOMChangeListener,
- DOMUtil, UIConfirmation
- )
-{
- "use strict";
-
- var _activeDialog = null;
- var _container = null;
- var _dialogs = new Dictionary();
- var _dialogObjects = new ObjectMap();
- var _dialogFullHeight = false;
- var _keyupListener = null;
-
- /**
- * @exports WoltLab/WCF/UI/Dialog
- */
- var UIDialog = {
- /**
- * Sets up global container and internal variables.
- */
- setup: function() {
- // Fetch Ajax, as it cannot be provided because of a circular dependency
- if (Ajax === undefined) Ajax = require('Ajax');
-
- _container = document.createElement('div');
- _container.classList.add('dialogOverlay');
- _container.setAttribute('aria-hidden', 'true');
- _container.addEventListener('click', this._closeOnBackdrop.bind(this));
-
- document.body.appendChild(_container);
-
- _keyupListener = (function(event) {
- if (event.keyCode === 27) {
- if (event.target.nodeName !== 'INPUT' && event.target.nodeName !== 'TEXTAREA') {
- this.close(_activeDialog);
-
- return false;
- }
- }
-
- return true;
- }).bind(this);
-
- enquire.register('screen and (max-width: 800px)', {
- match: function() { _dialogFullHeight = true; },
- unmatch: function() { _dialogFullHeight = false; },
- setup: function() { _dialogFullHeight = true; },
- deferSetup: true
- });
- },
-
- /**
- * Opens the dialog and implicitly creates it on first usage.
- *
- * @param {object} callbackObject used to invoke `_dialogSetup()` on first call
- * @param {(string|DocumentFragment=} html html content or document fragment to use for dialog content
- * @returns {object<string, *>} dialog data
- */
- open: function(callbackObject, html) {
- var dialogData = _dialogObjects.get(callbackObject);
- if (Core.isPlainObject(dialogData)) {
- // dialog already exists
- return this.openStatic(dialogData.id, html);
- }
-
- // initialize a new dialog
- if (typeof callbackObject._dialogSetup !== 'function') {
- throw new Error("Callback object does not implement the method '_dialogSetup()'.");
- }
-
- var setupData = callbackObject._dialogSetup();
- if (!Core.isPlainObject(setupData)) {
- throw new Error("Expected an object literal as return value of '_dialogSetup()'.");
- }
-
- dialogData = { id: setupData.id };
-
- var createOnly = true;
- if (setupData.source === undefined) {
- var dialogElement = document.getElementById(setupData.id);
- if (dialogElement === null) {
- throw new Error("Element id '" + setupData.id + "' is invalid and no source attribute was given.");
- }
-
- setupData.source = document.createDocumentFragment();
- setupData.source.appendChild(dialogElement);
- }
- else if (setupData.source === null) {
- // `null` means there is no static markup and `html` should be used instead
- setupData.source = html;
- }
-
- else if (typeof setupData.source === 'function') {
- setupData.source();
- }
- else if (Core.isPlainObject(setupData.source)) {
- Ajax.api(this, setupData.source.data, (function(data) {
- if (data.returnValues && typeof data.returnValues.template === 'string') {
- this.open(callbackObject, data.returnValues.template);
-
- if (typeof setupData.source.after === 'function') {
- setupData.source.after(_dialogs.get(setupData.id).content, data);
- }
- }
- }).bind(this));
- }
- else {
- if (typeof setupData.source === 'string') {
- var dialogElement = document.createElement('div');
- dialogElement.setAttribute('id', setupData.id);
- dialogElement.innerHTML = setupData.source;
-
- setupData.source = document.createDocumentFragment();
- setupData.source.appendChild(dialogElement);
- }
-
- if (!setupData.source.nodeType || setupData.source.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
- throw new Error("Expected at least a document fragment as 'source' attribute.");
- }
-
- createOnly = false;
- }
-
- _dialogObjects.set(callbackObject, dialogData);
-
- return this.openStatic(setupData.id, setupData.source, setupData.options, createOnly);
- },
-
- /**
- * Opens an dialog, if the dialog is already open the content container
- * will be replaced by the HTML string contained in the parameter html.
- *
- * If id is an existing element id, html will be ignored and the referenced
- * element will be appended to the content element instead.
- *
- * @param {string} id element id, if exists the html parameter is ignored in favor of the existing element
- * @param {?(string|DocumentFragment)} html content html
- * @param {object<string, *>} options list of options, is completely ignored if the dialog already exists
- * @param {boolean=} createOnly create the dialog but do not open it
- * @return {object<string, *>} dialog data
- */
- openStatic: function(id, html, options, createOnly) {
- if (_dialogs.has(id)) {
- this._updateDialog(id, html);
- }
- else {
- options = Core.extend({
- backdropCloseOnClick: true,
- closable: true,
- closeButtonLabel: Language.get('wcf.global.button.close'),
- closeConfirmMessage: '',
- disableContentPadding: false,
- disposeOnClose: false,
- title: '',
-
- // callbacks
- onBeforeClose: null,
- onClose: null,
- onShow: null
- }, options);
-
- if (!options.closable) options.backdropCloseOnClick = false;
- if (options.closeConfirmMessage) {
- options.onBeforeClose = (function(id) {
- UIConfirmation.show({
- confirm: this.close.bind(this, id),
- message: options.closeConfirmMessage
- });
- }).bind(this);
- }
-
- this._createDialog(id, html, options);
- }
-
- return _dialogs.get(id);
- },
-
- /**
- * Sets the dialog title.
- *
- * @param {string} id element id
- * @param {string} title dialog title
- */
- setTitle: function(id, title) {
- var data = _dialogs.get(id);
- if (data === undefined) {
- throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
- }
-
- var header = DOMTraverse.childrenByTag(data.dialog, 'HEADER');
- DOMTraverse.childrenByTag(header[0], 'SPAN').textContent = title;
- },
-
- /**
- * Creates the DOM for a new dialog and opens it.
- *
- * @param {string} id element id, if exists the html parameter is ignored in favor of the existing element
- * @param {?(string|DocumentFragment)} html content html
- * @param {object<string, *>} options list of options
- * @param {boolean=} createOnly create the dialog but do not open it
- */
- _createDialog: function(id, html, options, createOnly) {
- var element = null;
- if (html === null) {
- element = document.getElementById(id);
- if (element === null) {
- throw new Error("Expected either a HTML string or an existing element id.");
- }
- }
-
- var dialog = document.createElement('div');
- dialog.classList.add('dialogContainer');
- dialog.setAttribute('aria-hidden', 'true');
- dialog.setAttribute('role', 'dialog');
- dialog.setAttribute('data-id', id);
-
- if (options.disposeOnClose) {
- dialog.setAttribute('data-dispose-on-close', true);
- }
-
- var header = document.createElement('header');
- dialog.appendChild(header);
-
- if (options.title) {
- var titleId = DOMUtil.getUniqueId();
- dialog.setAttribute('aria-labelledby', titleId);
-
- var title = document.createElement('span');
- title.classList.add('dialogTitle');
- title.textContent = options.title;
- title.setAttribute('id', titleId);
- header.appendChild(title);
- }
-
- if (options.closable) {
- var closeButton = document.createElement('a');
- closeButton.className = 'dialogCloseButton jsTooltip';
- closeButton.setAttribute('title', options.closeButtonLabel);
- closeButton.setAttribute('aria-label', options.closeButtonLabel);
- closeButton.addEventListener('click', this._close.bind(this));
- header.appendChild(closeButton);
-
- var span = document.createElement('span');
- span.textContent = options.closeButtonLabel;
- closeButton.appendChild(span);
- }
-
- var contentContainer = document.createElement('div');
- contentContainer.classList.add('dialogContent');
- if (options.disableContentPadding) contentContainer.classList.add('dialogContentNoPadding');
- dialog.appendChild(contentContainer);
-
- var content;
- if (element === null) {
- content = document.createElement('div');
-
- if (typeof html === 'string') {
- content.innerHTML = html;
- }
- else if (html instanceof DocumentFragment) {
- if (html.children[0].nodeName !== 'div' || html.childElementCount > 1) {
- content.appendChild(html);
- }
- else {
- content = html.children[0];
- }
- }
-
- content.id = id;
- }
- else {
- content = element;
- }
-
- contentContainer.appendChild(content);
-
- if (content.style.getPropertyValue('display') === 'none') {
- content.style.removeProperty('display');
- }
-
- _dialogs.set(id, {
- backdropCloseOnClick: options.backdropCloseOnClick,
- content: content,
- dialog: dialog,
- header: header,
- onBeforeClose: options.onBeforeClose,
- onClose: options.onClose,
- onShow: options.onShow
- });
-
- DOMUtil.prepend(dialog, _container);
-
- if (createOnly !== true) {
- this._updateDialog(id, null);
- }
- },
-
- /**
- * Updates the dialog's content element.
- *
- * @param {string} id element id
- * @param {?string} html content html, prevent changes by passing null
- */
- _updateDialog: function(id, html) {
- var data = _dialogs.get(id);
- if (data === undefined) {
- throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
- }
-
- if (typeof html === 'string') {
- data.content.innerHTML = '';
-
- var content = document.createElement('div');
- content.innerHTML = html;
-
- data.content.appendChild(content);
- }
-
- if (data.dialog.getAttribute('aria-hidden') === 'true') {
- if (_container.getAttribute('aria-hidden') === 'true') {
- window.addEventListener('keyup', _keyupListener);
- }
-
- data.dialog.setAttribute('aria-hidden', 'false');
- _container.setAttribute('aria-hidden', 'false');
- _container.setAttribute('data-close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
- _activeDialog = id;
-
- this.rebuild(id);
-
- if (typeof data.onShow === 'function') {
- data.onShow(id);
- }
- }
-
- DOMChangeListener.trigger();
- },
-
- /**
- * Rebuilds dialog identified by given id.
- *
- * @param {string} id element id
- */
- rebuild: function(id) {
- var data = _dialogs.get(id);
- if (data === undefined) {
- throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
- }
-
- // ignore non-active dialogs
- if (data.dialog.getAttribute('aria-hidden') === 'true') {
- return;
- }
-
- var contentContainer = data.content.parentNode;
-
- var formSubmit = data.content.querySelector('.formSubmit');
- var unavailableHeight = 0;
- if (formSubmit !== null) {
- contentContainer.classList.add('dialogForm');
- formSubmit.classList.add('dialogFormSubmit');
-
- unavailableHeight += DOMUtil.outerHeight(formSubmit);
- contentContainer.style.setProperty('margin-bottom', unavailableHeight + 'px');
- }
- else {
- contentContainer.classList.remove('dialogForm');
- }
-
- unavailableHeight += DOMUtil.outerHeight(data.header);
-
- var maximumHeight = (window.innerHeight * (_dialogFullHeight ? 1 : 0.8)) - unavailableHeight;
- contentContainer.style.setProperty('max-height', ~~maximumHeight + 'px');
-
- // fix for a calculation bug in Chrome causing the scrollbar to overlap the border
- if (Environment.browser() === 'chrome') {
- if (data.content.scrollHeight > maximumHeight) {
- data.content.style.setProperty('margin-right', '-1px');
- }
- else {
- data.content.style.removeProperty('margin-right');
- }
- }
- },
-
- /**
- * Handles clicks on the close button or the backdrop if enabled.
- *
- * @param {object} event click event
- * @return {boolean} false if the event should be cancelled
- */
- _close: function(event) {
- event.preventDefault();
-
- var data = _dialogs.get(_activeDialog);
- if (typeof data.onBeforeClose === 'function') {
- data.onBeforeClose(_activeDialog);
-
- return false;
- }
-
- this.close(_activeDialog);
- },
-
- /**
- * Closes the current active dialog by clicks on the backdrop.
- *
- * @param {object} event event object
- */
- _closeOnBackdrop: function(event) {
- if (event.target !== _container) {
- return true;
- }
-
- if (_container.getAttribute('data-close-on-click') === 'true') {
- this._close(event);
- }
- else {
- event.preventDefault();
- }
- },
-
- /**
- * Closes a dialog identified by given id.
- *
- * @param {(string|object)} id element id or callback object
- */
- close: function(id) {
- if (typeof id === 'object') {
- var dialogData = _dialogObjects.get(id);
- if (dialogData !== undefined) {
- id = dialogData.id;
- }
- }
-
- var data = _dialogs.get(id);
- if (data === undefined) {
- throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
- }
-
- if (typeof data.onClose === 'function') {
- data.onClose(id);
- }
-
- if (data.dialog.getAttribute('data-dispose-on-close')) {
- setTimeout(function() {
- if (data.dialog.getAttribute('aria-hidden') === 'true') {
- _container.removeChild(data.dialog);
- _dialogs['delete'](id);
- }
- }, 5000);
- }
- else {
- data.dialog.setAttribute('aria-hidden', 'true');
- }
-
- // get next active dialog
- _activeDialog = null;
- for (var i = 0; i < _container.childElementCount; i++) {
- var child = _container.children[i];
- if (child.getAttribute('aria-hidden') === 'false') {
- _activeDialog = child.getAttribute('data-id');
- break;
- }
- }
-
- if (_activeDialog === null) {
- _container.setAttribute('aria-hidden', 'true');
- _container.setAttribute('data-close-on-click', 'false');
-
- window.removeEventListener('keyup', _keyupListener);
- }
- else {
- data = _dialogs.get(_activeDialog);
- _container.setAttribute('data-close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
- }
- },
-
- /**
- * Returns the dialog data for given element id.
- *
- * @param {string} id element id
- * @return {(object|undefined)} dialog data or undefined if element id is unknown
- */
- getDialog: function(id) {
- return _dialogs.get(id);
- },
-
- _ajaxSetup: function() {
- return {};
- }
- };
-
- return UIDialog;
-});
+++ /dev/null
-/**
- * Simple Dropdown
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/Dropdown/Simple
- */
-define(
- [ 'CallbackList', 'Core', 'Dictionary', 'UI/Alignment', 'DOM/ChangeListener', 'DOM/Traverse', 'DOM/Util', 'UI/CloseOverlay'],
- function(CallbackList, Core, Dictionary, UIAlignment, DOMChangeListener, DOMTraverse, DOMUtil, UICloseOverlay)
-{
- "use strict";
-
- var _availableDropdowns = null;
- var _callbacks = new CallbackList();
- var _didInit = false;
- var _dropdowns = new Dictionary();
- var _menus = new Dictionary();
- var _menuContainer = null;
-
- /**
- * @exports WoltLab/WCF/UI/Dropdown/Simple
- */
- var SimpleDropdown = {
- /**
- * Performs initial setup such as setting up dropdowns and binding listeners.
- */
- setup: function() {
- if (_didInit) return;
- _didInit = true;
-
- _menuContainer = document.createElement('div');
- _menuContainer.setAttribute('id', 'dropdownMenuContainer');
- document.body.appendChild(_menuContainer);
-
- _availableDropdowns = document.getElementsByClassName('dropdownToggle');
-
- this.initAll();
-
- UICloseOverlay.add('WoltLab/WCF/UI/Dropdown/Simple', this.closeAll.bind(this));
- DOMChangeListener.add('WoltLab/WCF/UI/Dropdown/Simple', this.initAll.bind(this));
-
- document.addEventListener('scroll', this._onScroll.bind(this));
-
- // expose on window object for backward compatibility
- window.bc_wcfSimpleDropdown = this;
- },
-
- /**
- * Loops through all possible dropdowns and registers new ones.
- */
- initAll: function() {
- for (var i = 0, length = _availableDropdowns.length; i < length; i++) {
- this.init(_availableDropdowns[i], false);
- }
- },
-
- /**
- * Initializes a dropdown.
- *
- * @param {Element} button
- * @param {boolean} isLazyInitialization
- */
- init: function(button, isLazyInitialization) {
- this.setup();
-
- if (button.classList.contains('jsDropdownEnabled') || button.getAttribute('data-target')) {
- return false;
- }
-
- var dropdown = DOMTraverse.parentByClass(button, 'dropdown');
- if (dropdown === null) {
- throw new Error("Invalid dropdown passed, button '" + DOMUtil.identify(button) + "' does not have a parent with .dropdown.");
- }
-
- var menu = DOMTraverse.nextByClass(button, 'dropdownMenu');
- if (menu === null) {
- throw new Error("Invalid dropdown passed, button '" + DOMUtil.identify(button) + "' does not have a menu as next sibling.");
- }
-
- // move menu into global container
- _menuContainer.appendChild(menu);
-
- var containerId = DOMUtil.identify(dropdown);
- if (!_dropdowns.has(containerId)) {
- button.classList.add('jsDropdownEnabled');
- button.addEventListener('click', this._toggle.bind(this));
-
- _dropdowns.set(containerId, dropdown);
- _menus.set(containerId, menu);
-
- if (!containerId.match(/^wcf\d+$/)) {
- menu.setAttribute('data-source', containerId);
- }
- }
-
- button.setAttribute('data-target', containerId);
-
- if (isLazyInitialization) {
- setTimeout(function() { Core.triggerEvent(button, 'click'); }, 10);
- }
- },
-
- /**
- * Initializes a remote-controlled dropdown.
- *
- * @param {Element} dropdown dropdown wrapper element
- * @param {Element} menu menu list element
- */
- initFragment: function(dropdown, menu) {
- this.setup();
-
- if (_dropdowns.has(dropdown)) {
- return;
- }
-
- var containerId = DOMUtil.identify(dropdown);
- _dropdowns.set(containerId, dropdown);
- _menuContainer.appendChild(menu);
-
- _menus.set(containerId, menu);
- },
-
- /**
- * Registers a callback for open/close events.
- *
- * @param {string} containerId dropdown wrapper id
- * @param {function(string, string)} callback
- */
- registerCallback: function(containerId, callback) {
- _callbacks.add(containerId, callback);
- },
-
- /**
- * Returns the requested dropdown wrapper element.
- *
- * @return {Element} dropdown wrapper element
- */
- getDropdown: function(containerId) {
- return _dropdowns.get(containerId);
- },
-
- /**
- * Returns the requested dropdown menu list element.
- *
- * @return {Element} menu list element
- */
- getDropdownMenu: function(containerId) {
- return _menus.get(containerId);
- },
-
- /**
- * Toggles the requested dropdown between opened and closed.
- *
- * @param {string} containerId dropdown wrapper id
- */
- toggleDropdown: function(containerId) {
- this._toggle(null, containerId);
- },
-
- /**
- * Calculates and sets the alignment of given dropdown.
- *
- * @param {Element} dropdown dropdown wrapper element
- * @param {Element} dropdownMenu menu list element
- */
- setAlignment: function(dropdown, dropdownMenu) {
- // check if button belongs to an i18n textarea
- var button = dropdown.querySelector('.dropdownToggle');
- var refDimensionsElement = null;
- if (button !== null && button.classList.contains('dropdownCaptionTextarea')) {
- refDimensionsElement = button;
- }
-
- UIAlignment.set(dropdownMenu, dropdown, {
- pointerClassNames: ['dropdownArrowBottom', 'dropdownArrowRight'],
- refDimensionsElement: refDimensionsElement
- });
- },
-
- /**
- * Calculats and sets the alignment of the dropdown identified by given id.
- *
- * @param {string} containerId dropdown wrapper id
- */
- setAlignmentById: function(containerId) {
- var dropdown = _dropdowns.get(containerId);
- if (dropdown === undefined) {
- throw new Error("Unknown dropdown identifier '" + containerId + "'.");
- }
-
- var menu = _menus.get(containerId);
-
- this.setAlignment(dropdown, menu);
- },
-
- /**
- * Returns true if target dropdown exists and is open.
- *
- * @param {string} containerId dropdown wrapper id
- * @return {boolean} true if dropdown exists and is open
- */
- isOpen: function(containerId) {
- var menu = _menus.get(containerId);
- if (menu !== undefined && menu.classList.contains('dropdownOpen')) {
- return true;
- }
-
- return false;
- },
-
- /**
- * Opens the dropdown unless it is already open.
- *
- * @param {string} containerId dropdown wrapper id
- */
- open: function(containerId) {
- var menu = _menus.get(containerId);
- if (menu !== undefined && !menu.classList.contains('dropdownOpen')) {
- this.toggleDropdown(containerId);
- }
- },
-
- /**
- * Closes the dropdown identified by given id without notifying callbacks.
- *
- * @param {string} containerId dropdown wrapper id
- */
- close: function(containerId) {
- var dropdown = _dropdowns.get(containerId);
- if (dropdown !== undefined) {
- dropdown.classList.remove('dropdownOpen');
- _menus.get(containerId).classList.remove('dropdownOpen');
- }
- },
-
- /**
- * Closes all dropdowns.
- */
- closeAll: function() {
- _dropdowns.forEach((function(dropdown, containerId) {
- if (dropdown.classList.contains('dropdownOpen')) {
- dropdown.classList.remove('dropdownOpen');
- _menus.get(containerId).classList.remove('dropdownOpen');
-
- this._notifyCallbacks(containerId, 'close');
- }
- }).bind(this));
- },
-
- /**
- * Destroys a dropdown identified by given id.
- *
- * @param {string} containerId dropdown wrapper id
- * @return {boolean} false for unknown dropdowns
- */
- destroy: function(containerId) {
- if (!_dropdowns.has(containerId)) {
- return false;
- }
-
- this.close(containerId);
-
- var menu = _menus.get(containerId);
- _menus.parentNode.removeChild(menu);
-
- _menus['delete'](containerId);
- _dropdowns['delete'](containerId);
-
- return true;
- },
-
- /**
- * Handles dropdown positions in overlays when scrolling in the overlay.
- *
- * @param {Event} event event object
- */
- _onDialogScroll: function(event) {
- var dialogContent = event.currentTarget;
- var dropdowns = dialogContent.querySelectorAll('.dropdown.dropdownOpen');
-
- for (var i = 0, length = dropdowns.length; i < length; i++) {
- var dropdown = dropdowns[i];
- var containerId = DOMUtil.identify(dropdown);
- var offset = DOMUtil.offset(dropdown);
- var dialogOffset = DOMUtil.offset(dialogContent);
-
- // check if dropdown toggle is still (partially) visible
- if (offset.top + dropdown.clientHeight <= dialogOffset.top) {
- // top check
- this.toggleDropdown(containerId);
- }
- else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) {
- // bottom check
- this.toggleDropdown(containerId);
- }
- else if (offset.left <= dialogOffset.left) {
- // left check
- this.toggleDropdown(containerId);
- }
- else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) {
- // right check
- this.toggleDropdown(containerId);
- }
- else {
- this.setAlignment(containerId, _menus.get(containerId));
- }
- }
- },
-
- /**
- * Recalculates dropdown positions on page scroll.
- */
- _onScroll: function() {
- _dropdowns.forEach((function(dropdown, containerId) {
- if (dropdown.getAttribute('data-is-overlay-dropdown-button') === true && dropdown.classList.contains('dropdownOpen')) {
- this.setAlignment(dropdown, _menus.get(containerId));
- }
- }).bind(this));
- },
-
- /**
- * Notifies callbacks on status change.
- *
- * @param {string} containerId dropdown wrapper id
- * @param {string} action can be either 'open' or 'close'
- */
- _notifyCallbacks: function(containerId, action) {
- _callbacks.forEach(containerId, function(callback) {
- callback(containerId, action);
- });
- },
-
- /**
- * Toggles the dropdown's state between open and close.
- *
- * @param {?Event} event event object, should be 'null' if targetId is given
- * @param {string=} targetId dropdown wrapper id
- * @return {boolean} 'false' if event is not null
- */
- _toggle: function(event, targetId) {
- if (event !== null) {
- event.preventDefault();
- event.stopPropagation();
-
- targetId = event.currentTarget.getAttribute('data-target');
- }
-
- // check if 'isOverlayDropdownButton' is set which indicates if
- // the dropdown toggle is in an overlay
- var dropdown = _dropdowns.get(targetId);
- if (dropdown !== undefined && dropdown.getAttribute('data-is-overlay-dropdown-button') === null) {
- var dialogContent = DOMTraverse.parentByClass(dropdown, 'dialogContent');
- dropdown.setAttribute('data-is-overlay-dropdown-button', (dialogContent !== null));
-
- if (dialogContent !== null) {
- dialogContent.addEventListener('scroll', this._onDialogScroll.bind(this));
- }
- }
-
- // close all dropdowns
- _dropdowns.forEach((function(dropdown, containerId) {
- var menu = _menus.get(containerId);
-
- if (dropdown.classList.contains('dropdownOpen')) {
- dropdown.classList.remove('dropdownOpen');
- menu.classList.remove('dropdownOpen');
-
- this._notifyCallbacks(containerId, 'close');
- }
- else if (containerId === targetId && menu.childElementCount > 0) {
- dropdown.classList.add('dropdownOpen');
- menu.classList.add('dropdownOpen');
-
- this._notifyCallbacks(containerId, 'open');
-
- this.setAlignment(dropdown, menu);
- }
- }).bind(this));
-
- // TODO
- WCF.Dropdown.Interactive.Handler.closeAll();
-
- return (event === null);
- }
- };
-
- return SimpleDropdown;
-});
+++ /dev/null
-/**
- * Dynamically transforms menu-like structures to handle items exceeding the available width
- * by moving them into a separate dropdown.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/FlexibleMenu
- */
-define(['Core', 'Dictionary', 'DOM/ChangeListener', 'DOM/Traverse', 'DOM/Util', 'UI/SimpleDropdown'], function(Core, Dictionary, DOMChangeListener, DOMTraverse, DOMUtil, SimpleDropdown) {
- "use strict";
-
- var _containers = new Dictionary();
- var _dropdowns = new Dictionary();
- var _dropdownMenus = new Dictionary();
- var _itemLists = new Dictionary();
-
- /**
- * @exports WoltLab/WCF/UI/FlexibleMenu
- */
- var UIFlexibleMenu = {
- /**
- * Register default menus and set up event listeners.
- */
- setup: function() {
- if (document.getElementById('mainMenu') !== null) this.register('mainMenu');
- var navigationHeader = document.querySelector('.navigationHeader');
- if (navigationHeader !== null) this.register(DOMUtil.identify(navigationHeader));
-
- window.addEventListener('resize', this.rebuildAll.bind(this));
- DOMChangeListener.add('WoltLab/WCF/UI/FlexibleMenu', this.registerTabMenus.bind(this));
- },
-
- /**
- * Registers a menu by element id.
- *
- * @param {string} containerId element id
- */
- register: function(containerId) {
- var container = document.getElementById(containerId);
- if (container === null) {
- throw "Expected a valid element id, '" + containerId + "' does not exist.";
- }
-
- if (_containers.has(containerId)) {
- return;
- }
-
- var list = DOMTraverse.childByTag(container, 'UL');
- if (list === null) {
- throw "Expected an <ul> element as child of container '" + containerId + "'.";
- }
-
- _containers.set(containerId, container);
- _itemLists.set(containerId, list);
-
- this.rebuild(containerId);
- },
-
- /**
- * Registers tab menus.
- */
- registerTabMenus: function() {
- var tabMenus = document.querySelectorAll('.tabMenuContainer:not(.jsFlexibleMenuEnabled), .messageTabMenu:not(.jsFlexibleMenuEnabled)');
- for (var i = 0, length = tabMenus.length; i < length; i++) {
- var tabMenu = tabMenus[i];
- var nav = DOMTraverse.childByTag(tabMenu, 'NAV');
- if (nav !== null) {
- tabMenu.classList.add('jsFlexibleMenuEnabled');
- this.register(DOMUtil.identify(nav));
- }
- }
- },
-
- /**
- * Rebuilds all menus, e.g. on window resize.
- */
- rebuildAll: function() {
- _containers.forEach((function(container, containerId) {
- this.rebuild(containerId);
- }).bind(this));
- },
-
- /**
- * Rebuild the menu identified by given element id.
- *
- * @param {string} containerId element id
- */
- rebuild: function(containerId) {
- var container = _containers.get(containerId);
- if (container === undefined) {
- throw "Expected a valid element id, '" + containerId + "' is unknown.";
- }
-
- var styles = window.getComputedStyle(container);
-
- var availableWidth = container.parentNode.clientWidth;
- availableWidth -= DOMUtil.styleAsInt(styles, 'margin-left');
- availableWidth -= DOMUtil.styleAsInt(styles, 'margin-right');
-
- var list = _itemLists.get(containerId);
- var items = DOMTraverse.childrenByTag(list, 'LI');
- var dropdown = _dropdowns.get(containerId);
- var dropdownWidth = 0;
- if (dropdown !== undefined) {
- // show all items for calculation
- for (var i = 0, length = items.length; i < length; i++) {
- var item = items[i];
- if (item.classList.contains('dropdown')) {
- continue;
- }
-
- item.style.removeProperty('display');
- }
-
- if (dropdown.parentNode !== null) {
- dropdownWidth = DOMUtil.outerWidth(dropdown);
- }
- }
-
- var currentWidth = list.scrollWidth - dropdownWidth;
- var hiddenItems = [];
- if (currentWidth > availableWidth) {
- // hide items starting with the last one
- for (var i = items.length - 1; i >= 0; i--) {
- var item = items[i];
-
- // ignore dropdown and active item
- if (item.classList.contains('dropdown') || item.classList.contains('active') || item.classList.contains('ui-state-active')) {
- continue;
- }
-
- hiddenItems.push(item);
- item.style.setProperty('display', 'none');
-
- if (list.scrollWidth < availableWidth) {
- break;
- }
- }
- }
-
- if (hiddenItems.length) {
- var dropdownMenu;
- if (dropdown === undefined) {
- dropdown = document.createElement('li');
- dropdown.className = 'dropdown jsFlexibleMenuDropdown';
- var icon = document.createElement('a');
- icon.className = 'icon icon16 fa-list';
- dropdown.appendChild(icon);
-
- dropdownMenu = document.createElement('ul');
- dropdownMenu.classList.add('dropdownMenu');
- dropdown.appendChild(dropdownMenu);
-
- _dropdowns.set(containerId, dropdown);
- _dropdownMenus.set(containerId, dropdownMenu);
-
- SimpleDropdown.init(icon);
- }
- else {
- dropdownMenu = _dropdownMenus.get(containerId);
- }
-
- if (dropdown.parentNode === null) {
- list.appendChild(dropdown);
- }
-
- // build dropdown menu
- var fragment = document.createDocumentFragment();
-
- var self = this;
- hiddenItems.forEach(function(hiddenItem) {
- var item = document.createElement('li');
- item.innerHTML = hiddenItem.innerHTML;
-
- item.addEventListener('click', (function(event) {
- event.preventDefault();
-
- Core.triggerEvent(hiddenItem.querySelector('a'), 'click');
-
- // force a rebuild to guarantee the active item being visible
- setTimeout(function() {
- self.rebuild(containerId);
- }, 59);
- }).bind(this));
-
- fragment.appendChild(item);
- });
-
- dropdownMenu.innerHTML = '';
- dropdownMenu.appendChild(fragment);
- }
- else if (dropdown !== undefined && dropdown.parentNode !== null) {
- dropdown.parentNode.removeChild(dropdown);
- }
- }
- };
-
- return UIFlexibleMenu;
-});
+++ /dev/null
-/**
- * Flexible UI element featuring both a list of items and an input field with suggestion support.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/ItemList
- */
-define(['Core', 'Dictionary', 'Language', 'DOM/Traverse', 'WoltLab/WCF/UI/Suggestion'], function(Core, Dictionary, Language, DOMTraverse, UISuggestion) {
- "use strict";
-
- var _activeId = '';
- var _data = new Dictionary();
- var _didInit = false;
-
- var _callbackKeyDown = null;
- var _callbackKeyPress = null;
- var _callbackKeyUp = null;
- var _callbackRemoveItem = null;
-
- /**
- * @exports WoltLab/WCF/UI/ItemList
- */
- var UIItemList = {
- /**
- * Initializes an item list.
- *
- * The `values` argument must be empty or contain a list of strings or object, e.g.
- * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
- *
- * @param {string} elementId input element id
- * @param {array<mixed>} values list of existing values
- * @param {object<string>} options option list
- */
- init: function(elementId, values, options) {
- var element = document.getElementById(elementId);
- if (element === null) {
- throw new Error("Expected a valid element id.");
- }
-
- options = Core.extend({
- // search parameters for suggestions
- ajax: {
- actionName: 'getSearchResultList',
- className: '',
- data: {}
- },
-
- // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
- excludedSearchValues: [],
- // maximum number of items this list may contain, `-1` for infinite
- maxItems: -1,
- // maximum length of an item value, `-1` for infinite
- maxLength: -1,
- // disallow custom values, only values offered by the suggestion dropdown are accepted
- restricted: false,
-
- // initial value will be interpreted as comma separated value and submitted as such
- isCSV: false,
-
- // will be invoked whenever the items change, receives the element id first and list of values second
- callbackChange: null,
- // callback once the form is about to be submitted
- callbackSubmit: null,
- // value may contain the placeholder `{$objectId}`
- submitFieldName: ''
- }, options);
-
- var form = DOMTraverse.parentByTag(element, 'FORM');
- if (form !== null) {
- if (options.isCSV === false) {
- if (!options.submitFieldName.length && typeof options.callbackSubmit !== 'function') {
- throw new Error("Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.");
- }
-
- form.addEventListener('submit', (function() {
- var values = this.getValues(elementId);
- if (options.submitFieldName.length) {
- var input;
- for (var i = 0, length = values.length; i < length; i++) {
- input = document.createElement('input');
- input.type = 'hidden';
- input.name = options.submitFieldName.replace(/{$objectId}/, values[i].objectId);
- input.value = values[i].value;
-
- form.appendChild(input);
- }
- }
- else {
- options.callbackSubmit(form, values);
- }
- }).bind(this));
- }
- }
-
- this._setup();
-
- var data = this._createUI(element, options, values);
- var suggestion = new UISuggestion(elementId, {
- ajax: options.ajax,
- callbackSelect: this._addItem.bind(this),
- excludedSearchValues: options.excludedSearchValues
- });
-
- _data.set(elementId, {
- dropdownMenu: null,
- element: data.element,
- list: data.list,
- listItem: data.element.parentNode,
- options: options,
- shadow: data.shadow,
- suggestion: suggestion
- });
-
- values = (data.values.length) ? data.values : values;
- if (Array.isArray(values)) {
- var value;
- for (var i = 0, length = values.length; i < length; i++) {
- value = values[i];
- if (typeof value === 'string') {
- value = { objectId: 0, value: value };
- }
-
- this._addItem(elementId, value);
- }
- }
- },
-
- /**
- * Returns the list of current values.
- *
- * @param {string} element id input element id
- * @return {array<object>} list of objects containing object id and value
- */
- getValues: function(elementId) {
- if (!_data.has(elementId)) {
- throw new Error("Element id '" + elementId + "' is unknown.");
- }
-
- var data = _data.get(elementId);
- var items = DOMTraverse.childrenByClass(data.list, 'item');
- var values = [], value, item;
- for (var i = 0, length = items.length; i < length; i++) {
- item = items[i];
- value = {
- objectId: item.getAttribute('data-object-id'),
- value: DOMTraverse.childByTag(item, 'SPAN').textContent
- };
-
- values.push(value);
- }
-
- return values;
- },
-
- /**
- * Binds static event listeners.
- */
- _setup: function() {
- if (_didInit) {
- return;
- }
-
- _didInit = true;
-
- _callbackKeyDown = this._keyDown.bind(this);
- _callbackKeyPress = this._keyPress.bind(this);
- _callbackKeyUp = this._keyUp.bind(this);
- _callbackRemoveItem = this._removeItem.bind(this);
- },
-
- /**
- * Creates the DOM structure for target element. If `element` is a `<textarea>`
- * it will be automatically replaced with an `<input>` element.
- *
- * @param {Element} element input element
- * @param {object<string>} options option list
- */
- _createUI: function(element, options) {
- var list = document.createElement('ol');
- list.className = 'inputItemList';
- list.setAttribute('data-element-id', element.id);
- list.addEventListener('click', function(event) {
- if (event.target === list) element.focus();
- });
-
- var listItem = document.createElement('li');
- listItem.className = 'input';
- list.appendChild(listItem);
-
- element.addEventListener('keydown', _callbackKeyDown);
- element.addEventListener('keypress', _callbackKeyPress);
- element.addEventListener('keyup', _callbackKeyUp);
-
- element.parentNode.insertBefore(list, element);
- listItem.appendChild(element);
-
- if (options.maxLength !== -1) {
- element.setAttribute('maxLength', options.maxLength);
- }
-
- var shadow = null, values = [];
- if (options.isCSV) {
- shadow = document.createElement('input');
- shadow.className = 'itemListInputShadow';
- shadow.type = 'hidden';
- shadow.name = element.name;
- element.removeAttribute('name');
-
- list.parentNode.insertBefore(shadow, list);
-
- if (element.nodeName === 'TEXTAREA') {
- var value, tmp = element.value.split(',');
- for (var i = 0, length = tmp.length; i < length; i++) {
- value = tmp[i].trim();
- if (value.length) {
- values.push(value);
- }
- }
-
- var inputElement = document.createElement('input');
- element.parentNode.insertBefore(inputElement, element);
- inputElement.id = element.id;
-
- element.parentNode.removeChild(element);
- element = inputElement;
- }
- }
-
- return {
- element: element,
- list: list,
- shadow: shadow,
- values: values
- };
- },
-
- /**
- * Enforces the maximum number of items.
- *
- * @param {string} elementId input element id
- */
- _handleLimit: function(elementId) {
- var data = _data.get(elementId);
- if (data.options.maxItems === -1) {
- return;
- }
-
- if (data.list.childElementCount - 1 < data.options.maxItems) {
- if (data.element.disabled) {
- data.element.disabled = false;
- data.element.removeAttribute('placeholder');
- }
- }
- else if (!data.element.disabled) {
- data.element.disabled = true;
- data.element.setAttribute('placeholder', Language.get('wcf.global.form.input.maxItems'));
- }
- },
-
- /**
- * Sets the active item list id and handles keyboard access to remove an existing item.
- *
- * @param {object} event event object
- */
- _keyDown: function(event) {
- var input = event.currentTarget;
- var lastItem = input.parentNode.previousElementSibling;
-
- _activeId = input.id;
-
- if (event.keyCode === 8) {
- // 8 = [BACKSPACE]
- if (input.value.length === 0) {
- if (lastItem !== null) {
- if (lastItem.classList.contains('active')) {
- this._removeItem(null, lastItem);
- }
- else {
- lastItem.classList.add('active');
- }
- }
- }
- }
- else if (event.keyCode === 27) {
- // 27 = [ESC]
- if (lastItem !== null && lastItem.classList.contains('active')) {
- lastItem.classList.remove('active');
- }
- }
- },
-
- /**
- * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
- *
- * @param {object} event event object
- */
- _keyPress: function(event) {
- // 13 = [ENTER], 44 = [,]
- if (event.charCode === 13 || event.charCode === 44) {
- event.preventDefault();
-
- if (_data.get(event.currentTarget.id).options.restricted) {
- // restricted item lists only allow results from the dropdown to be picked
- return;
- }
-
- var value = event.currentTarget.value.trim();
- if (value.length) {
- this._addItem(event.currentTarget.id, { objectId: 0, value: value });
- }
- }
- },
-
- /**
- * Handles the keyup event to unmark an item for deletion.
- *
- * @param {object} event event object
- */
- _keyUp: function(event) {
- var input = event.currentTarget;
-
- if (input.value.length > 0) {
- var lastItem = input.parentNode.previousElementSibling;
- if (lastItem !== null) {
- lastItem.classList.remove('active');
- }
- }
- },
-
- /**
- * Adds an item to the list.
- *
- * @param {string} elementId input element id
- * @param {string} value item value
- */
- _addItem: function(elementId, value) {
- var data = _data.get(elementId);
-
- var listItem = document.createElement('li');
- listItem.className = 'item';
-
- var content = document.createElement('span');
- content.className = 'content';
- content.setAttribute('data-object-id', value.objectId);
- content.textContent = value.value;
-
- var button = document.createElement('a');
- button.className = 'icon icon16 fa-times';
- button.addEventListener('click', _callbackRemoveItem);
- listItem.appendChild(content);
- listItem.appendChild(button);
-
- data.list.insertBefore(listItem, data.listItem);
- data.suggestion.addExcludedValue(value.value);
- data.element.value = '';
-
- this._handleLimit(elementId);
- var values = this._syncShadow(data);
-
- if (typeof data.options.callbackChange === 'function') {
- if (values === null) values = this.getValues(elementId);
- data.options.callbackChange(elementId, values);
- }
- },
-
- /**
- * Removes an item from the list.
- *
- * @param {?object} event event object
- * @param {Element=} item list item
- */
- _removeItem: function(event, item) {
- item = (event === null) ? item : event.currentTarget.parentNode;
-
- var parent = item.parentNode;
- var elementId = parent.getAttribute('data-element-id');
- var data = _data.get(elementId);
-
- data.suggestion.removeExcludedValue(item.children[0].textContent);
- parent.removeChild(item);
- data.element.focus();
-
- this._handleLimit(elementId);
- var values = this._syncShadow(data);
-
- if (typeof data.options.callbackChange === 'function') {
- if (values === null) values = this.getValues(elementId);
- data.options.callbackChange(elementId, values);
- }
- },
-
- /**
- * Synchronizes the shadow input field with the current list item values.
- *
- * @param {object} data element data
- */
- _syncShadow: function(data) {
- if (!data.options.isCSV) return null;
-
- var value = '', values = this.getValues(data.element.id);
- for (var i = 0, length = values.length; i < length; i++) {
- value += (value.length ? ',' : '') + values[i].value;
- }
-
- data.shadow.value = value;
-
- return values;
- }
- };
-
- return UIItemList;
-});
+++ /dev/null
-/**
- * Provides an item list for users and groups.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/ItemList/User
- */
-define(['WoltLab/WCF/UI/ItemList'], function(UIItemList) {
- "use strict";
-
- /**
- * @exports WoltLab/WCF/UI/ItemList/User
- */
- var UIItemListUser = {
- /**
- * Initializes user suggestion support for an element.
- *
- * @param {string} elementId input element id
- * @param {object} options option list
- */
- init: function(elementId, options) {
- UIItemList.init(elementId, [], {
- ajax: {
- className: 'wcf\\data\\user\\UserAction',
- parameters: {
- data: {
- includeUserGroups: ~~options.includeUserGroups
- }
- }
- },
- callbackChange: (typeof options.callbackChange === 'function' ? options.callbackChange : null),
- excludedSearchValues: (Array.isArray(options.excludedSearchValues) ? options.excludedSearchValues : []),
- isCSV: true,
- maxItems: ~~options.maxItems || -1,
- restricted: true
- });
- },
-
- /**
- * @see WoltLab/WCF/UI/ItemList::getValues()
- */
- getValues: function(elementId) {
- return UIItemList.getValues(elementId);
- }
- };
-
- return UIItemListUser;
-});
+++ /dev/null
-/**
- * Modifies the interface to provide a better usability for mobile devices.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/Mobile
- */
-define(
- [ 'enquire', 'Environment', 'Language', 'DOM/ChangeListener', 'DOM/Traverse', 'UI/CloseOverlay'],
- function(enquire, Environment, Language, DOMChangeListener, DOMTraverse, UICloseOverlay)
-{
- "use strict";
-
- var _buttonGroupNavigations = null;
- var _enabled = false;
- var _main = null;
- var _sidebar = null;
-
- /**
- * @exports WoltLab/WCF/UI/Mobile
- */
- var UIMobile = {
- /**
- * Initializes the mobile UI using enquire.js.
- */
- setup: function() {
- _buttonGroupNavigations = document.getElementsByClassName('buttonGroupNavigation');
- _main = document.getElementById('main');
- _sidebar = _main.querySelector('#main > div > div > .sidebar');
-
- if (Environment.touch()) {
- document.documentElement.classList.add('touch');
- }
-
- if (Environment.platform() !== 'desktop') {
- document.documentElement.classList.add('mobile');
- }
-
- enquire.register('screen and (max-width: 800px)', {
- match: this.enable.bind(this),
- unmatch: this.disable.bind(this),
- setup: this._init.bind(this),
- deferSetup: true
- });
-
- if (Environment.browser() === 'microsoft' && _sidebar !== null && _sidebar.clientWidth > 305) {
- this._fixSidebarIE();
- }
- },
-
- /**
- * Enables the mobile UI.
- */
- enable: function() {
- _enabled = true;
-
- if (Environment.browser() === 'microsoft') this._fixSidebarIE();
- },
-
- /**
- * Disables the mobile UI.
- */
- disable: function() {
- _enabled = false;
-
- if (Environment.browser() === 'microsoft') this._fixSidebarIE();
- },
-
- _fixSidebarIE: function() {
- if (_sidebar === null) return;
-
- // sidebar is rarely broken on IE9/IE10
- _sidebar.style.setProperty('display', 'none');
- _sidebar.style.removeProperty('display');
- },
-
- _init: function() {
- this._initSidebarToggleButtons();
- this._initSearchBar();
- this._initButtonGroupNavigation();
-
- UICloseOverlay.add('WoltLab/WCF/UI/Mobile', this._closeAllMenus.bind(this));
- DOMChangeListener.add('WoltLab/WCF/UI/Mobile', this._initButtonGroupNavigation.bind(this));
- },
-
- _initSidebarToggleButtons: function() {
- if (_sidebar === null) return;
-
- var sidebarPosition = (_main.classList.contains('sidebarOrientationLeft')) ? 'Left' : '';
- sidebarPosition = (sidebarPosition) ? sidebarPosition : (_main.classList.contains('sidebarOrientationRight') ? 'Right' : '');
-
- if (!sidebarPosition) {
- return;
- }
-
- // use icons if language item is empty/non-existant
- var languageShowSidebar = 'wcf.global.sidebar.show' + sidebarPosition + 'Sidebar';
- if (languageShowSidebar === Language.get(languageShowSidebar) || Language.get(languageShowSidebar) === '') {
- languageShowSidebar = document.createElement('span');
- languageShowSidebar.className = 'icon icon16 fa-angle-double-' + sidebarPosition.toLowerCase();
- }
-
- var languageHideSidebar = 'wcf.global.sidebar.hide' + sidebarPosition + 'Sidebar';
- if (languageHideSidebar === Language.get(languageHideSidebar) || Language.get(languageHideSidebar) === '') {
- languageHideSidebar = document.createElement('span');
- languageHideSidebar.className = 'icon icon16 fa-angle-double-' + (sidebarPosition === 'Left' ? 'right' : 'left');
- }
-
- // add toggle buttons
- var showSidebar = document.createElement('span');
- showSidebar.className = 'button small mobileSidebarToggleButton';
- showSidebar.addEventListener('click', function() { _main.classList.add('mobileShowSidebar'); });
- if (languageShowSidebar instanceof Element) showSidebar.appendChild(languageShowSidebar);
- else showSidebar.textContent = languageShowSidebar;
-
- var hideSidebar = document.createElement('span');
- hideSidebar.className = 'button small mobileSidebarToggleButton';
- hideSidebar.addEventListener('click', function() { _main.classList.remove('mobileShowSidebar'); });
- if (languageHideSidebar instanceof Element) hideSidebar.appendChild(languageHideSidebar);
- else hideSidebar.textContent = languageHideSidebar;
-
- document.querySelector('.content').appendChild(showSidebar);
- _sidebar.appendChild(hideSidebar);
- },
-
- _initSearchBar: function() {
- var _searchBar = document.querySelector('.searchBar');
-
- _searchBar.addEventListener('click', function() {
- if (_enabled) {
- _searchBar.classList.add('searchBarOpen');
-
- return false;
- }
-
- return false;
- });
-
- _main.addEventListener('click', function() { _searchBar.classList.remove('searchBarOpen'); });
- },
-
- _initButtonGroupNavigation: function() {
- for (var i = 0, length = _buttonGroupNavigations.length; i < length; i++) {
- var navigation = _buttonGroupNavigations[i];
-
- if (navigation.classList.contains('jsMobileButtonGroupNavigation')) continue;
- else navigation.classList.add('jsMobileButtonGroupNavigation');
-
- var button = document.createElement('a');
- button.classList.add('dropdownLabel');
-
- var span = document.createElement('span');
- span.className = 'icon icon24 fa-list';
- button.appendChild(span);
-
- button.addEventListener('click', function(ev) {
- var next = DOMTraverse.next(button);
- if (next !== null) {
- next.classList.toggle('open');
-
- ev.stopPropagation();
- return false;
- }
-
- return true;
- });
-
- navigation.insertBefore(button, navigation.firstChild);
- }
- },
-
- _closeAllMenus: function() {
- var openMenus = document.querySelectorAll('.jsMobileButtonGroupNavigation > ul.open');
- for (var i = 0, length = openMenus.length; i < length; i++) {
- openMenus[i].classList.remove('open');
- }
- }
- };
-
- return UIMobile;
-});
+++ /dev/null
-/**
- * Flexible UI element featuring both a list of items and an input field with suggestion support.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/Suggestion
- */
-define(['Ajax', 'Core', 'UI/SimpleDropdown'], function(Ajax, Core, UISimpleDropdown) {
- "use strict";
-
- /**
- * @constructor
- * @param {string} elementId input element id
- * @param {object<mixed>} options option list
- */
- function UISuggestion(elementId, options) { this.init(elementId, options); };
- UISuggestion.prototype = {
- /**
- * Initializes a new suggestion input.
- *
- * @param {string} element id input element id
- * @param {object<mixed>} options option list
- */
- init: function(elementId, options) {
- this._dropdownMenu = null;
- this._value = '';
-
- this._element = document.getElementById(elementId);
- if (this._element === null) {
- throw new Error("Expected a valid element id.");
- }
-
- this._options = Core.extend({
- ajax: {
- actionName: 'getSearchResultList',
- className: '',
- interfaceName: 'wcf\\data\\ISearchAction',
- parameters: {
- data: {}
- }
- },
-
- // will be executed once a value from the dropdown has been selected
- callbackSelect: null,
- // list of excluded search values
- excludedSearchValues: [],
- // minimum number of characters required to trigger a search request
- treshold: 3
- }, options);
-
- if (typeof this._options.callbackSelect !== 'function') {
- throw new Error("Expected a valid callback for option 'callbackSelect'.");
- }
-
- this._element.addEventListener('click', function(event) { event.stopPropagation(); });
- this._element.addEventListener('keydown', this._keyDown.bind(this));
- this._element.addEventListener('keyup', this._keyUp.bind(this));
- },
-
- /**
- * Adds an excluded search value.
- *
- * @param {string} value excluded value
- */
- addExcludedValue: function(value) {
- if (this._options.excludedSearchValues.indexOf(value) === -1) {
- this._options.excludedSearchValues.push(value);
- }
- },
-
- /**
- * Removes an excluded search value.
- *
- * @param {string} value excluded value
- */
- removeExcludedValue: function(value) {
- var index = this._options.excludedSearchValues.indexOf(value);
- if (index !== -1) {
- this._options.excludedSearchValues.splice(index, 1);
- }
- },
-
- /**
- * Handles the keyboard navigation for interaction with the suggestion list.
- *
- * @param {object} event event object
- */
- _keyDown: function(event) {
- if (this._dropdownMenu === null || !UISimpleDropdown.isOpen(this._element.id)) {
- return true;
- }
-
- if (event.keyCode !== 13 && event.keyCode !== 27 && event.keyCode !== 38 && event.keyCode !== 40) {
- return true;
- }
-
- var active, i = 0, length = this._dropdownMenu.childElementCount;
- while (i < length) {
- active = this._dropdownMenu.children[i];
- if (active.classList.contains('active')) {
- break;
- }
-
- i++;
- }
-
- if (event.keyCode === 13) {
- // Enter
- UISimpleDropdown.close(this._element.id);
-
- this._select(active);
- }
- else if (event.keyCode === 27) {
- if (UISimpleDropdown.isOpen(this._element.id)) {
- UISimpleDropdown.close(this._element.id);
- }
- else {
- // let the event pass through
- return true;
- }
- }
- else {
- var index = 0;
-
- if (event.keyCode === 38) {
- // ArrowUp
- index = ((i === 0) ? length : i) - 1;
- }
- else if (event.keyCode === 40) {
- // ArrowDown
- index = i + 1;
- if (index === length) index = 0;
- }
-
- if (index !== i) {
- active.classList.remove('active');
- this._dropdownMenu.children[index].classList.add('active');
- }
- }
-
- event.preventDefault();
- return false;
- },
-
- /**
- * Selects an item from the list.
- *
- * @param {(Element|Event)} item list item or event object
- */
- _select: function(item) {
- var isEvent = (item instanceof Event);
- if (isEvent) {
- item = item.currentTarget.parentNode;
- }
-
- this._options.callbackSelect(this._element.id, { objectId: item.children[0].getAttribute('data-object-id'), value: item.textContent });
-
- if (isEvent) {
- this._element.focus();
- }
- },
-
- /**
- * Performs a search for the input value unless it is below the treshold.
- *
- * @param {object} event event object
- */
- _keyUp: function(event) {
- var value = event.currentTarget.value.trim();
-
- if (this._value === value) {
- return;
- }
- else if (value.length < this._options.treshold) {
- if (this._dropdownMenu !== null) {
- UISimpleDropdown.close(this._element.id);
- }
-
- this._value = value;
-
- return;
- }
-
- this._value = value;
-
- Ajax.api(this, {
- parameters: {
- data: {
- excludedSearchValues: this._options.excludedSearchValues,
- searchString: value
- }
- }
- });
- },
-
- _ajaxSetup: function() {
- return {
- data: this._options.ajax
- };
- },
-
- /**
- * Handles successful Ajax requests.
- *
- * @param {object} data response values
- */
- _ajaxSuccess: function(data) {
- if (this._dropdownMenu === null) {
- this._dropdownMenu = document.createElement('div');
- this._dropdownMenu.className = 'dropdownMenu';
-
- UISimpleDropdown.initFragment(this._element, this._dropdownMenu);
- }
- else {
- this._dropdownMenu.innerHTML = '';
- }
-
- if (data.returnValues.length) {
- var anchor, item, listItem;
- for (var i = 0, length = data.returnValues.length; i < length; i++) {
- item = data.returnValues[i];
-
- anchor = document.createElement('a');
- anchor.textContent = item.label;
- anchor.setAttribute('data-object-id', item.objectID);
- anchor.addEventListener('click', this._select.bind(this));
-
- listItem = document.createElement('li');
- if (i === 0) listItem.className = 'active';
- listItem.appendChild(anchor);
-
- this._dropdownMenu.appendChild(listItem);
- }
-
- UISimpleDropdown.open(this._element.id);
- }
- else {
- UISimpleDropdown.close(this._element.id);
- }
- }
- };
-
- return UISuggestion;
-});
+++ /dev/null
-/**
- * Common interface for tab menu access.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/TabMenu
- */
-define(['Dictionary', 'DOM/ChangeListener', 'DOM/Util', './TabMenu/Simple'], function(Dictionary, DOMChangeListener, DOMUtil, SimpleTabMenu) {
- "use strict";
-
- var _tabMenus = new Dictionary();
-
- /**
- * @exports WoltLab/WCF/UI/TabMenu
- */
- var UITabMenu = {
- /**
- * Sets up tab menus and binds listeners.
- */
- setup: function() {
- this._init();
- this._selectErroneousTabs();
-
- DOMChangeListener.add('WoltLab/WCF/UI/TabMenu', this._init.bind(this));
- },
-
- /**
- * Initializes available tab menus.
- */
- _init: function() {
- var tabMenus = document.querySelectorAll('.tabMenuContainer:not(.staticTabMenuContainer)');
- for (var i = 0, length = tabMenus.length; i < length; i++) {
- var container = tabMenus[i];
- var containerId = DOMUtil.identify(container);
-
- if (_tabMenus.has(containerId)) {
- continue;
- }
-
- var tabMenu = new SimpleTabMenu(containerId, container);
- if (tabMenu.validate()) {
- tabMenu.init();
-
- _tabMenus.set(containerId, tabMenu);
- }
- }
- },
-
- /**
- * Selects the first tab containing an element with class `formError`.
- */
- _selectErroneousTabs: function() {
- _tabMenus.forEach(function(tabMenu) {
- var foundError = false;
- tabMenu.getContainers().forEach(function(container) {
- if (!foundError && container.getElementsByClassName('formError').length) {
- foundError = true;
-
- tabMenu.select(container.id);
- }
- });
- });
- },
-
- /**
- * Returns a SimpleTabMenu instance for given container id.
- *
- * @param {string} containerId tab menu container id
- * @return {(SimpleTabMenu|undefined)} tab menu object
- */
- getTabMenu: function(containerId) {
- return _tabMenus.get(containerId);
- }
- };
-
- return UITabMenu;
-});
+++ /dev/null
-/**
- * Simple tab menu implementation with a straight-forward logic.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/TabMenu/Simple
- */
-define(['Dictionary', 'DOM/Traverse', 'DOM/Util', 'EventHandler'], function(Dictionary, DOMTraverse, DOMUtil, EventHandler) {
- "use strict";
-
- /**
- * @param {string} containerId container id
- * @param {Element} container container element
- * @constructor
- */
- function TabMenuSimple(containerId, container) {
- this._container = container;
- this._containers = new Dictionary();
- this._containerId = containerId;
- this._isLegacy = null;
- this._isParent = false;
- this._parent = null;
- this._tabs = new Dictionary();
- };
-
- TabMenuSimple.prototype = {
- /**
- * Validates the properties and DOM structure of this container.
- *
- * Expected DOM:
- * <div class="tabMenuContainer">
- * <nav>
- * <ul>
- * <li data-name="foo"><a>bar</a></li>
- * </ul>
- * </nav>
- *
- * <div id="foo">baz</div>
- * </div>
- *
- * @return {boolean} false if any properties are invalid or the DOM does not match the expectations
- */
- validate: function() {
- if (!this._container.classList.contains('tabMenuContainer')) {
- return false;
- }
-
- var nav = DOMTraverse.childByTag(this._container, 'NAV');
- if (nav === null) {
- return false;
- }
-
- // get children
- var tabs = nav.getElementsByTagName('li');
- if (tabs.length === null) {
- return false;
- }
-
- var containers = DOMTraverse.childrenByTag(this._container, 'DIV');
- for (var i = 0, length = containers.length; i < length; i++) {
- var container = containers[i];
- var name = container.getAttribute('data-name');
-
- if (!name) {
- name = DOMUtil.identify(container);
- }
-
- container.setAttribute('data-name', name);
- this._containers.set(name, container);
- }
-
- for (var i = 0, length = tabs.length; i < length; i++) {
- var tab = tabs[i];
- var name = this._getTabName(tab);
-
- if (!name) {
- continue;
- }
-
- if (this._tabs.has(name)) {
- throw new Error("Tab names must be unique, li[data-name='" + name + "'] (tab menu id: '" + this._containerId + "') exists more than once.");
- }
-
- var container = this._containers.get(name);
- if (container === undefined) {
- throw new Error("Expected content element for li[data-name='" + name + "'] (tab menu id: '" + this._containerId + "').");
- }
- else if (container.parentNode !== this._container) {
- throw new Error("Expected content element '" + name + "' (tab menu id: '" + this._containerId + "') to be a direct children.");
- }
-
- // check if tab holds exactly one children which is an anchor element
- if (tab.childElementCount !== 1 || tab.children[0].nodeName !== 'A') {
- throw new Error("Expected exactly one <a> as children for li[data-name='" + name + "'] (tab menu id: '" + this._containerId + "').");
- }
-
- this._tabs.set(name, tab);
- }
-
- if (!this._tabs.size) {
- throw new Error("Expected at least one tab (tab menu id: '" + this._containerId + "').");
- }
-
- if (this._isLegacy) {
- this._container.setAttribute('data-is-legacy', true);
-
- this._tabs.forEach(function(tab, name) {
- tab.setAttribute('aria-controls', name);
- });
- }
-
- return true;
- },
-
- /**
- * Initializes this tab menu.
- *
- * @param {Dictionary=} oldTabs previous list of tabs
- */
- init: function(oldTabs) {
- oldTabs = oldTabs || null;
-
- // bind listeners
- this._tabs.forEach((function(tab) {
- if (oldTabs === null || oldTabs.get(tab.getAttribute('data-name')) !== tab) {
- tab.children[0].addEventListener('click', this._onClick.bind(this));
- }
- }).bind(this));
-
- if (oldTabs === null) {
- var preselect = this._container.getAttribute('data-preselect');
- if (preselect === "true" || preselect === null || preselect === "") preselect = true;
- if (preselect === "false") preselect = false;
-
- this._containers.forEach(function(container) {
- container.classList.add('hidden');
- });
-
- if (preselect !== false) {
- if (preselect !== true) {
- var tab = this._tabs.get(preselect);
- if (tab !== undefined) {
- this.select(null, tab, true);
- }
- }
- else {
- var selectTab = null;
- this._tabs.forEach(function(tab) {
- if (selectTab === null && tab.previousElementSibling === null) {
- selectTab = tab;
- }
- });
-
- if (selectTab !== null) {
- this.select(null, selectTab, true);
- }
- }
- }
- }
- },
-
- /**
- * Selects a tab.
- *
- * @param {?(string|integer)} name tab name or sequence no
- * @param {Element=} tab tab element
- * @param {boolean=} disableEvent suppress event handling
- */
- select: function(name, tab, disableEvent) {
- tab = tab || this._tabs.get(name) || null;
-
- if (tab === null) {
- // check if name is an integer
- if (~~name == name) {
- name = ~~name;
-
- var i = 0;
- this._tabs.forEach(function(item) {
- if (i === name) {
- tab = item;
- }
-
- i++;
- });
- }
-
- if (tab === null) {
- throw new Error("Expected a valid tab name, '" + name + "' given (tab menu id: '" + this._containerId + "').");
- }
- }
-
- if (!name) name = tab.getAttribute('data-name');
-
- // unmark active tab
- var oldTab = document.querySelector('#' + this._containerId + ' > nav > ul > li.active');
- var oldContent = null;
- if (oldTab !== null) {
- oldTab.classList.remove('active');
- oldContent = this._containers.get(oldTab.getAttribute('data-name'));
- oldContent.classList.remove('active');
- oldContent.classList.add('hidden');
-
- if (this._isLegacy) {
- oldTab.classList.remove('ui-state-active');
- oldContent.classList.remove('ui-state-active');
- }
- }
-
- tab.classList.add('active');
- var newContent = this._containers.get(name);
- newContent.classList.add('active');
-
- if (this._isLegacy) {
- tab.classList.add('ui-state-active');
- newContent.classList.add('ui-state-active');
- newContent.classList.remove('hidden');
- }
-
- if (disableEvent !== true) {
- EventHandler.fire('com.woltlab.wcf.simpleTabMenu_' + this._containerId, 'select', {
- active: tab,
- activeName: name,
- previous: oldTab,
- previousName: oldTab.getAttribute('data-name')
- });
-
- if (this._isLegacy && typeof window.jQuery === 'function') {
- // simulate jQuery UI Tabs event
- window.jQuery(this._container).trigger('wcftabsbeforeactivate', {
- newTab: window.jQuery(tab),
- oldTab: window.jQuery(oldTab),
- newPanel: window.jQuery(newContent),
- oldPanel: window.jQuery(oldContent)
- });
- }
- }
- },
-
- /**
- * Rebuilds all tabs, must be invoked after adding or removing of tabs.
- *
- * Warning: Do not remove tabs if you plan to add these later again or at least clone the nodes
- * to prevent issues with already bound event listeners. Consider hiding them via CSS.
- */
- rebuild: function() {
- var oldTabs = this._tabs;
-
- this.validate();
- this.init(oldTabs);
- },
-
- /**
- * Handles clicks on a tab.
- *
- * @param {object} event event object
- */
- _onClick: function(event) {
- event.preventDefault();
-
- var tab = event.currentTarget.parentNode;
-
- this.select(null, tab);
- },
-
- /**
- * Returns the tab name.
- *
- * @param {Element} tab tab element
- * @return {string} tab name
- */
- _getTabName: function(tab) {
- var name = tab.getAttribute('data-name');
-
- // handle legacy tab menus
- if (!name) {
- if (tab.childElementCount === 1 && tab.children[0].nodeName === 'A') {
- var href = tab.children[0].getAttribute('href');
- if (href.match(/#([^#]+)$/)) {
- name = RegExp.$1;
-
- if (document.getElementById(name) === null) {
- name = null;
- }
- else {
- this._isLegacy = true;
- tab.setAttribute('data-name', name);
- }
- }
- }
- }
-
- return name;
- },
-
- /**
- * Returns the list of registered content containers.
- *
- * @returns {Dictionary} content containers
- */
- getContainers: function() {
- return this._containers;
- },
-
- /**
- * Returns the list of registered tabs.
- *
- * @returns {Dictionary} tab items
- */
- getTabs: function() {
- return this._tabs;
- }
- };
-
- return TabMenuSimple;
-});
+++ /dev/null
-/**
- * Provides enhanced tooltips.
- *
- * @author Alexander Ebert
- * @copyright 2001-2015 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLab/WCF/UI/Tooltip
- */
-define(['Environment', 'DOM/ChangeListener', 'UI/Alignment'], function(Environment, DOMChangeListener, UIAlignment) {
- "use strict";
-
- var _elements = null;
- var _pointer = null;
- var _text = null;
- var _tooltip = null;
-
- /**
- * @exports WoltLab/WCF/UI/Tooltip
- */
- var UITooltip = {
- /**
- * Initializes the tooltip element and binds event listener.
- */
- setup: function() {
- if (Environment.platform() !== 'desktop') return;
-
- _tooltip = document.createElement('div');
- _tooltip.setAttribute('id', 'balloonTooltip');
- _tooltip.classList.add('balloonTooltip');
-
- _text = document.createElement('span');
- _text.setAttribute('id', 'balloonTooltipText');
- _tooltip.appendChild(_text);
-
- _pointer = document.createElement('span');
- _pointer.classList.add('elementPointer');
- _pointer.appendChild(document.createElement('span'));
- _tooltip.appendChild(_pointer);
-
- document.body.appendChild(_tooltip);
-
- _elements = document.getElementsByClassName('jsTooltip');
-
- this.init();
-
- DOMChangeListener.add('WoltLab/WCF/UI/Tooltip', this.init.bind(this));
- },
-
- /**
- * Initializes tooltip elements.
- */
- init: function() {
- while (_elements.length) {
- var element = _elements[0];
- element.classList.remove('jsTooltip');
-
- var title = element.getAttribute('title');
- title = (typeof title === 'string') ? title.trim() : '';
-
- if (title.length) {
- element.setAttribute('data-tooltip', title);
- element.removeAttribute('title');
-
- element.addEventListener('mouseenter', this._mouseEnter.bind(this));
- element.addEventListener('mouseleave', this._mouseLeave.bind(this));
- element.addEventListener('click', this._mouseLeave.bind(this));
- }
- }
- },
-
- /**
- * Displays the tooltip on mouse enter.
- *
- * @param {object} event event object
- */
- _mouseEnter: function(event) {
- var element = event.currentTarget;
- var title = element.getAttribute('title');
- title = (typeof title === 'string') ? title.trim() : '';
-
- if (title !== '') {
- element.setAttribute('data-tooltip', title);
- element.removeAttribute('title');
- }
-
- title = element.getAttribute('data-tooltip');
-
- // reset tooltip position
- _tooltip.style.removeProperty('top');
- _tooltip.style.removeProperty('left');
-
- // ignore empty tooltip
- if (!title.length) {
- _tooltip.classList.remove('active');
- return;
- }
- else {
- _tooltip.classList.add('active');
- }
-
- _text.textContent = title;
-
- UIAlignment.set(_tooltip, element, {
- horizontal: 'center',
- pointer: true,
- pointerClassNames: ['inverse'],
- vertical: 'top'
- });
- },
-
- /**
- * Hides the tooltip once the mouse leaves the element.
- *
- * @param {object} event event object
- */
- _mouseLeave: function(event) {
- _tooltip.classList.remove('active');
- }
- };
-
- return UITooltip;
-});
--- /dev/null
+/**
+ * Utility class to align elements relatively to another.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/Alignment
+ */
+define(['Core', 'Language', 'DOM/Traverse', 'DOM/Util'], function(Core, Language, DOMTraverse, DOMUtil) {
+ "use strict";
+
+ /**
+ * @exports WoltLab/WCF/UI/Alignment
+ */
+ var UIAlignment = {
+ /**
+ * Sets the alignment for target element relatively to the reference element.
+ *
+ * @param {Element} el target element
+ * @param {Element} ref reference element
+ * @param {object<string, *>} options list of options to alter the behavior
+ */
+ set: function(el, ref, options) {
+ options = Core.extend({
+ // offset to reference element
+ verticalOffset: 7,
+
+ // align the pointer element, expects .elementPointer as a direct child of given element
+ pointer: false,
+
+ // offset from/left side, ignored for center alignment
+ pointerOffset: 4,
+
+ // use static pointer positions, expects two items: class to move it to the bottom and the second to move it to the right
+ pointerClassNames: [],
+
+ // alternate element used to calculate dimensions
+ refDimensionsElement: null,
+
+ // preferred alignment, possible values: left/right/center and top/bottom
+ horizontal: 'left',
+ vertical: 'bottom',
+
+ // allow flipping over axis, possible values: both, horizontal, vertical and none
+ allowFlip: 'both'
+ }, options);
+
+ if (!Array.isArray(options.pointerClassNames) || options.pointerClassNames.length !== (options.pointer ? 1 : 2)) options.pointerClassNames = [];
+ if (['left', 'right', 'center'].indexOf(options.horizontal) === -1) options.horizontal = 'left';
+ if (options.vertical !== 'bottom') options.vertical = 'top';
+ if (['both', 'horizontal', 'vertical', 'none'].indexOf(options.allowFlip) === -1) options.allowFlip = 'both';
+
+ // place element in the upper left corner to prevent calculation issues due to possible scrollbars
+ DOMUtil.setStyles(el, {
+ bottom: 'auto !important',
+ left: '0 !important',
+ right: 'auto !important',
+ top: '0 !important'
+ });
+
+ var elDimensions = DOMUtil.outerDimensions(el);
+ var refDimensions = DOMUtil.outerDimensions((options.refDimensionsElement instanceof Element ? options.refDimensionsElement : ref));
+ var refOffsets = DOMUtil.offset(ref);
+ var windowHeight = window.innerHeight;
+ var windowWidth = document.body.clientWidth;
+
+ var horizontal = { result: null };
+ var alignCenter = false;
+ if (options.horizontal === 'center') {
+ alignCenter = true;
+ horizontal = this._tryAlignmentHorizontal(options.horizontal, elDimensions, refDimensions, refOffsets, windowWidth);
+
+ if (!horizontal.result) {
+ if (options.allowFlip === 'both' || options.allowFlip === 'horizontal') {
+ options.horizontal = 'left';
+ }
+ else {
+ horizontal.result = true;
+ }
+ }
+ }
+
+ // in rtl languages we simply swap the value for 'horizontal'
+ if (Language.get('wcf.global.pageDirection') === 'rtl') {
+ options.horizontal = (options.horizontal === 'left') ? 'right' : 'left';
+ }
+
+ if (!horizontal.result) {
+ var horizontalCenter = horizontal;
+ horizontal = this._tryAlignmentHorizontal(options.horizontal, elDimensions, refDimensions, refOffsets, windowWidth);
+ if (!horizontal.result && (options.allowFlip === 'both' || options.allowFlip === 'horizontal')) {
+ var horizontalFlipped = this._tryAlignmentHorizontal((options.horizontal === 'left' ? 'right' : 'left'), elDimensions, refDimensions, refOffsets, windowWidth);
+ // only use these results if it fits into the boundaries, otherwise both directions exceed and we honor the demanded direction
+ if (horizontalFlipped.result) {
+ horizontal = horizontalFlipped;
+ }
+ else if (alignCenter) {
+ horizontal = horizontalCenter;
+ }
+ }
+ }
+
+ var left = horizontal.left;
+ var right = horizontal.right;
+
+ var vertical = this._tryAlignmentVertical(options.vertical, elDimensions, refDimensions, refOffsets, windowHeight, options.verticalOffset);
+ if (!vertical.result && (options.allowFlip === 'both' || options.allowFlip === 'vertical')) {
+ var verticalFlipped = this._tryAlignmentVertical((options.vertical === 'top' ? 'bottom' : 'top'), elDimensions, refDimensions, refOffsets, windowHeight, options.verticalOffset);
+ // only use these results if it fits into the boundaries, otherwise both directions exceed and we honor the demanded direction
+ if (verticalFlipped.result) {
+ vertical = verticalFlipped;
+ }
+ }
+
+ var bottom = vertical.bottom;
+ var top = vertical.top;
+
+ // set pointer position
+ if (options.pointer) {
+ var pointer = DOMTraverse.childrenByClass(el, 'elementPointer');
+ pointer = pointer[0] || null;
+ if (pointer === null) {
+ throw new Error("Expected the .elementPointer element to be a direct children.");
+ }
+
+ if (horizontal.align === 'center') {
+ pointer.classList.add('center');
+
+ pointer.classList.remove('left');
+ pointer.classList.remove('right');
+ }
+ else {
+ pointer.classList.add(horizontal.align);
+
+ pointer.classList.remove('center');
+ pointer.classList.remove(horizontal.align === 'left' ? 'right' : 'left');
+ }
+
+ if (vertical.align === 'top') {
+ pointer.classList.add('flipVertical');
+ }
+ else {
+ pointer.classList.remove('flipVertical');
+ }
+ }
+ else if (options.pointerClassNames.length === 2) {
+ var pointerRight = 0;
+ var pointerBottom = 1;
+
+ el.classList[(top === 'auto' ? 'add' : 'remove')](options.pointerClassNames[pointerBottom]);
+ el.classList[(left === 'auto' ? 'add' : 'remove')](options.pointerClassNames[pointerRight]);
+ }
+
+ DOMUtil.setStyles(el, {
+ bottom: bottom + (bottom !== 'auto' ? 'px' : ''),
+ left: left + (left !== 'auto' ? 'px' : ''),
+ right: right + (right !== 'auto' ? 'px' : ''),
+ top: top + (top !== 'auto' ? 'px' : '')
+ });
+ },
+
+ /**
+ * Calculates left/right position and verifys if the element would be still within the page's boundaries.
+ *
+ * @param {string} align align to this side of the reference element
+ * @param {object<string, integer>} elDimensions element dimensions
+ * @param {object<string, integer>} refDimensions reference element dimensions
+ * @param {object<string, integer>} refOffsets position of reference element relative to the document
+ * @param {integer} windowWidth window width
+ * @returns {object<string, *>} calculation results
+ */
+ _tryAlignmentHorizontal: function(align, elDimensions, refDimensions, refOffsets, windowWidth) {
+ var left = 'auto';
+ var right = 'auto';
+ var result = true;
+
+ if (align === 'left') {
+ left = refOffsets.left;
+ if (left + elDimensions.width > windowWidth) {
+ result = false;
+ }
+ }
+ else if (align === 'right') {
+ right = windowWidth - (refOffsets.left + refDimensions.width);
+ if (right < 0) {
+ result = false;
+ }
+ }
+ else {
+ left = refOffsets.left + (refDimensions.width / 2) - (elDimensions.width / 2);
+ left = ~~left;
+
+ if (left < 0 || left + elDimensions.width > windowWidth) {
+ result = false;
+ }
+ }
+
+ return {
+ align: align,
+ left: left,
+ right: right,
+ result: result
+ };
+ },
+
+ /**
+ * Calculates top/bottom position and verifys if the element would be still within the page's boundaries.
+ *
+ * @param {string} align align to this side of the reference element
+ * @param {object<string, integer>} elDimensions element dimensions
+ * @param {object<string, integer>} refDimensions reference element dimensions
+ * @param {object<string, integer>} refOffsets position of reference element relative to the document
+ * @param {integer} windowHeight window height
+ * @param {integer} verticalOffset desired gap between element and reference element
+ * @returns {object<string, *>} calculation results
+ */
+ _tryAlignmentVertical: function(align, elDimensions, refDimensions, refOffsets, windowHeight, verticalOffset) {
+ var bottom = 'auto';
+ var top = 'auto';
+ var result = true;
+
+ if (align === 'top') {
+ var bodyHeight = document.body.clientHeight;
+ bottom = (bodyHeight - refOffsets.top) + verticalOffset;
+ if (bodyHeight - (bottom + elDimensions.height) < document.body.scrollTop) {
+ result = false;
+ }
+ }
+ else {
+ top = refOffsets.top + refDimensions.height + verticalOffset;
+ if (top + elDimensions.height > windowHeight) {
+ result = false;
+ }
+ }
+
+ return {
+ align: align,
+ bottom: bottom,
+ top: top,
+ result: result
+ };
+ }
+ };
+
+ return UIAlignment;
+});
--- /dev/null
+/**
+ * Allows to be informed when a click event bubbled up to the document's body.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/CloseOlveray
+ */
+define(['CallbackList'], function(CallbackList) {
+ "use strict";
+
+ var _callbackList = new CallbackList();
+
+ /**
+ * @exports WoltLab/WCF/UI/CloseOverlay
+ */
+ var UICloseOverlay = {
+ /**
+ * Sets up global event listener for bubbled clicks events.
+ */
+ setup: function() {
+ document.body.addEventListener('click', this.execute.bind(this));
+ },
+
+ /**
+ * @see WoltLab/WCF/CallbackList#add
+ */
+ add: _callbackList.add.bind(_callbackList),
+
+ /**
+ * @see WoltLab/WCF/CallbackList#remove
+ */
+ remove: _callbackList.remove.bind(_callbackList),
+
+ /**
+ * Invokes all registered callbacks.
+ */
+ execute: function() {
+ _callbackList.forEach(null, function(callback) {
+ callback();
+ });
+ }
+ };
+
+ UICloseOverlay.setup();
+
+ return UICloseOverlay;
+});
--- /dev/null
+/**
+ * Provides the sidebar toggle button.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/Collapsible/Sidebar
+ */
+define(['Ajax', 'Language', 'DOM/Util'], function(Ajax, Language, DOMUtil) {
+ "use strict";
+
+ var _isOpen = false;
+ var _main = null;
+ var _name = '';
+
+ /**
+ * @module WoltLab/WCF/UI/Collapsible/Sidebar
+ */
+ var UICollapsibleSidebar = {
+ /**
+ * Sets up the toggle button.
+ */
+ setup: function() {
+ var sidebar = document.querySelector('.sidebar');
+ if (sidebar === null) {
+ return;
+ }
+
+ _isOpen = (sidebar.getAttribute('data-is-open') === 'true');
+ _main = document.getElementById('main');
+ _name = sidebar.getAttribute('data-sidebar-name');
+
+ this._createUI(sidebar);
+
+ _main.classList[(_isOpen ? 'remove' : 'add')]('sidebarCollapsed');
+ },
+
+ /**
+ * Creates the toggle button.
+ *
+ * @param {Element} sidebar sidebar element
+ */
+ _createUI: function(sidebar) {
+ var button = document.createElement('a');
+ button.href = '#';
+ button.className = 'collapsibleButton jsTooltip';
+ button.setAttribute('title', Language.get('wcf.global.button.collapsible'));
+
+ var span = document.createElement('span');
+ span.appendChild(button);
+ DOMUtil.prepend(span, sidebar);
+
+ button.addEventListener('click', this._click.bind(this));
+ },
+
+ /**
+ * Toggles the sidebar on click.
+ *
+ * @param {object} event event object
+ */
+ _click: function(event) {
+ event.preventDefault();
+
+ _isOpen = (_isOpen === false);
+
+ Ajax.api(this, {
+ isOpen: ~~_isOpen
+ });
+ },
+
+ _ajaxSetup: function() {
+ return {
+ data: {
+ actionName: 'toggle',
+ className: 'wcf\\system\\user\\collapsible\\content\\UserCollapsibleSidebarHandler',
+ sidebarName: _name
+ },
+ url: 'index.php/AJAXInvoke/?t=' + SECURITY_TOKEN + SID_ARG_2ND
+ };
+ },
+
+ _ajaxSuccess: function(data) {
+ _main.classList[(_isOpen ? 'remove' : 'add')]('sidebarCollapsed');
+ }
+ };
+
+ return UICollapsibleSidebar;
+});
--- /dev/null
+/**
+ * Provides the confirmation dialog overlay.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/Confirmation
+ */
+define(['Core', 'Language', 'UI/Dialog'], function(Core, Language, UIDialog) {
+ "use strict";
+
+ var _active = false;
+ var _confirmButton = null;
+ var _content = null;
+ var _options = {};
+ var _text = null;
+
+ /**
+ * Confirmation dialog overlay.
+ *
+ * @exports WoltLab/WCF/UI/Confirmation
+ */
+ var UIConfirmation = {
+ /**
+ * Shows the confirmation dialog.
+ *
+ * Possible options:
+ * - cancel: callback if user cancels the dialog
+ * - confirm: callback if user confirm the dialog
+ * - legacyCallback: WCF 2.0/2.1 compatible callback with string parameter
+ * - message: displayed confirmation message
+ * - parameters: list of parameters passed to the callback on confirm
+ * - template: optional HTML string to be inserted below the `message`
+ *
+ * @param {object<string, *>} options confirmation options
+ */
+ show: function(options) {
+ if (UIDialog === undefined) UIDialog = require('UI/Dialog');
+
+ if (_active) {
+ return;
+ }
+
+ _options = Core.extend({
+ cancel: null,
+ confirm: null,
+ legacyCallback: null,
+ message: '',
+ parameters: {},
+ template: ''
+ }, options);
+
+ _options.message = (typeof _options.message === 'string') ? _options.message.trim() : '';
+ if (!_options.message.length) {
+ throw new Error("Expected a non-empty string for option 'message'.");
+ }
+
+ if (typeof _options.confirm !== 'function' && typeof _options.legacyCallback !== 'function') {
+ throw new TypeError("Expected a valid callback for option 'confirm'.");
+ }
+
+ if (_content === null) {
+ this._createDialog();
+ }
+
+ _content.innerHTML = (typeof options.template === 'string') ? options.template.trim() : '';
+ _text.textContent = _options.message;
+
+ _active = true;
+
+ UIDialog.open(this);
+ },
+
+ _dialogSetup: function() {
+ return {
+ id: 'wcfSystemConfirmation',
+ options: {
+ onClose: this._onClose.bind(this),
+ onShow: this._onShow.bind(this),
+ title: Language.get('wcf.global.confirmation.title')
+ }
+ };
+ },
+
+ /**
+ * Returns content container element.
+ *
+ * @return {Element} content container element
+ */
+ getContentElement: function() {
+ return _content;
+ },
+
+ /**
+ * Creates the dialog DOM elements.
+ */
+ _createDialog: function() {
+ var dialog = document.createElement('div');
+ dialog.setAttribute('id', 'wcfSystemConfirmation');
+ dialog.classList.add('systemConfirmation');
+
+ _text = document.createElement('p');
+ dialog.appendChild(_text);
+
+ _content = document.createElement('div');
+ _content.setAttribute('id', 'wcfSystemConfirmationContent');
+ dialog.appendChild(_content);
+
+ var formSubmit = document.createElement('div');
+ formSubmit.classList.add('formSubmit');
+ dialog.appendChild(formSubmit);
+
+ _confirmButton = document.createElement('button');
+ _confirmButton.classList.add('buttonPrimary');
+ _confirmButton.textContent = Language.get('wcf.global.confirmation.confirm');
+ _confirmButton.addEventListener('click', this._confirm.bind(this));
+ formSubmit.appendChild(_confirmButton);
+
+ var cancelButton = document.createElement('button');
+ cancelButton.textContent = Language.get('wcf.global.confirmation.cancel');
+ cancelButton.addEventListener('click', function() { UIDialog.close('wcfSystemConfirmation'); });
+ formSubmit.appendChild(cancelButton);
+
+ document.body.appendChild(dialog);
+ },
+
+ /**
+ * Invoked if the user confirms the dialog.
+ */
+ _confirm: function() {
+ if (typeof _options.legacyCallback === 'function') {
+ _options.legacyCallback('confirm', _options.parameters);
+ }
+ else {
+ _options.confirm(_options.parameters);
+ }
+
+ _active = false;
+ UIDialog.close('wcfSystemConfirmation');
+ },
+
+ /**
+ * Invoked on dialog close or if user cancels the dialog.
+ */
+ _onClose: function() {
+ if (_active) {
+ _confirmButton.blur();
+ _active = false;
+
+ if (typeof _options.legacyCallback === 'function') {
+ _options.legacyCallback('cancel', _options.parameters);
+ }
+ else if (typeof _options.cancel === 'function') {
+ _options.cancel(_options.parameters);
+ }
+ }
+ },
+
+ /**
+ * Sets the focus on the confirm button on dialog open for proper keyboard support.
+ */
+ _onShow: function() {
+ _confirmButton.blur();
+ _confirmButton.focus();
+ }
+ };
+
+ return UIConfirmation;
+});
--- /dev/null
+/**
+ * Modal dialog handler.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/Dialog
+ */
+define(
+ [
+ 'enquire', 'Ajax', 'Core', 'Dictionary',
+ 'Environment', 'Language', 'ObjectMap', 'DOM/ChangeListener',
+ 'DOM/Util', 'UI/Confirmation'
+ ],
+ function(
+ enquire, Ajax, Core, Dictionary,
+ Environment, Language, ObjectMap, DOMChangeListener,
+ DOMUtil, UIConfirmation
+ )
+{
+ "use strict";
+
+ var _activeDialog = null;
+ var _container = null;
+ var _dialogs = new Dictionary();
+ var _dialogObjects = new ObjectMap();
+ var _dialogFullHeight = false;
+ var _keyupListener = null;
+
+ /**
+ * @exports WoltLab/WCF/UI/Dialog
+ */
+ var UIDialog = {
+ /**
+ * Sets up global container and internal variables.
+ */
+ setup: function() {
+ // Fetch Ajax, as it cannot be provided because of a circular dependency
+ if (Ajax === undefined) Ajax = require('Ajax');
+
+ _container = document.createElement('div');
+ _container.classList.add('dialogOverlay');
+ _container.setAttribute('aria-hidden', 'true');
+ _container.addEventListener('click', this._closeOnBackdrop.bind(this));
+
+ document.body.appendChild(_container);
+
+ _keyupListener = (function(event) {
+ if (event.keyCode === 27) {
+ if (event.target.nodeName !== 'INPUT' && event.target.nodeName !== 'TEXTAREA') {
+ this.close(_activeDialog);
+
+ return false;
+ }
+ }
+
+ return true;
+ }).bind(this);
+
+ enquire.register('screen and (max-width: 800px)', {
+ match: function() { _dialogFullHeight = true; },
+ unmatch: function() { _dialogFullHeight = false; },
+ setup: function() { _dialogFullHeight = true; },
+ deferSetup: true
+ });
+ },
+
+ /**
+ * Opens the dialog and implicitly creates it on first usage.
+ *
+ * @param {object} callbackObject used to invoke `_dialogSetup()` on first call
+ * @param {(string|DocumentFragment=} html html content or document fragment to use for dialog content
+ * @returns {object<string, *>} dialog data
+ */
+ open: function(callbackObject, html) {
+ var dialogData = _dialogObjects.get(callbackObject);
+ if (Core.isPlainObject(dialogData)) {
+ // dialog already exists
+ return this.openStatic(dialogData.id, html);
+ }
+
+ // initialize a new dialog
+ if (typeof callbackObject._dialogSetup !== 'function') {
+ throw new Error("Callback object does not implement the method '_dialogSetup()'.");
+ }
+
+ var setupData = callbackObject._dialogSetup();
+ if (!Core.isPlainObject(setupData)) {
+ throw new Error("Expected an object literal as return value of '_dialogSetup()'.");
+ }
+
+ dialogData = { id: setupData.id };
+
+ var createOnly = true;
+ if (setupData.source === undefined) {
+ var dialogElement = document.getElementById(setupData.id);
+ if (dialogElement === null) {
+ throw new Error("Element id '" + setupData.id + "' is invalid and no source attribute was given.");
+ }
+
+ setupData.source = document.createDocumentFragment();
+ setupData.source.appendChild(dialogElement);
+ }
+ else if (setupData.source === null) {
+ // `null` means there is no static markup and `html` should be used instead
+ setupData.source = html;
+ }
+
+ else if (typeof setupData.source === 'function') {
+ setupData.source();
+ }
+ else if (Core.isPlainObject(setupData.source)) {
+ Ajax.api(this, setupData.source.data, (function(data) {
+ if (data.returnValues && typeof data.returnValues.template === 'string') {
+ this.open(callbackObject, data.returnValues.template);
+
+ if (typeof setupData.source.after === 'function') {
+ setupData.source.after(_dialogs.get(setupData.id).content, data);
+ }
+ }
+ }).bind(this));
+ }
+ else {
+ if (typeof setupData.source === 'string') {
+ var dialogElement = document.createElement('div');
+ dialogElement.setAttribute('id', setupData.id);
+ dialogElement.innerHTML = setupData.source;
+
+ setupData.source = document.createDocumentFragment();
+ setupData.source.appendChild(dialogElement);
+ }
+
+ if (!setupData.source.nodeType || setupData.source.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
+ throw new Error("Expected at least a document fragment as 'source' attribute.");
+ }
+
+ createOnly = false;
+ }
+
+ _dialogObjects.set(callbackObject, dialogData);
+
+ return this.openStatic(setupData.id, setupData.source, setupData.options, createOnly);
+ },
+
+ /**
+ * Opens an dialog, if the dialog is already open the content container
+ * will be replaced by the HTML string contained in the parameter html.
+ *
+ * If id is an existing element id, html will be ignored and the referenced
+ * element will be appended to the content element instead.
+ *
+ * @param {string} id element id, if exists the html parameter is ignored in favor of the existing element
+ * @param {?(string|DocumentFragment)} html content html
+ * @param {object<string, *>} options list of options, is completely ignored if the dialog already exists
+ * @param {boolean=} createOnly create the dialog but do not open it
+ * @return {object<string, *>} dialog data
+ */
+ openStatic: function(id, html, options, createOnly) {
+ if (_dialogs.has(id)) {
+ this._updateDialog(id, html);
+ }
+ else {
+ options = Core.extend({
+ backdropCloseOnClick: true,
+ closable: true,
+ closeButtonLabel: Language.get('wcf.global.button.close'),
+ closeConfirmMessage: '',
+ disableContentPadding: false,
+ disposeOnClose: false,
+ title: '',
+
+ // callbacks
+ onBeforeClose: null,
+ onClose: null,
+ onShow: null
+ }, options);
+
+ if (!options.closable) options.backdropCloseOnClick = false;
+ if (options.closeConfirmMessage) {
+ options.onBeforeClose = (function(id) {
+ UIConfirmation.show({
+ confirm: this.close.bind(this, id),
+ message: options.closeConfirmMessage
+ });
+ }).bind(this);
+ }
+
+ this._createDialog(id, html, options);
+ }
+
+ return _dialogs.get(id);
+ },
+
+ /**
+ * Sets the dialog title.
+ *
+ * @param {string} id element id
+ * @param {string} title dialog title
+ */
+ setTitle: function(id, title) {
+ var data = _dialogs.get(id);
+ if (data === undefined) {
+ throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+ }
+
+ var header = DOMTraverse.childrenByTag(data.dialog, 'HEADER');
+ DOMTraverse.childrenByTag(header[0], 'SPAN').textContent = title;
+ },
+
+ /**
+ * Creates the DOM for a new dialog and opens it.
+ *
+ * @param {string} id element id, if exists the html parameter is ignored in favor of the existing element
+ * @param {?(string|DocumentFragment)} html content html
+ * @param {object<string, *>} options list of options
+ * @param {boolean=} createOnly create the dialog but do not open it
+ */
+ _createDialog: function(id, html, options, createOnly) {
+ var element = null;
+ if (html === null) {
+ element = document.getElementById(id);
+ if (element === null) {
+ throw new Error("Expected either a HTML string or an existing element id.");
+ }
+ }
+
+ var dialog = document.createElement('div');
+ dialog.classList.add('dialogContainer');
+ dialog.setAttribute('aria-hidden', 'true');
+ dialog.setAttribute('role', 'dialog');
+ dialog.setAttribute('data-id', id);
+
+ if (options.disposeOnClose) {
+ dialog.setAttribute('data-dispose-on-close', true);
+ }
+
+ var header = document.createElement('header');
+ dialog.appendChild(header);
+
+ if (options.title) {
+ var titleId = DOMUtil.getUniqueId();
+ dialog.setAttribute('aria-labelledby', titleId);
+
+ var title = document.createElement('span');
+ title.classList.add('dialogTitle');
+ title.textContent = options.title;
+ title.setAttribute('id', titleId);
+ header.appendChild(title);
+ }
+
+ if (options.closable) {
+ var closeButton = document.createElement('a');
+ closeButton.className = 'dialogCloseButton jsTooltip';
+ closeButton.setAttribute('title', options.closeButtonLabel);
+ closeButton.setAttribute('aria-label', options.closeButtonLabel);
+ closeButton.addEventListener('click', this._close.bind(this));
+ header.appendChild(closeButton);
+
+ var span = document.createElement('span');
+ span.textContent = options.closeButtonLabel;
+ closeButton.appendChild(span);
+ }
+
+ var contentContainer = document.createElement('div');
+ contentContainer.classList.add('dialogContent');
+ if (options.disableContentPadding) contentContainer.classList.add('dialogContentNoPadding');
+ dialog.appendChild(contentContainer);
+
+ var content;
+ if (element === null) {
+ content = document.createElement('div');
+
+ if (typeof html === 'string') {
+ content.innerHTML = html;
+ }
+ else if (html instanceof DocumentFragment) {
+ if (html.children[0].nodeName !== 'div' || html.childElementCount > 1) {
+ content.appendChild(html);
+ }
+ else {
+ content = html.children[0];
+ }
+ }
+
+ content.id = id;
+ }
+ else {
+ content = element;
+ }
+
+ contentContainer.appendChild(content);
+
+ if (content.style.getPropertyValue('display') === 'none') {
+ content.style.removeProperty('display');
+ }
+
+ _dialogs.set(id, {
+ backdropCloseOnClick: options.backdropCloseOnClick,
+ content: content,
+ dialog: dialog,
+ header: header,
+ onBeforeClose: options.onBeforeClose,
+ onClose: options.onClose,
+ onShow: options.onShow
+ });
+
+ DOMUtil.prepend(dialog, _container);
+
+ if (createOnly !== true) {
+ this._updateDialog(id, null);
+ }
+ },
+
+ /**
+ * Updates the dialog's content element.
+ *
+ * @param {string} id element id
+ * @param {?string} html content html, prevent changes by passing null
+ */
+ _updateDialog: function(id, html) {
+ var data = _dialogs.get(id);
+ if (data === undefined) {
+ throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+ }
+
+ if (typeof html === 'string') {
+ data.content.innerHTML = '';
+
+ var content = document.createElement('div');
+ content.innerHTML = html;
+
+ data.content.appendChild(content);
+ }
+
+ if (data.dialog.getAttribute('aria-hidden') === 'true') {
+ if (_container.getAttribute('aria-hidden') === 'true') {
+ window.addEventListener('keyup', _keyupListener);
+ }
+
+ data.dialog.setAttribute('aria-hidden', 'false');
+ _container.setAttribute('aria-hidden', 'false');
+ _container.setAttribute('data-close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
+ _activeDialog = id;
+
+ this.rebuild(id);
+
+ if (typeof data.onShow === 'function') {
+ data.onShow(id);
+ }
+ }
+
+ DOMChangeListener.trigger();
+ },
+
+ /**
+ * Rebuilds dialog identified by given id.
+ *
+ * @param {string} id element id
+ */
+ rebuild: function(id) {
+ var data = _dialogs.get(id);
+ if (data === undefined) {
+ throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+ }
+
+ // ignore non-active dialogs
+ if (data.dialog.getAttribute('aria-hidden') === 'true') {
+ return;
+ }
+
+ var contentContainer = data.content.parentNode;
+
+ var formSubmit = data.content.querySelector('.formSubmit');
+ var unavailableHeight = 0;
+ if (formSubmit !== null) {
+ contentContainer.classList.add('dialogForm');
+ formSubmit.classList.add('dialogFormSubmit');
+
+ unavailableHeight += DOMUtil.outerHeight(formSubmit);
+ contentContainer.style.setProperty('margin-bottom', unavailableHeight + 'px');
+ }
+ else {
+ contentContainer.classList.remove('dialogForm');
+ }
+
+ unavailableHeight += DOMUtil.outerHeight(data.header);
+
+ var maximumHeight = (window.innerHeight * (_dialogFullHeight ? 1 : 0.8)) - unavailableHeight;
+ contentContainer.style.setProperty('max-height', ~~maximumHeight + 'px');
+
+ // fix for a calculation bug in Chrome causing the scrollbar to overlap the border
+ if (Environment.browser() === 'chrome') {
+ if (data.content.scrollHeight > maximumHeight) {
+ data.content.style.setProperty('margin-right', '-1px');
+ }
+ else {
+ data.content.style.removeProperty('margin-right');
+ }
+ }
+ },
+
+ /**
+ * Handles clicks on the close button or the backdrop if enabled.
+ *
+ * @param {object} event click event
+ * @return {boolean} false if the event should be cancelled
+ */
+ _close: function(event) {
+ event.preventDefault();
+
+ var data = _dialogs.get(_activeDialog);
+ if (typeof data.onBeforeClose === 'function') {
+ data.onBeforeClose(_activeDialog);
+
+ return false;
+ }
+
+ this.close(_activeDialog);
+ },
+
+ /**
+ * Closes the current active dialog by clicks on the backdrop.
+ *
+ * @param {object} event event object
+ */
+ _closeOnBackdrop: function(event) {
+ if (event.target !== _container) {
+ return true;
+ }
+
+ if (_container.getAttribute('data-close-on-click') === 'true') {
+ this._close(event);
+ }
+ else {
+ event.preventDefault();
+ }
+ },
+
+ /**
+ * Closes a dialog identified by given id.
+ *
+ * @param {(string|object)} id element id or callback object
+ */
+ close: function(id) {
+ if (typeof id === 'object') {
+ var dialogData = _dialogObjects.get(id);
+ if (dialogData !== undefined) {
+ id = dialogData.id;
+ }
+ }
+
+ var data = _dialogs.get(id);
+ if (data === undefined) {
+ throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+ }
+
+ if (typeof data.onClose === 'function') {
+ data.onClose(id);
+ }
+
+ if (data.dialog.getAttribute('data-dispose-on-close')) {
+ setTimeout(function() {
+ if (data.dialog.getAttribute('aria-hidden') === 'true') {
+ _container.removeChild(data.dialog);
+ _dialogs['delete'](id);
+ }
+ }, 5000);
+ }
+ else {
+ data.dialog.setAttribute('aria-hidden', 'true');
+ }
+
+ // get next active dialog
+ _activeDialog = null;
+ for (var i = 0; i < _container.childElementCount; i++) {
+ var child = _container.children[i];
+ if (child.getAttribute('aria-hidden') === 'false') {
+ _activeDialog = child.getAttribute('data-id');
+ break;
+ }
+ }
+
+ if (_activeDialog === null) {
+ _container.setAttribute('aria-hidden', 'true');
+ _container.setAttribute('data-close-on-click', 'false');
+
+ window.removeEventListener('keyup', _keyupListener);
+ }
+ else {
+ data = _dialogs.get(_activeDialog);
+ _container.setAttribute('data-close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
+ }
+ },
+
+ /**
+ * Returns the dialog data for given element id.
+ *
+ * @param {string} id element id
+ * @return {(object|undefined)} dialog data or undefined if element id is unknown
+ */
+ getDialog: function(id) {
+ return _dialogs.get(id);
+ },
+
+ _ajaxSetup: function() {
+ return {};
+ }
+ };
+
+ return UIDialog;
+});
--- /dev/null
+/**
+ * Simple Dropdown
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/Dropdown/Simple
+ */
+define(
+ [ 'CallbackList', 'Core', 'Dictionary', 'UI/Alignment', 'DOM/ChangeListener', 'DOM/Traverse', 'DOM/Util', 'UI/CloseOverlay'],
+ function(CallbackList, Core, Dictionary, UIAlignment, DOMChangeListener, DOMTraverse, DOMUtil, UICloseOverlay)
+{
+ "use strict";
+
+ var _availableDropdowns = null;
+ var _callbacks = new CallbackList();
+ var _didInit = false;
+ var _dropdowns = new Dictionary();
+ var _menus = new Dictionary();
+ var _menuContainer = null;
+
+ /**
+ * @exports WoltLab/WCF/UI/Dropdown/Simple
+ */
+ var SimpleDropdown = {
+ /**
+ * Performs initial setup such as setting up dropdowns and binding listeners.
+ */
+ setup: function() {
+ if (_didInit) return;
+ _didInit = true;
+
+ _menuContainer = document.createElement('div');
+ _menuContainer.setAttribute('id', 'dropdownMenuContainer');
+ document.body.appendChild(_menuContainer);
+
+ _availableDropdowns = document.getElementsByClassName('dropdownToggle');
+
+ this.initAll();
+
+ UICloseOverlay.add('WoltLab/WCF/UI/Dropdown/Simple', this.closeAll.bind(this));
+ DOMChangeListener.add('WoltLab/WCF/UI/Dropdown/Simple', this.initAll.bind(this));
+
+ document.addEventListener('scroll', this._onScroll.bind(this));
+
+ // expose on window object for backward compatibility
+ window.bc_wcfSimpleDropdown = this;
+ },
+
+ /**
+ * Loops through all possible dropdowns and registers new ones.
+ */
+ initAll: function() {
+ for (var i = 0, length = _availableDropdowns.length; i < length; i++) {
+ this.init(_availableDropdowns[i], false);
+ }
+ },
+
+ /**
+ * Initializes a dropdown.
+ *
+ * @param {Element} button
+ * @param {boolean} isLazyInitialization
+ */
+ init: function(button, isLazyInitialization) {
+ this.setup();
+
+ if (button.classList.contains('jsDropdownEnabled') || button.getAttribute('data-target')) {
+ return false;
+ }
+
+ var dropdown = DOMTraverse.parentByClass(button, 'dropdown');
+ if (dropdown === null) {
+ throw new Error("Invalid dropdown passed, button '" + DOMUtil.identify(button) + "' does not have a parent with .dropdown.");
+ }
+
+ var menu = DOMTraverse.nextByClass(button, 'dropdownMenu');
+ if (menu === null) {
+ throw new Error("Invalid dropdown passed, button '" + DOMUtil.identify(button) + "' does not have a menu as next sibling.");
+ }
+
+ // move menu into global container
+ _menuContainer.appendChild(menu);
+
+ var containerId = DOMUtil.identify(dropdown);
+ if (!_dropdowns.has(containerId)) {
+ button.classList.add('jsDropdownEnabled');
+ button.addEventListener('click', this._toggle.bind(this));
+
+ _dropdowns.set(containerId, dropdown);
+ _menus.set(containerId, menu);
+
+ if (!containerId.match(/^wcf\d+$/)) {
+ menu.setAttribute('data-source', containerId);
+ }
+ }
+
+ button.setAttribute('data-target', containerId);
+
+ if (isLazyInitialization) {
+ setTimeout(function() { Core.triggerEvent(button, 'click'); }, 10);
+ }
+ },
+
+ /**
+ * Initializes a remote-controlled dropdown.
+ *
+ * @param {Element} dropdown dropdown wrapper element
+ * @param {Element} menu menu list element
+ */
+ initFragment: function(dropdown, menu) {
+ this.setup();
+
+ if (_dropdowns.has(dropdown)) {
+ return;
+ }
+
+ var containerId = DOMUtil.identify(dropdown);
+ _dropdowns.set(containerId, dropdown);
+ _menuContainer.appendChild(menu);
+
+ _menus.set(containerId, menu);
+ },
+
+ /**
+ * Registers a callback for open/close events.
+ *
+ * @param {string} containerId dropdown wrapper id
+ * @param {function(string, string)} callback
+ */
+ registerCallback: function(containerId, callback) {
+ _callbacks.add(containerId, callback);
+ },
+
+ /**
+ * Returns the requested dropdown wrapper element.
+ *
+ * @return {Element} dropdown wrapper element
+ */
+ getDropdown: function(containerId) {
+ return _dropdowns.get(containerId);
+ },
+
+ /**
+ * Returns the requested dropdown menu list element.
+ *
+ * @return {Element} menu list element
+ */
+ getDropdownMenu: function(containerId) {
+ return _menus.get(containerId);
+ },
+
+ /**
+ * Toggles the requested dropdown between opened and closed.
+ *
+ * @param {string} containerId dropdown wrapper id
+ */
+ toggleDropdown: function(containerId) {
+ this._toggle(null, containerId);
+ },
+
+ /**
+ * Calculates and sets the alignment of given dropdown.
+ *
+ * @param {Element} dropdown dropdown wrapper element
+ * @param {Element} dropdownMenu menu list element
+ */
+ setAlignment: function(dropdown, dropdownMenu) {
+ // check if button belongs to an i18n textarea
+ var button = dropdown.querySelector('.dropdownToggle');
+ var refDimensionsElement = null;
+ if (button !== null && button.classList.contains('dropdownCaptionTextarea')) {
+ refDimensionsElement = button;
+ }
+
+ UIAlignment.set(dropdownMenu, dropdown, {
+ pointerClassNames: ['dropdownArrowBottom', 'dropdownArrowRight'],
+ refDimensionsElement: refDimensionsElement
+ });
+ },
+
+ /**
+ * Calculats and sets the alignment of the dropdown identified by given id.
+ *
+ * @param {string} containerId dropdown wrapper id
+ */
+ setAlignmentById: function(containerId) {
+ var dropdown = _dropdowns.get(containerId);
+ if (dropdown === undefined) {
+ throw new Error("Unknown dropdown identifier '" + containerId + "'.");
+ }
+
+ var menu = _menus.get(containerId);
+
+ this.setAlignment(dropdown, menu);
+ },
+
+ /**
+ * Returns true if target dropdown exists and is open.
+ *
+ * @param {string} containerId dropdown wrapper id
+ * @return {boolean} true if dropdown exists and is open
+ */
+ isOpen: function(containerId) {
+ var menu = _menus.get(containerId);
+ if (menu !== undefined && menu.classList.contains('dropdownOpen')) {
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Opens the dropdown unless it is already open.
+ *
+ * @param {string} containerId dropdown wrapper id
+ */
+ open: function(containerId) {
+ var menu = _menus.get(containerId);
+ if (menu !== undefined && !menu.classList.contains('dropdownOpen')) {
+ this.toggleDropdown(containerId);
+ }
+ },
+
+ /**
+ * Closes the dropdown identified by given id without notifying callbacks.
+ *
+ * @param {string} containerId dropdown wrapper id
+ */
+ close: function(containerId) {
+ var dropdown = _dropdowns.get(containerId);
+ if (dropdown !== undefined) {
+ dropdown.classList.remove('dropdownOpen');
+ _menus.get(containerId).classList.remove('dropdownOpen');
+ }
+ },
+
+ /**
+ * Closes all dropdowns.
+ */
+ closeAll: function() {
+ _dropdowns.forEach((function(dropdown, containerId) {
+ if (dropdown.classList.contains('dropdownOpen')) {
+ dropdown.classList.remove('dropdownOpen');
+ _menus.get(containerId).classList.remove('dropdownOpen');
+
+ this._notifyCallbacks(containerId, 'close');
+ }
+ }).bind(this));
+ },
+
+ /**
+ * Destroys a dropdown identified by given id.
+ *
+ * @param {string} containerId dropdown wrapper id
+ * @return {boolean} false for unknown dropdowns
+ */
+ destroy: function(containerId) {
+ if (!_dropdowns.has(containerId)) {
+ return false;
+ }
+
+ this.close(containerId);
+
+ var menu = _menus.get(containerId);
+ _menus.parentNode.removeChild(menu);
+
+ _menus['delete'](containerId);
+ _dropdowns['delete'](containerId);
+
+ return true;
+ },
+
+ /**
+ * Handles dropdown positions in overlays when scrolling in the overlay.
+ *
+ * @param {Event} event event object
+ */
+ _onDialogScroll: function(event) {
+ var dialogContent = event.currentTarget;
+ var dropdowns = dialogContent.querySelectorAll('.dropdown.dropdownOpen');
+
+ for (var i = 0, length = dropdowns.length; i < length; i++) {
+ var dropdown = dropdowns[i];
+ var containerId = DOMUtil.identify(dropdown);
+ var offset = DOMUtil.offset(dropdown);
+ var dialogOffset = DOMUtil.offset(dialogContent);
+
+ // check if dropdown toggle is still (partially) visible
+ if (offset.top + dropdown.clientHeight <= dialogOffset.top) {
+ // top check
+ this.toggleDropdown(containerId);
+ }
+ else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) {
+ // bottom check
+ this.toggleDropdown(containerId);
+ }
+ else if (offset.left <= dialogOffset.left) {
+ // left check
+ this.toggleDropdown(containerId);
+ }
+ else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) {
+ // right check
+ this.toggleDropdown(containerId);
+ }
+ else {
+ this.setAlignment(containerId, _menus.get(containerId));
+ }
+ }
+ },
+
+ /**
+ * Recalculates dropdown positions on page scroll.
+ */
+ _onScroll: function() {
+ _dropdowns.forEach((function(dropdown, containerId) {
+ if (dropdown.getAttribute('data-is-overlay-dropdown-button') === true && dropdown.classList.contains('dropdownOpen')) {
+ this.setAlignment(dropdown, _menus.get(containerId));
+ }
+ }).bind(this));
+ },
+
+ /**
+ * Notifies callbacks on status change.
+ *
+ * @param {string} containerId dropdown wrapper id
+ * @param {string} action can be either 'open' or 'close'
+ */
+ _notifyCallbacks: function(containerId, action) {
+ _callbacks.forEach(containerId, function(callback) {
+ callback(containerId, action);
+ });
+ },
+
+ /**
+ * Toggles the dropdown's state between open and close.
+ *
+ * @param {?Event} event event object, should be 'null' if targetId is given
+ * @param {string=} targetId dropdown wrapper id
+ * @return {boolean} 'false' if event is not null
+ */
+ _toggle: function(event, targetId) {
+ if (event !== null) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ targetId = event.currentTarget.getAttribute('data-target');
+ }
+
+ // check if 'isOverlayDropdownButton' is set which indicates if
+ // the dropdown toggle is in an overlay
+ var dropdown = _dropdowns.get(targetId);
+ if (dropdown !== undefined && dropdown.getAttribute('data-is-overlay-dropdown-button') === null) {
+ var dialogContent = DOMTraverse.parentByClass(dropdown, 'dialogContent');
+ dropdown.setAttribute('data-is-overlay-dropdown-button', (dialogContent !== null));
+
+ if (dialogContent !== null) {
+ dialogContent.addEventListener('scroll', this._onDialogScroll.bind(this));
+ }
+ }
+
+ // close all dropdowns
+ _dropdowns.forEach((function(dropdown, containerId) {
+ var menu = _menus.get(containerId);
+
+ if (dropdown.classList.contains('dropdownOpen')) {
+ dropdown.classList.remove('dropdownOpen');
+ menu.classList.remove('dropdownOpen');
+
+ this._notifyCallbacks(containerId, 'close');
+ }
+ else if (containerId === targetId && menu.childElementCount > 0) {
+ dropdown.classList.add('dropdownOpen');
+ menu.classList.add('dropdownOpen');
+
+ this._notifyCallbacks(containerId, 'open');
+
+ this.setAlignment(dropdown, menu);
+ }
+ }).bind(this));
+
+ // TODO
+ WCF.Dropdown.Interactive.Handler.closeAll();
+
+ return (event === null);
+ }
+ };
+
+ return SimpleDropdown;
+});
--- /dev/null
+/**
+ * Dynamically transforms menu-like structures to handle items exceeding the available width
+ * by moving them into a separate dropdown.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/FlexibleMenu
+ */
+define(['Core', 'Dictionary', 'DOM/ChangeListener', 'DOM/Traverse', 'DOM/Util', 'UI/SimpleDropdown'], function(Core, Dictionary, DOMChangeListener, DOMTraverse, DOMUtil, SimpleDropdown) {
+ "use strict";
+
+ var _containers = new Dictionary();
+ var _dropdowns = new Dictionary();
+ var _dropdownMenus = new Dictionary();
+ var _itemLists = new Dictionary();
+
+ /**
+ * @exports WoltLab/WCF/UI/FlexibleMenu
+ */
+ var UIFlexibleMenu = {
+ /**
+ * Register default menus and set up event listeners.
+ */
+ setup: function() {
+ if (document.getElementById('mainMenu') !== null) this.register('mainMenu');
+ var navigationHeader = document.querySelector('.navigationHeader');
+ if (navigationHeader !== null) this.register(DOMUtil.identify(navigationHeader));
+
+ window.addEventListener('resize', this.rebuildAll.bind(this));
+ DOMChangeListener.add('WoltLab/WCF/UI/FlexibleMenu', this.registerTabMenus.bind(this));
+ },
+
+ /**
+ * Registers a menu by element id.
+ *
+ * @param {string} containerId element id
+ */
+ register: function(containerId) {
+ var container = document.getElementById(containerId);
+ if (container === null) {
+ throw "Expected a valid element id, '" + containerId + "' does not exist.";
+ }
+
+ if (_containers.has(containerId)) {
+ return;
+ }
+
+ var list = DOMTraverse.childByTag(container, 'UL');
+ if (list === null) {
+ throw "Expected an <ul> element as child of container '" + containerId + "'.";
+ }
+
+ _containers.set(containerId, container);
+ _itemLists.set(containerId, list);
+
+ this.rebuild(containerId);
+ },
+
+ /**
+ * Registers tab menus.
+ */
+ registerTabMenus: function() {
+ var tabMenus = document.querySelectorAll('.tabMenuContainer:not(.jsFlexibleMenuEnabled), .messageTabMenu:not(.jsFlexibleMenuEnabled)');
+ for (var i = 0, length = tabMenus.length; i < length; i++) {
+ var tabMenu = tabMenus[i];
+ var nav = DOMTraverse.childByTag(tabMenu, 'NAV');
+ if (nav !== null) {
+ tabMenu.classList.add('jsFlexibleMenuEnabled');
+ this.register(DOMUtil.identify(nav));
+ }
+ }
+ },
+
+ /**
+ * Rebuilds all menus, e.g. on window resize.
+ */
+ rebuildAll: function() {
+ _containers.forEach((function(container, containerId) {
+ this.rebuild(containerId);
+ }).bind(this));
+ },
+
+ /**
+ * Rebuild the menu identified by given element id.
+ *
+ * @param {string} containerId element id
+ */
+ rebuild: function(containerId) {
+ var container = _containers.get(containerId);
+ if (container === undefined) {
+ throw "Expected a valid element id, '" + containerId + "' is unknown.";
+ }
+
+ var styles = window.getComputedStyle(container);
+
+ var availableWidth = container.parentNode.clientWidth;
+ availableWidth -= DOMUtil.styleAsInt(styles, 'margin-left');
+ availableWidth -= DOMUtil.styleAsInt(styles, 'margin-right');
+
+ var list = _itemLists.get(containerId);
+ var items = DOMTraverse.childrenByTag(list, 'LI');
+ var dropdown = _dropdowns.get(containerId);
+ var dropdownWidth = 0;
+ if (dropdown !== undefined) {
+ // show all items for calculation
+ for (var i = 0, length = items.length; i < length; i++) {
+ var item = items[i];
+ if (item.classList.contains('dropdown')) {
+ continue;
+ }
+
+ item.style.removeProperty('display');
+ }
+
+ if (dropdown.parentNode !== null) {
+ dropdownWidth = DOMUtil.outerWidth(dropdown);
+ }
+ }
+
+ var currentWidth = list.scrollWidth - dropdownWidth;
+ var hiddenItems = [];
+ if (currentWidth > availableWidth) {
+ // hide items starting with the last one
+ for (var i = items.length - 1; i >= 0; i--) {
+ var item = items[i];
+
+ // ignore dropdown and active item
+ if (item.classList.contains('dropdown') || item.classList.contains('active') || item.classList.contains('ui-state-active')) {
+ continue;
+ }
+
+ hiddenItems.push(item);
+ item.style.setProperty('display', 'none');
+
+ if (list.scrollWidth < availableWidth) {
+ break;
+ }
+ }
+ }
+
+ if (hiddenItems.length) {
+ var dropdownMenu;
+ if (dropdown === undefined) {
+ dropdown = document.createElement('li');
+ dropdown.className = 'dropdown jsFlexibleMenuDropdown';
+ var icon = document.createElement('a');
+ icon.className = 'icon icon16 fa-list';
+ dropdown.appendChild(icon);
+
+ dropdownMenu = document.createElement('ul');
+ dropdownMenu.classList.add('dropdownMenu');
+ dropdown.appendChild(dropdownMenu);
+
+ _dropdowns.set(containerId, dropdown);
+ _dropdownMenus.set(containerId, dropdownMenu);
+
+ SimpleDropdown.init(icon);
+ }
+ else {
+ dropdownMenu = _dropdownMenus.get(containerId);
+ }
+
+ if (dropdown.parentNode === null) {
+ list.appendChild(dropdown);
+ }
+
+ // build dropdown menu
+ var fragment = document.createDocumentFragment();
+
+ var self = this;
+ hiddenItems.forEach(function(hiddenItem) {
+ var item = document.createElement('li');
+ item.innerHTML = hiddenItem.innerHTML;
+
+ item.addEventListener('click', (function(event) {
+ event.preventDefault();
+
+ Core.triggerEvent(hiddenItem.querySelector('a'), 'click');
+
+ // force a rebuild to guarantee the active item being visible
+ setTimeout(function() {
+ self.rebuild(containerId);
+ }, 59);
+ }).bind(this));
+
+ fragment.appendChild(item);
+ });
+
+ dropdownMenu.innerHTML = '';
+ dropdownMenu.appendChild(fragment);
+ }
+ else if (dropdown !== undefined && dropdown.parentNode !== null) {
+ dropdown.parentNode.removeChild(dropdown);
+ }
+ }
+ };
+
+ return UIFlexibleMenu;
+});
--- /dev/null
+/**
+ * Flexible UI element featuring both a list of items and an input field with suggestion support.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/ItemList
+ */
+define(['Core', 'Dictionary', 'Language', 'DOM/Traverse', 'WoltLab/WCF/UI/Suggestion'], function(Core, Dictionary, Language, DOMTraverse, UISuggestion) {
+ "use strict";
+
+ var _activeId = '';
+ var _data = new Dictionary();
+ var _didInit = false;
+
+ var _callbackKeyDown = null;
+ var _callbackKeyPress = null;
+ var _callbackKeyUp = null;
+ var _callbackRemoveItem = null;
+
+ /**
+ * @exports WoltLab/WCF/UI/ItemList
+ */
+ var UIItemList = {
+ /**
+ * Initializes an item list.
+ *
+ * The `values` argument must be empty or contain a list of strings or object, e.g.
+ * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
+ *
+ * @param {string} elementId input element id
+ * @param {array<mixed>} values list of existing values
+ * @param {object<string>} options option list
+ */
+ init: function(elementId, values, options) {
+ var element = document.getElementById(elementId);
+ if (element === null) {
+ throw new Error("Expected a valid element id.");
+ }
+
+ options = Core.extend({
+ // search parameters for suggestions
+ ajax: {
+ actionName: 'getSearchResultList',
+ className: '',
+ data: {}
+ },
+
+ // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
+ excludedSearchValues: [],
+ // maximum number of items this list may contain, `-1` for infinite
+ maxItems: -1,
+ // maximum length of an item value, `-1` for infinite
+ maxLength: -1,
+ // disallow custom values, only values offered by the suggestion dropdown are accepted
+ restricted: false,
+
+ // initial value will be interpreted as comma separated value and submitted as such
+ isCSV: false,
+
+ // will be invoked whenever the items change, receives the element id first and list of values second
+ callbackChange: null,
+ // callback once the form is about to be submitted
+ callbackSubmit: null,
+ // value may contain the placeholder `{$objectId}`
+ submitFieldName: ''
+ }, options);
+
+ var form = DOMTraverse.parentByTag(element, 'FORM');
+ if (form !== null) {
+ if (options.isCSV === false) {
+ if (!options.submitFieldName.length && typeof options.callbackSubmit !== 'function') {
+ throw new Error("Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.");
+ }
+
+ form.addEventListener('submit', (function() {
+ var values = this.getValues(elementId);
+ if (options.submitFieldName.length) {
+ var input;
+ for (var i = 0, length = values.length; i < length; i++) {
+ input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = options.submitFieldName.replace(/{$objectId}/, values[i].objectId);
+ input.value = values[i].value;
+
+ form.appendChild(input);
+ }
+ }
+ else {
+ options.callbackSubmit(form, values);
+ }
+ }).bind(this));
+ }
+ }
+
+ this._setup();
+
+ var data = this._createUI(element, options, values);
+ var suggestion = new UISuggestion(elementId, {
+ ajax: options.ajax,
+ callbackSelect: this._addItem.bind(this),
+ excludedSearchValues: options.excludedSearchValues
+ });
+
+ _data.set(elementId, {
+ dropdownMenu: null,
+ element: data.element,
+ list: data.list,
+ listItem: data.element.parentNode,
+ options: options,
+ shadow: data.shadow,
+ suggestion: suggestion
+ });
+
+ values = (data.values.length) ? data.values : values;
+ if (Array.isArray(values)) {
+ var value;
+ for (var i = 0, length = values.length; i < length; i++) {
+ value = values[i];
+ if (typeof value === 'string') {
+ value = { objectId: 0, value: value };
+ }
+
+ this._addItem(elementId, value);
+ }
+ }
+ },
+
+ /**
+ * Returns the list of current values.
+ *
+ * @param {string} element id input element id
+ * @return {array<object>} list of objects containing object id and value
+ */
+ getValues: function(elementId) {
+ if (!_data.has(elementId)) {
+ throw new Error("Element id '" + elementId + "' is unknown.");
+ }
+
+ var data = _data.get(elementId);
+ var items = DOMTraverse.childrenByClass(data.list, 'item');
+ var values = [], value, item;
+ for (var i = 0, length = items.length; i < length; i++) {
+ item = items[i];
+ value = {
+ objectId: item.getAttribute('data-object-id'),
+ value: DOMTraverse.childByTag(item, 'SPAN').textContent
+ };
+
+ values.push(value);
+ }
+
+ return values;
+ },
+
+ /**
+ * Binds static event listeners.
+ */
+ _setup: function() {
+ if (_didInit) {
+ return;
+ }
+
+ _didInit = true;
+
+ _callbackKeyDown = this._keyDown.bind(this);
+ _callbackKeyPress = this._keyPress.bind(this);
+ _callbackKeyUp = this._keyUp.bind(this);
+ _callbackRemoveItem = this._removeItem.bind(this);
+ },
+
+ /**
+ * Creates the DOM structure for target element. If `element` is a `<textarea>`
+ * it will be automatically replaced with an `<input>` element.
+ *
+ * @param {Element} element input element
+ * @param {object<string>} options option list
+ */
+ _createUI: function(element, options) {
+ var list = document.createElement('ol');
+ list.className = 'inputItemList';
+ list.setAttribute('data-element-id', element.id);
+ list.addEventListener('click', function(event) {
+ if (event.target === list) element.focus();
+ });
+
+ var listItem = document.createElement('li');
+ listItem.className = 'input';
+ list.appendChild(listItem);
+
+ element.addEventListener('keydown', _callbackKeyDown);
+ element.addEventListener('keypress', _callbackKeyPress);
+ element.addEventListener('keyup', _callbackKeyUp);
+
+ element.parentNode.insertBefore(list, element);
+ listItem.appendChild(element);
+
+ if (options.maxLength !== -1) {
+ element.setAttribute('maxLength', options.maxLength);
+ }
+
+ var shadow = null, values = [];
+ if (options.isCSV) {
+ shadow = document.createElement('input');
+ shadow.className = 'itemListInputShadow';
+ shadow.type = 'hidden';
+ shadow.name = element.name;
+ element.removeAttribute('name');
+
+ list.parentNode.insertBefore(shadow, list);
+
+ if (element.nodeName === 'TEXTAREA') {
+ var value, tmp = element.value.split(',');
+ for (var i = 0, length = tmp.length; i < length; i++) {
+ value = tmp[i].trim();
+ if (value.length) {
+ values.push(value);
+ }
+ }
+
+ var inputElement = document.createElement('input');
+ element.parentNode.insertBefore(inputElement, element);
+ inputElement.id = element.id;
+
+ element.parentNode.removeChild(element);
+ element = inputElement;
+ }
+ }
+
+ return {
+ element: element,
+ list: list,
+ shadow: shadow,
+ values: values
+ };
+ },
+
+ /**
+ * Enforces the maximum number of items.
+ *
+ * @param {string} elementId input element id
+ */
+ _handleLimit: function(elementId) {
+ var data = _data.get(elementId);
+ if (data.options.maxItems === -1) {
+ return;
+ }
+
+ if (data.list.childElementCount - 1 < data.options.maxItems) {
+ if (data.element.disabled) {
+ data.element.disabled = false;
+ data.element.removeAttribute('placeholder');
+ }
+ }
+ else if (!data.element.disabled) {
+ data.element.disabled = true;
+ data.element.setAttribute('placeholder', Language.get('wcf.global.form.input.maxItems'));
+ }
+ },
+
+ /**
+ * Sets the active item list id and handles keyboard access to remove an existing item.
+ *
+ * @param {object} event event object
+ */
+ _keyDown: function(event) {
+ var input = event.currentTarget;
+ var lastItem = input.parentNode.previousElementSibling;
+
+ _activeId = input.id;
+
+ if (event.keyCode === 8) {
+ // 8 = [BACKSPACE]
+ if (input.value.length === 0) {
+ if (lastItem !== null) {
+ if (lastItem.classList.contains('active')) {
+ this._removeItem(null, lastItem);
+ }
+ else {
+ lastItem.classList.add('active');
+ }
+ }
+ }
+ }
+ else if (event.keyCode === 27) {
+ // 27 = [ESC]
+ if (lastItem !== null && lastItem.classList.contains('active')) {
+ lastItem.classList.remove('active');
+ }
+ }
+ },
+
+ /**
+ * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
+ *
+ * @param {object} event event object
+ */
+ _keyPress: function(event) {
+ // 13 = [ENTER], 44 = [,]
+ if (event.charCode === 13 || event.charCode === 44) {
+ event.preventDefault();
+
+ if (_data.get(event.currentTarget.id).options.restricted) {
+ // restricted item lists only allow results from the dropdown to be picked
+ return;
+ }
+
+ var value = event.currentTarget.value.trim();
+ if (value.length) {
+ this._addItem(event.currentTarget.id, { objectId: 0, value: value });
+ }
+ }
+ },
+
+ /**
+ * Handles the keyup event to unmark an item for deletion.
+ *
+ * @param {object} event event object
+ */
+ _keyUp: function(event) {
+ var input = event.currentTarget;
+
+ if (input.value.length > 0) {
+ var lastItem = input.parentNode.previousElementSibling;
+ if (lastItem !== null) {
+ lastItem.classList.remove('active');
+ }
+ }
+ },
+
+ /**
+ * Adds an item to the list.
+ *
+ * @param {string} elementId input element id
+ * @param {string} value item value
+ */
+ _addItem: function(elementId, value) {
+ var data = _data.get(elementId);
+
+ var listItem = document.createElement('li');
+ listItem.className = 'item';
+
+ var content = document.createElement('span');
+ content.className = 'content';
+ content.setAttribute('data-object-id', value.objectId);
+ content.textContent = value.value;
+
+ var button = document.createElement('a');
+ button.className = 'icon icon16 fa-times';
+ button.addEventListener('click', _callbackRemoveItem);
+ listItem.appendChild(content);
+ listItem.appendChild(button);
+
+ data.list.insertBefore(listItem, data.listItem);
+ data.suggestion.addExcludedValue(value.value);
+ data.element.value = '';
+
+ this._handleLimit(elementId);
+ var values = this._syncShadow(data);
+
+ if (typeof data.options.callbackChange === 'function') {
+ if (values === null) values = this.getValues(elementId);
+ data.options.callbackChange(elementId, values);
+ }
+ },
+
+ /**
+ * Removes an item from the list.
+ *
+ * @param {?object} event event object
+ * @param {Element=} item list item
+ */
+ _removeItem: function(event, item) {
+ item = (event === null) ? item : event.currentTarget.parentNode;
+
+ var parent = item.parentNode;
+ var elementId = parent.getAttribute('data-element-id');
+ var data = _data.get(elementId);
+
+ data.suggestion.removeExcludedValue(item.children[0].textContent);
+ parent.removeChild(item);
+ data.element.focus();
+
+ this._handleLimit(elementId);
+ var values = this._syncShadow(data);
+
+ if (typeof data.options.callbackChange === 'function') {
+ if (values === null) values = this.getValues(elementId);
+ data.options.callbackChange(elementId, values);
+ }
+ },
+
+ /**
+ * Synchronizes the shadow input field with the current list item values.
+ *
+ * @param {object} data element data
+ */
+ _syncShadow: function(data) {
+ if (!data.options.isCSV) return null;
+
+ var value = '', values = this.getValues(data.element.id);
+ for (var i = 0, length = values.length; i < length; i++) {
+ value += (value.length ? ',' : '') + values[i].value;
+ }
+
+ data.shadow.value = value;
+
+ return values;
+ }
+ };
+
+ return UIItemList;
+});
--- /dev/null
+/**
+ * Provides an item list for users and groups.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/ItemList/User
+ */
+define(['WoltLab/WCF/UI/ItemList'], function(UIItemList) {
+ "use strict";
+
+ /**
+ * @exports WoltLab/WCF/UI/ItemList/User
+ */
+ var UIItemListUser = {
+ /**
+ * Initializes user suggestion support for an element.
+ *
+ * @param {string} elementId input element id
+ * @param {object} options option list
+ */
+ init: function(elementId, options) {
+ UIItemList.init(elementId, [], {
+ ajax: {
+ className: 'wcf\\data\\user\\UserAction',
+ parameters: {
+ data: {
+ includeUserGroups: ~~options.includeUserGroups
+ }
+ }
+ },
+ callbackChange: (typeof options.callbackChange === 'function' ? options.callbackChange : null),
+ excludedSearchValues: (Array.isArray(options.excludedSearchValues) ? options.excludedSearchValues : []),
+ isCSV: true,
+ maxItems: ~~options.maxItems || -1,
+ restricted: true
+ });
+ },
+
+ /**
+ * @see WoltLab/WCF/UI/ItemList::getValues()
+ */
+ getValues: function(elementId) {
+ return UIItemList.getValues(elementId);
+ }
+ };
+
+ return UIItemListUser;
+});
--- /dev/null
+/**
+ * Modifies the interface to provide a better usability for mobile devices.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/Mobile
+ */
+define(
+ [ 'enquire', 'Environment', 'Language', 'DOM/ChangeListener', 'DOM/Traverse', 'UI/CloseOverlay'],
+ function(enquire, Environment, Language, DOMChangeListener, DOMTraverse, UICloseOverlay)
+{
+ "use strict";
+
+ var _buttonGroupNavigations = null;
+ var _enabled = false;
+ var _main = null;
+ var _sidebar = null;
+
+ /**
+ * @exports WoltLab/WCF/UI/Mobile
+ */
+ var UIMobile = {
+ /**
+ * Initializes the mobile UI using enquire.js.
+ */
+ setup: function() {
+ _buttonGroupNavigations = document.getElementsByClassName('buttonGroupNavigation');
+ _main = document.getElementById('main');
+ _sidebar = _main.querySelector('#main > div > div > .sidebar');
+
+ if (Environment.touch()) {
+ document.documentElement.classList.add('touch');
+ }
+
+ if (Environment.platform() !== 'desktop') {
+ document.documentElement.classList.add('mobile');
+ }
+
+ enquire.register('screen and (max-width: 800px)', {
+ match: this.enable.bind(this),
+ unmatch: this.disable.bind(this),
+ setup: this._init.bind(this),
+ deferSetup: true
+ });
+
+ if (Environment.browser() === 'microsoft' && _sidebar !== null && _sidebar.clientWidth > 305) {
+ this._fixSidebarIE();
+ }
+ },
+
+ /**
+ * Enables the mobile UI.
+ */
+ enable: function() {
+ _enabled = true;
+
+ if (Environment.browser() === 'microsoft') this._fixSidebarIE();
+ },
+
+ /**
+ * Disables the mobile UI.
+ */
+ disable: function() {
+ _enabled = false;
+
+ if (Environment.browser() === 'microsoft') this._fixSidebarIE();
+ },
+
+ _fixSidebarIE: function() {
+ if (_sidebar === null) return;
+
+ // sidebar is rarely broken on IE9/IE10
+ _sidebar.style.setProperty('display', 'none');
+ _sidebar.style.removeProperty('display');
+ },
+
+ _init: function() {
+ this._initSidebarToggleButtons();
+ this._initSearchBar();
+ this._initButtonGroupNavigation();
+
+ UICloseOverlay.add('WoltLab/WCF/UI/Mobile', this._closeAllMenus.bind(this));
+ DOMChangeListener.add('WoltLab/WCF/UI/Mobile', this._initButtonGroupNavigation.bind(this));
+ },
+
+ _initSidebarToggleButtons: function() {
+ if (_sidebar === null) return;
+
+ var sidebarPosition = (_main.classList.contains('sidebarOrientationLeft')) ? 'Left' : '';
+ sidebarPosition = (sidebarPosition) ? sidebarPosition : (_main.classList.contains('sidebarOrientationRight') ? 'Right' : '');
+
+ if (!sidebarPosition) {
+ return;
+ }
+
+ // use icons if language item is empty/non-existant
+ var languageShowSidebar = 'wcf.global.sidebar.show' + sidebarPosition + 'Sidebar';
+ if (languageShowSidebar === Language.get(languageShowSidebar) || Language.get(languageShowSidebar) === '') {
+ languageShowSidebar = document.createElement('span');
+ languageShowSidebar.className = 'icon icon16 fa-angle-double-' + sidebarPosition.toLowerCase();
+ }
+
+ var languageHideSidebar = 'wcf.global.sidebar.hide' + sidebarPosition + 'Sidebar';
+ if (languageHideSidebar === Language.get(languageHideSidebar) || Language.get(languageHideSidebar) === '') {
+ languageHideSidebar = document.createElement('span');
+ languageHideSidebar.className = 'icon icon16 fa-angle-double-' + (sidebarPosition === 'Left' ? 'right' : 'left');
+ }
+
+ // add toggle buttons
+ var showSidebar = document.createElement('span');
+ showSidebar.className = 'button small mobileSidebarToggleButton';
+ showSidebar.addEventListener('click', function() { _main.classList.add('mobileShowSidebar'); });
+ if (languageShowSidebar instanceof Element) showSidebar.appendChild(languageShowSidebar);
+ else showSidebar.textContent = languageShowSidebar;
+
+ var hideSidebar = document.createElement('span');
+ hideSidebar.className = 'button small mobileSidebarToggleButton';
+ hideSidebar.addEventListener('click', function() { _main.classList.remove('mobileShowSidebar'); });
+ if (languageHideSidebar instanceof Element) hideSidebar.appendChild(languageHideSidebar);
+ else hideSidebar.textContent = languageHideSidebar;
+
+ document.querySelector('.content').appendChild(showSidebar);
+ _sidebar.appendChild(hideSidebar);
+ },
+
+ _initSearchBar: function() {
+ var _searchBar = document.querySelector('.searchBar');
+
+ _searchBar.addEventListener('click', function() {
+ if (_enabled) {
+ _searchBar.classList.add('searchBarOpen');
+
+ return false;
+ }
+
+ return false;
+ });
+
+ _main.addEventListener('click', function() { _searchBar.classList.remove('searchBarOpen'); });
+ },
+
+ _initButtonGroupNavigation: function() {
+ for (var i = 0, length = _buttonGroupNavigations.length; i < length; i++) {
+ var navigation = _buttonGroupNavigations[i];
+
+ if (navigation.classList.contains('jsMobileButtonGroupNavigation')) continue;
+ else navigation.classList.add('jsMobileButtonGroupNavigation');
+
+ var button = document.createElement('a');
+ button.classList.add('dropdownLabel');
+
+ var span = document.createElement('span');
+ span.className = 'icon icon24 fa-list';
+ button.appendChild(span);
+
+ button.addEventListener('click', function(ev) {
+ var next = DOMTraverse.next(button);
+ if (next !== null) {
+ next.classList.toggle('open');
+
+ ev.stopPropagation();
+ return false;
+ }
+
+ return true;
+ });
+
+ navigation.insertBefore(button, navigation.firstChild);
+ }
+ },
+
+ _closeAllMenus: function() {
+ var openMenus = document.querySelectorAll('.jsMobileButtonGroupNavigation > ul.open');
+ for (var i = 0, length = openMenus.length; i < length; i++) {
+ openMenus[i].classList.remove('open');
+ }
+ }
+ };
+
+ return UIMobile;
+});
--- /dev/null
+/**
+ * Flexible UI element featuring both a list of items and an input field with suggestion support.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/Suggestion
+ */
+define(['Ajax', 'Core', 'UI/SimpleDropdown'], function(Ajax, Core, UISimpleDropdown) {
+ "use strict";
+
+ /**
+ * @constructor
+ * @param {string} elementId input element id
+ * @param {object<mixed>} options option list
+ */
+ function UISuggestion(elementId, options) { this.init(elementId, options); };
+ UISuggestion.prototype = {
+ /**
+ * Initializes a new suggestion input.
+ *
+ * @param {string} element id input element id
+ * @param {object<mixed>} options option list
+ */
+ init: function(elementId, options) {
+ this._dropdownMenu = null;
+ this._value = '';
+
+ this._element = document.getElementById(elementId);
+ if (this._element === null) {
+ throw new Error("Expected a valid element id.");
+ }
+
+ this._options = Core.extend({
+ ajax: {
+ actionName: 'getSearchResultList',
+ className: '',
+ interfaceName: 'wcf\\data\\ISearchAction',
+ parameters: {
+ data: {}
+ }
+ },
+
+ // will be executed once a value from the dropdown has been selected
+ callbackSelect: null,
+ // list of excluded search values
+ excludedSearchValues: [],
+ // minimum number of characters required to trigger a search request
+ treshold: 3
+ }, options);
+
+ if (typeof this._options.callbackSelect !== 'function') {
+ throw new Error("Expected a valid callback for option 'callbackSelect'.");
+ }
+
+ this._element.addEventListener('click', function(event) { event.stopPropagation(); });
+ this._element.addEventListener('keydown', this._keyDown.bind(this));
+ this._element.addEventListener('keyup', this._keyUp.bind(this));
+ },
+
+ /**
+ * Adds an excluded search value.
+ *
+ * @param {string} value excluded value
+ */
+ addExcludedValue: function(value) {
+ if (this._options.excludedSearchValues.indexOf(value) === -1) {
+ this._options.excludedSearchValues.push(value);
+ }
+ },
+
+ /**
+ * Removes an excluded search value.
+ *
+ * @param {string} value excluded value
+ */
+ removeExcludedValue: function(value) {
+ var index = this._options.excludedSearchValues.indexOf(value);
+ if (index !== -1) {
+ this._options.excludedSearchValues.splice(index, 1);
+ }
+ },
+
+ /**
+ * Handles the keyboard navigation for interaction with the suggestion list.
+ *
+ * @param {object} event event object
+ */
+ _keyDown: function(event) {
+ if (this._dropdownMenu === null || !UISimpleDropdown.isOpen(this._element.id)) {
+ return true;
+ }
+
+ if (event.keyCode !== 13 && event.keyCode !== 27 && event.keyCode !== 38 && event.keyCode !== 40) {
+ return true;
+ }
+
+ var active, i = 0, length = this._dropdownMenu.childElementCount;
+ while (i < length) {
+ active = this._dropdownMenu.children[i];
+ if (active.classList.contains('active')) {
+ break;
+ }
+
+ i++;
+ }
+
+ if (event.keyCode === 13) {
+ // Enter
+ UISimpleDropdown.close(this._element.id);
+
+ this._select(active);
+ }
+ else if (event.keyCode === 27) {
+ if (UISimpleDropdown.isOpen(this._element.id)) {
+ UISimpleDropdown.close(this._element.id);
+ }
+ else {
+ // let the event pass through
+ return true;
+ }
+ }
+ else {
+ var index = 0;
+
+ if (event.keyCode === 38) {
+ // ArrowUp
+ index = ((i === 0) ? length : i) - 1;
+ }
+ else if (event.keyCode === 40) {
+ // ArrowDown
+ index = i + 1;
+ if (index === length) index = 0;
+ }
+
+ if (index !== i) {
+ active.classList.remove('active');
+ this._dropdownMenu.children[index].classList.add('active');
+ }
+ }
+
+ event.preventDefault();
+ return false;
+ },
+
+ /**
+ * Selects an item from the list.
+ *
+ * @param {(Element|Event)} item list item or event object
+ */
+ _select: function(item) {
+ var isEvent = (item instanceof Event);
+ if (isEvent) {
+ item = item.currentTarget.parentNode;
+ }
+
+ this._options.callbackSelect(this._element.id, { objectId: item.children[0].getAttribute('data-object-id'), value: item.textContent });
+
+ if (isEvent) {
+ this._element.focus();
+ }
+ },
+
+ /**
+ * Performs a search for the input value unless it is below the treshold.
+ *
+ * @param {object} event event object
+ */
+ _keyUp: function(event) {
+ var value = event.currentTarget.value.trim();
+
+ if (this._value === value) {
+ return;
+ }
+ else if (value.length < this._options.treshold) {
+ if (this._dropdownMenu !== null) {
+ UISimpleDropdown.close(this._element.id);
+ }
+
+ this._value = value;
+
+ return;
+ }
+
+ this._value = value;
+
+ Ajax.api(this, {
+ parameters: {
+ data: {
+ excludedSearchValues: this._options.excludedSearchValues,
+ searchString: value
+ }
+ }
+ });
+ },
+
+ _ajaxSetup: function() {
+ return {
+ data: this._options.ajax
+ };
+ },
+
+ /**
+ * Handles successful Ajax requests.
+ *
+ * @param {object} data response values
+ */
+ _ajaxSuccess: function(data) {
+ if (this._dropdownMenu === null) {
+ this._dropdownMenu = document.createElement('div');
+ this._dropdownMenu.className = 'dropdownMenu';
+
+ UISimpleDropdown.initFragment(this._element, this._dropdownMenu);
+ }
+ else {
+ this._dropdownMenu.innerHTML = '';
+ }
+
+ if (data.returnValues.length) {
+ var anchor, item, listItem;
+ for (var i = 0, length = data.returnValues.length; i < length; i++) {
+ item = data.returnValues[i];
+
+ anchor = document.createElement('a');
+ anchor.textContent = item.label;
+ anchor.setAttribute('data-object-id', item.objectID);
+ anchor.addEventListener('click', this._select.bind(this));
+
+ listItem = document.createElement('li');
+ if (i === 0) listItem.className = 'active';
+ listItem.appendChild(anchor);
+
+ this._dropdownMenu.appendChild(listItem);
+ }
+
+ UISimpleDropdown.open(this._element.id);
+ }
+ else {
+ UISimpleDropdown.close(this._element.id);
+ }
+ }
+ };
+
+ return UISuggestion;
+});
--- /dev/null
+/**
+ * Common interface for tab menu access.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/TabMenu
+ */
+define(['Dictionary', 'DOM/ChangeListener', 'DOM/Util', './TabMenu/Simple'], function(Dictionary, DOMChangeListener, DOMUtil, SimpleTabMenu) {
+ "use strict";
+
+ var _tabMenus = new Dictionary();
+
+ /**
+ * @exports WoltLab/WCF/UI/TabMenu
+ */
+ var UITabMenu = {
+ /**
+ * Sets up tab menus and binds listeners.
+ */
+ setup: function() {
+ this._init();
+ this._selectErroneousTabs();
+
+ DOMChangeListener.add('WoltLab/WCF/UI/TabMenu', this._init.bind(this));
+ },
+
+ /**
+ * Initializes available tab menus.
+ */
+ _init: function() {
+ var tabMenus = document.querySelectorAll('.tabMenuContainer:not(.staticTabMenuContainer)');
+ for (var i = 0, length = tabMenus.length; i < length; i++) {
+ var container = tabMenus[i];
+ var containerId = DOMUtil.identify(container);
+
+ if (_tabMenus.has(containerId)) {
+ continue;
+ }
+
+ var tabMenu = new SimpleTabMenu(containerId, container);
+ if (tabMenu.validate()) {
+ tabMenu.init();
+
+ _tabMenus.set(containerId, tabMenu);
+ }
+ }
+ },
+
+ /**
+ * Selects the first tab containing an element with class `formError`.
+ */
+ _selectErroneousTabs: function() {
+ _tabMenus.forEach(function(tabMenu) {
+ var foundError = false;
+ tabMenu.getContainers().forEach(function(container) {
+ if (!foundError && container.getElementsByClassName('formError').length) {
+ foundError = true;
+
+ tabMenu.select(container.id);
+ }
+ });
+ });
+ },
+
+ /**
+ * Returns a SimpleTabMenu instance for given container id.
+ *
+ * @param {string} containerId tab menu container id
+ * @return {(SimpleTabMenu|undefined)} tab menu object
+ */
+ getTabMenu: function(containerId) {
+ return _tabMenus.get(containerId);
+ }
+ };
+
+ return UITabMenu;
+});
--- /dev/null
+/**
+ * Simple tab menu implementation with a straight-forward logic.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/TabMenu/Simple
+ */
+define(['Dictionary', 'DOM/Traverse', 'DOM/Util', 'EventHandler'], function(Dictionary, DOMTraverse, DOMUtil, EventHandler) {
+ "use strict";
+
+ /**
+ * @param {string} containerId container id
+ * @param {Element} container container element
+ * @constructor
+ */
+ function TabMenuSimple(containerId, container) {
+ this._container = container;
+ this._containers = new Dictionary();
+ this._containerId = containerId;
+ this._isLegacy = null;
+ this._isParent = false;
+ this._parent = null;
+ this._tabs = new Dictionary();
+ };
+
+ TabMenuSimple.prototype = {
+ /**
+ * Validates the properties and DOM structure of this container.
+ *
+ * Expected DOM:
+ * <div class="tabMenuContainer">
+ * <nav>
+ * <ul>
+ * <li data-name="foo"><a>bar</a></li>
+ * </ul>
+ * </nav>
+ *
+ * <div id="foo">baz</div>
+ * </div>
+ *
+ * @return {boolean} false if any properties are invalid or the DOM does not match the expectations
+ */
+ validate: function() {
+ if (!this._container.classList.contains('tabMenuContainer')) {
+ return false;
+ }
+
+ var nav = DOMTraverse.childByTag(this._container, 'NAV');
+ if (nav === null) {
+ return false;
+ }
+
+ // get children
+ var tabs = nav.getElementsByTagName('li');
+ if (tabs.length === null) {
+ return false;
+ }
+
+ var containers = DOMTraverse.childrenByTag(this._container, 'DIV');
+ for (var i = 0, length = containers.length; i < length; i++) {
+ var container = containers[i];
+ var name = container.getAttribute('data-name');
+
+ if (!name) {
+ name = DOMUtil.identify(container);
+ }
+
+ container.setAttribute('data-name', name);
+ this._containers.set(name, container);
+ }
+
+ for (var i = 0, length = tabs.length; i < length; i++) {
+ var tab = tabs[i];
+ var name = this._getTabName(tab);
+
+ if (!name) {
+ continue;
+ }
+
+ if (this._tabs.has(name)) {
+ throw new Error("Tab names must be unique, li[data-name='" + name + "'] (tab menu id: '" + this._containerId + "') exists more than once.");
+ }
+
+ var container = this._containers.get(name);
+ if (container === undefined) {
+ throw new Error("Expected content element for li[data-name='" + name + "'] (tab menu id: '" + this._containerId + "').");
+ }
+ else if (container.parentNode !== this._container) {
+ throw new Error("Expected content element '" + name + "' (tab menu id: '" + this._containerId + "') to be a direct children.");
+ }
+
+ // check if tab holds exactly one children which is an anchor element
+ if (tab.childElementCount !== 1 || tab.children[0].nodeName !== 'A') {
+ throw new Error("Expected exactly one <a> as children for li[data-name='" + name + "'] (tab menu id: '" + this._containerId + "').");
+ }
+
+ this._tabs.set(name, tab);
+ }
+
+ if (!this._tabs.size) {
+ throw new Error("Expected at least one tab (tab menu id: '" + this._containerId + "').");
+ }
+
+ if (this._isLegacy) {
+ this._container.setAttribute('data-is-legacy', true);
+
+ this._tabs.forEach(function(tab, name) {
+ tab.setAttribute('aria-controls', name);
+ });
+ }
+
+ return true;
+ },
+
+ /**
+ * Initializes this tab menu.
+ *
+ * @param {Dictionary=} oldTabs previous list of tabs
+ */
+ init: function(oldTabs) {
+ oldTabs = oldTabs || null;
+
+ // bind listeners
+ this._tabs.forEach((function(tab) {
+ if (oldTabs === null || oldTabs.get(tab.getAttribute('data-name')) !== tab) {
+ tab.children[0].addEventListener('click', this._onClick.bind(this));
+ }
+ }).bind(this));
+
+ if (oldTabs === null) {
+ var preselect = this._container.getAttribute('data-preselect');
+ if (preselect === "true" || preselect === null || preselect === "") preselect = true;
+ if (preselect === "false") preselect = false;
+
+ this._containers.forEach(function(container) {
+ container.classList.add('hidden');
+ });
+
+ if (preselect !== false) {
+ if (preselect !== true) {
+ var tab = this._tabs.get(preselect);
+ if (tab !== undefined) {
+ this.select(null, tab, true);
+ }
+ }
+ else {
+ var selectTab = null;
+ this._tabs.forEach(function(tab) {
+ if (selectTab === null && tab.previousElementSibling === null) {
+ selectTab = tab;
+ }
+ });
+
+ if (selectTab !== null) {
+ this.select(null, selectTab, true);
+ }
+ }
+ }
+ }
+ },
+
+ /**
+ * Selects a tab.
+ *
+ * @param {?(string|integer)} name tab name or sequence no
+ * @param {Element=} tab tab element
+ * @param {boolean=} disableEvent suppress event handling
+ */
+ select: function(name, tab, disableEvent) {
+ tab = tab || this._tabs.get(name) || null;
+
+ if (tab === null) {
+ // check if name is an integer
+ if (~~name == name) {
+ name = ~~name;
+
+ var i = 0;
+ this._tabs.forEach(function(item) {
+ if (i === name) {
+ tab = item;
+ }
+
+ i++;
+ });
+ }
+
+ if (tab === null) {
+ throw new Error("Expected a valid tab name, '" + name + "' given (tab menu id: '" + this._containerId + "').");
+ }
+ }
+
+ if (!name) name = tab.getAttribute('data-name');
+
+ // unmark active tab
+ var oldTab = document.querySelector('#' + this._containerId + ' > nav > ul > li.active');
+ var oldContent = null;
+ if (oldTab !== null) {
+ oldTab.classList.remove('active');
+ oldContent = this._containers.get(oldTab.getAttribute('data-name'));
+ oldContent.classList.remove('active');
+ oldContent.classList.add('hidden');
+
+ if (this._isLegacy) {
+ oldTab.classList.remove('ui-state-active');
+ oldContent.classList.remove('ui-state-active');
+ }
+ }
+
+ tab.classList.add('active');
+ var newContent = this._containers.get(name);
+ newContent.classList.add('active');
+
+ if (this._isLegacy) {
+ tab.classList.add('ui-state-active');
+ newContent.classList.add('ui-state-active');
+ newContent.classList.remove('hidden');
+ }
+
+ if (disableEvent !== true) {
+ EventHandler.fire('com.woltlab.wcf.simpleTabMenu_' + this._containerId, 'select', {
+ active: tab,
+ activeName: name,
+ previous: oldTab,
+ previousName: oldTab.getAttribute('data-name')
+ });
+
+ if (this._isLegacy && typeof window.jQuery === 'function') {
+ // simulate jQuery UI Tabs event
+ window.jQuery(this._container).trigger('wcftabsbeforeactivate', {
+ newTab: window.jQuery(tab),
+ oldTab: window.jQuery(oldTab),
+ newPanel: window.jQuery(newContent),
+ oldPanel: window.jQuery(oldContent)
+ });
+ }
+ }
+ },
+
+ /**
+ * Rebuilds all tabs, must be invoked after adding or removing of tabs.
+ *
+ * Warning: Do not remove tabs if you plan to add these later again or at least clone the nodes
+ * to prevent issues with already bound event listeners. Consider hiding them via CSS.
+ */
+ rebuild: function() {
+ var oldTabs = this._tabs;
+
+ this.validate();
+ this.init(oldTabs);
+ },
+
+ /**
+ * Handles clicks on a tab.
+ *
+ * @param {object} event event object
+ */
+ _onClick: function(event) {
+ event.preventDefault();
+
+ var tab = event.currentTarget.parentNode;
+
+ this.select(null, tab);
+ },
+
+ /**
+ * Returns the tab name.
+ *
+ * @param {Element} tab tab element
+ * @return {string} tab name
+ */
+ _getTabName: function(tab) {
+ var name = tab.getAttribute('data-name');
+
+ // handle legacy tab menus
+ if (!name) {
+ if (tab.childElementCount === 1 && tab.children[0].nodeName === 'A') {
+ var href = tab.children[0].getAttribute('href');
+ if (href.match(/#([^#]+)$/)) {
+ name = RegExp.$1;
+
+ if (document.getElementById(name) === null) {
+ name = null;
+ }
+ else {
+ this._isLegacy = true;
+ tab.setAttribute('data-name', name);
+ }
+ }
+ }
+ }
+
+ return name;
+ },
+
+ /**
+ * Returns the list of registered content containers.
+ *
+ * @returns {Dictionary} content containers
+ */
+ getContainers: function() {
+ return this._containers;
+ },
+
+ /**
+ * Returns the list of registered tabs.
+ *
+ * @returns {Dictionary} tab items
+ */
+ getTabs: function() {
+ return this._tabs;
+ }
+ };
+
+ return TabMenuSimple;
+});
--- /dev/null
+/**
+ * Provides enhanced tooltips.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLab/WCF/UI/Tooltip
+ */
+define(['Environment', 'DOM/ChangeListener', 'UI/Alignment'], function(Environment, DOMChangeListener, UIAlignment) {
+ "use strict";
+
+ var _elements = null;
+ var _pointer = null;
+ var _text = null;
+ var _tooltip = null;
+
+ /**
+ * @exports WoltLab/WCF/UI/Tooltip
+ */
+ var UITooltip = {
+ /**
+ * Initializes the tooltip element and binds event listener.
+ */
+ setup: function() {
+ if (Environment.platform() !== 'desktop') return;
+
+ _tooltip = document.createElement('div');
+ _tooltip.setAttribute('id', 'balloonTooltip');
+ _tooltip.classList.add('balloonTooltip');
+
+ _text = document.createElement('span');
+ _text.setAttribute('id', 'balloonTooltipText');
+ _tooltip.appendChild(_text);
+
+ _pointer = document.createElement('span');
+ _pointer.classList.add('elementPointer');
+ _pointer.appendChild(document.createElement('span'));
+ _tooltip.appendChild(_pointer);
+
+ document.body.appendChild(_tooltip);
+
+ _elements = document.getElementsByClassName('jsTooltip');
+
+ this.init();
+
+ DOMChangeListener.add('WoltLab/WCF/UI/Tooltip', this.init.bind(this));
+ },
+
+ /**
+ * Initializes tooltip elements.
+ */
+ init: function() {
+ while (_elements.length) {
+ var element = _elements[0];
+ element.classList.remove('jsTooltip');
+
+ var title = element.getAttribute('title');
+ title = (typeof title === 'string') ? title.trim() : '';
+
+ if (title.length) {
+ element.setAttribute('data-tooltip', title);
+ element.removeAttribute('title');
+
+ element.addEventListener('mouseenter', this._mouseEnter.bind(this));
+ element.addEventListener('mouseleave', this._mouseLeave.bind(this));
+ element.addEventListener('click', this._mouseLeave.bind(this));
+ }
+ }
+ },
+
+ /**
+ * Displays the tooltip on mouse enter.
+ *
+ * @param {object} event event object
+ */
+ _mouseEnter: function(event) {
+ var element = event.currentTarget;
+ var title = element.getAttribute('title');
+ title = (typeof title === 'string') ? title.trim() : '';
+
+ if (title !== '') {
+ element.setAttribute('data-tooltip', title);
+ element.removeAttribute('title');
+ }
+
+ title = element.getAttribute('data-tooltip');
+
+ // reset tooltip position
+ _tooltip.style.removeProperty('top');
+ _tooltip.style.removeProperty('left');
+
+ // ignore empty tooltip
+ if (!title.length) {
+ _tooltip.classList.remove('active');
+ return;
+ }
+ else {
+ _tooltip.classList.add('active');
+ }
+
+ _text.textContent = title;
+
+ UIAlignment.set(_tooltip, element, {
+ horizontal: 'center',
+ pointer: true,
+ pointerClassNames: ['inverse'],
+ vertical: 'top'
+ });
+ },
+
+ /**
+ * Hides the tooltip once the mouse leaves the element.
+ *
+ * @param {object} event event object
+ */
+ _mouseLeave: function(event) {
+ _tooltip.classList.remove('active');
+ }
+ };
+
+ return UITooltip;
+});