2018-08-16 22:30:59 +00:00
|
|
|
/*
|
2022-08-09 21:30:32 +00:00
|
|
|
* Copyright (c) 2010-2022 Tim Düsterhus.
|
2018-08-16 22:30:59 +00:00
|
|
|
*
|
|
|
|
* Use of this software is governed by the Business Source License
|
|
|
|
* included in the LICENSE file.
|
|
|
|
*
|
2022-08-09 21:30:32 +00:00
|
|
|
* Change Date: 2026-08-10
|
2018-08-16 22:30:59 +00:00
|
|
|
*
|
|
|
|
* On the date above, in accordance with the Business Source
|
|
|
|
* License, use of this software will be governed by version 2
|
|
|
|
* or later of the General Public License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
define(['WoltLabSuite/Core/Date/Util', 'WoltLabSuite/Core/Language'], function (
|
|
|
|
DateUtil,
|
|
|
|
Language
|
|
|
|
) {
|
|
|
|
'use strict'
|
|
|
|
|
|
|
|
class Helper {
|
|
|
|
static deepFreeze(obj) {
|
|
|
|
const propNames = Object.getOwnPropertyNames(obj)
|
|
|
|
|
|
|
|
propNames.forEach(function (name) {
|
|
|
|
let prop = obj[name]
|
|
|
|
|
|
|
|
if (typeof prop === 'object' && prop !== null) Helper.deepFreeze(prop)
|
|
|
|
})
|
|
|
|
|
|
|
|
return Object.freeze(obj)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns true if the given element is an input[type=text],
|
|
|
|
* input[type=password] or textarea.
|
|
|
|
*
|
|
|
|
* @param {Node} element
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
static isInput(element) {
|
|
|
|
if (element.tagName === 'INPUT') {
|
|
|
|
if (
|
|
|
|
element.getAttribute('type') !== 'text' &&
|
|
|
|
element.getAttribute('type') !== 'password'
|
|
|
|
) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
} else if (element.tagName !== 'TEXTAREA') {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
static throttle(fn, threshold = 250, scope) {
|
|
|
|
let last = 0
|
|
|
|
let deferTimer = null
|
|
|
|
|
|
|
|
return function () {
|
|
|
|
const now = new Date().getTime()
|
|
|
|
const args = arguments
|
|
|
|
const context = scope || this
|
|
|
|
|
|
|
|
if (last && now < last + threshold) {
|
|
|
|
clearTimeout(deferTimer)
|
|
|
|
|
|
|
|
return (deferTimer = setTimeout(function () {
|
|
|
|
last = now
|
|
|
|
|
|
|
|
return fn.apply(context, args)
|
|
|
|
}, threshold))
|
|
|
|
} else {
|
|
|
|
last = now
|
|
|
|
|
|
|
|
return fn.apply(context, args)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the caret position of the given element. If the element
|
|
|
|
* is not an input or textarea element -1 is returned.
|
|
|
|
*
|
|
|
|
* @param {Node} element
|
|
|
|
* @returns {number}
|
|
|
|
*/
|
|
|
|
static getCaret(element) {
|
|
|
|
if (!Helper.isInput(element)) throw new Error('Unsupported element')
|
|
|
|
|
|
|
|
let position = 0
|
|
|
|
|
|
|
|
if (element.selectionStart) {
|
|
|
|
position = element.selectionStart
|
|
|
|
}
|
|
|
|
|
|
|
|
return position
|
|
|
|
}
|
|
|
|
|
|
|
|
static setCaret(element, position) {
|
|
|
|
if (!Helper.isInput(element)) throw new Error('Unsupported element')
|
|
|
|
|
|
|
|
if (element.selectionStart) {
|
|
|
|
element.focus()
|
|
|
|
element.setSelectionRange(position, position)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static wrapElement(element, wrapper) {
|
|
|
|
wrapper = wrapper || document.createElement('div')
|
|
|
|
|
|
|
|
if (element.nextSibling) {
|
|
|
|
element.parentNode.insertBefore(wrapper, element.nextSibling)
|
|
|
|
} else {
|
|
|
|
element.parentNode.appendChild(wrapper)
|
|
|
|
}
|
|
|
|
|
|
|
|
return wrapper.appendChild(element)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Based on https://github.com/alexdunphy/flexText
|
|
|
|
static makeFlexible(textarea) {
|
|
|
|
if (textarea.tagName !== 'TEXTAREA') {
|
|
|
|
throw new Error(`Unsupported element type: ${textarea.tagName}`)
|
|
|
|
}
|
|
|
|
|
|
|
|
const pre = document.createElement('pre')
|
|
|
|
const span = document.createElement('span')
|
|
|
|
|
|
|
|
const mirror = function () {
|
|
|
|
span.textContent = textarea.value
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!textarea.parentNode.classList.contains('flexibleTextarea')) {
|
|
|
|
Helper.wrapElement(textarea)
|
|
|
|
textarea.parentNode.classList.add('flexibleTextarea')
|
|
|
|
}
|
|
|
|
|
2022-08-10 17:47:01 +00:00
|
|
|
textarea.removeAttribute('rows')
|
2018-08-16 22:30:59 +00:00
|
|
|
textarea.classList.add('flexibleTextareaContent')
|
|
|
|
pre.classList.add('flexibleTextareaMirror')
|
|
|
|
|
|
|
|
pre.appendChild(span)
|
|
|
|
pre.appendChild(document.createElement('br'))
|
|
|
|
textarea.parentNode.insertBefore(pre, textarea)
|
|
|
|
|
|
|
|
textarea.addEventListener('input', mirror)
|
|
|
|
mirror()
|
|
|
|
}
|
|
|
|
|
|
|
|
static getCircularArray(size) {
|
|
|
|
class CircularArray extends Array {
|
|
|
|
constructor(size) {
|
|
|
|
super()
|
|
|
|
|
|
|
|
Object.defineProperty(this, 'size', {
|
|
|
|
enumerable: false,
|
|
|
|
value: size,
|
|
|
|
writable: false,
|
|
|
|
configurable: false,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
push() {
|
|
|
|
super.push.apply(this, arguments)
|
|
|
|
|
|
|
|
if (this.length > this.size) {
|
|
|
|
super.shift()
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.length
|
|
|
|
}
|
|
|
|
|
|
|
|
unshift() {
|
|
|
|
super.unshift.apply(this, arguments)
|
|
|
|
|
|
|
|
if (this.length > this.size) {
|
|
|
|
super.pop()
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.length
|
|
|
|
}
|
|
|
|
|
|
|
|
first() {
|
|
|
|
return this[0]
|
|
|
|
}
|
|
|
|
|
|
|
|
last() {
|
|
|
|
return this[this.length - 1]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return new CircularArray(size)
|
|
|
|
}
|
|
|
|
|
|
|
|
static intToRGBHex(integer) {
|
|
|
|
const r = ((integer >> 16) & 0xff).toString(16)
|
|
|
|
const g = ((integer >> 8) & 0xff).toString(16)
|
|
|
|
const b = ((integer >> 0) & 0xff).toString(16)
|
|
|
|
|
|
|
|
const rr = r.length == 1 ? `0${r}` : r
|
|
|
|
const gg = g.length == 1 ? `0${g}` : g
|
|
|
|
const bb = b.length == 1 ? `0${b}` : b
|
|
|
|
|
|
|
|
return `#${rr}${gg}${bb}`
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the markup of a `time` element based on the given date just like a `time`
|
|
|
|
* element created by `wcf\system\template\plugin\TimeModifierTemplatePlugin`.
|
|
|
|
*
|
|
|
|
* @param {Date} date displayed date
|
|
|
|
* @returns {string} `time` element
|
|
|
|
*/
|
|
|
|
static getTimeElementHTML(date) {
|
|
|
|
const isFutureDate = date.getTime() > new Date().getTime()
|
|
|
|
let dateTime = ''
|
|
|
|
|
|
|
|
if (isFutureDate) {
|
|
|
|
dateTime = DateUtil.formatDateTime(date)
|
|
|
|
}
|
|
|
|
|
|
|
|
// WSC 3.1
|
|
|
|
if (typeof DateUtil.getTimeElement === 'function') {
|
|
|
|
const elem = DateUtil.getTimeElement(date)
|
|
|
|
|
|
|
|
// Work around a bug in DateUtil paired with Time/Relative
|
|
|
|
if (isFutureDate) elem.innerText = dateTime
|
|
|
|
|
|
|
|
return elem.outerHTML
|
|
|
|
}
|
|
|
|
|
|
|
|
return `<time class="datetime"
|
|
|
|
datetime="${DateUtil.format(date, 'c')}"
|
|
|
|
data-date="${DateUtil.formatDate(date)}"
|
|
|
|
data-time="${DateUtil.formatTime(date)}"
|
|
|
|
data-offset="${date.getTimezoneOffset() * 60}"
|
|
|
|
data-timestamp="${(date.getTime() - date.getMilliseconds()) / 1000}"
|
|
|
|
${isFutureDate ? 'data-is-future-date="true"' : ''}
|
|
|
|
>${dateTime}</time>`
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns whether the supplied selection range covers the whole text inside the given node
|
|
|
|
*
|
|
|
|
* Source: https://stackoverflow.com/a/27686686/1112384
|
|
|
|
*
|
|
|
|
* @param {Range} range Selection range
|
|
|
|
* @param {Node}
|
|
|
|
* @return {Boolean}
|
|
|
|
*/
|
|
|
|
static rangeSpansTextContent(range, node) {
|
|
|
|
const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT)
|
|
|
|
|
|
|
|
let firstTextNode, lastTextNode
|
|
|
|
while (treeWalker.nextNode()) {
|
|
|
|
if (treeWalker.currentNode.nodeValue.trim() === '') continue
|
|
|
|
|
|
|
|
if (!firstTextNode) {
|
|
|
|
firstTextNode = treeWalker.currentNode
|
|
|
|
}
|
|
|
|
|
|
|
|
lastTextNode = treeWalker.currentNode
|
|
|
|
}
|
|
|
|
|
|
|
|
const nodeRange = range.cloneRange()
|
|
|
|
if (firstTextNode) {
|
|
|
|
nodeRange.setStart(firstTextNode, 0)
|
|
|
|
nodeRange.setEnd(lastTextNode, lastTextNode.length)
|
|
|
|
} else {
|
|
|
|
nodeRange.selectNodeContents(node)
|
|
|
|
}
|
|
|
|
|
|
|
|
const bp1 = range.compareBoundaryPoints(Range.START_TO_START, nodeRange)
|
|
|
|
const bp2 = range.compareBoundaryPoints(Range.END_TO_END, nodeRange)
|
|
|
|
|
|
|
|
return bp1 < 1 && bp2 > -1
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the text of a node and its children.
|
|
|
|
*
|
|
|
|
* @see {@link https://github.com/WoltLab/WCF/blob/a20be4267fc711299d6bde7c34a8b36199ae393f/wcfsetup/install/files/js/WCF.Message.js#L1180-L1264}
|
|
|
|
* @param {Node} node
|
|
|
|
* @return {String}
|
|
|
|
*/
|
|
|
|
static getTextContent(node) {
|
|
|
|
const acceptNode = (node) => {
|
|
|
|
if (node instanceof Element) {
|
|
|
|
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE')
|
|
|
|
return NodeFilter.FILTER_REJECT
|
|
|
|
}
|
|
|
|
|
|
|
|
return NodeFilter.FILTER_ACCEPT
|
|
|
|
}
|
|
|
|
|
|
|
|
let out = ''
|
|
|
|
|
|
|
|
const flags = NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT
|
|
|
|
const treeWalker = document.createTreeWalker(node, flags, { acceptNode })
|
|
|
|
|
|
|
|
const ignoredLinks = []
|
|
|
|
|
|
|
|
while (treeWalker.nextNode()) {
|
|
|
|
const node = treeWalker.currentNode
|
|
|
|
|
|
|
|
if (node instanceof Text) {
|
|
|
|
if (
|
|
|
|
node.parentNode.tagName === 'A' &&
|
|
|
|
ignoredLinks.indexOf(node.parentNode) >= 0
|
|
|
|
) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
out += node.nodeValue.replace(/\n/g, '')
|
|
|
|
} else {
|
|
|
|
switch (node.tagName) {
|
|
|
|
case 'IMG': {
|
|
|
|
const alt = node.getAttribute('alt')
|
|
|
|
|
|
|
|
if (node.classList.contains('smiley')) {
|
|
|
|
out += ` ${alt} `
|
|
|
|
} else if (alt && alt !== '') {
|
|
|
|
out += ` ${alt} [Image ${node.src}] `
|
|
|
|
} else {
|
|
|
|
out += ` [Image ${node.src}] `
|
|
|
|
}
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
|
|
|
case 'BR':
|
|
|
|
case 'LI':
|
|
|
|
case 'UL':
|
|
|
|
case 'DIV':
|
|
|
|
case 'TR':
|
|
|
|
out += '\n'
|
|
|
|
break
|
|
|
|
|
|
|
|
case 'TH':
|
|
|
|
case 'TD':
|
|
|
|
out += '\t'
|
|
|
|
break
|
|
|
|
|
|
|
|
case 'P':
|
|
|
|
out += '\n\n'
|
|
|
|
break
|
|
|
|
|
|
|
|
case 'A': {
|
|
|
|
let link = node.href
|
|
|
|
const text = node.textContent.trim()
|
|
|
|
|
|
|
|
// handle named anchors
|
|
|
|
if (text !== '' && text !== node.href) {
|
|
|
|
ignoredLinks.push(node)
|
|
|
|
|
|
|
|
let truncated = false
|
|
|
|
|
|
|
|
if (text.indexOf('\u2026') >= 0) {
|
|
|
|
const parts = text.split(/\u2026/)
|
|
|
|
|
|
|
|
if (parts.length === 2) {
|
|
|
|
truncated =
|
|
|
|
node.href.startsWith(parts[0]) &&
|
|
|
|
node.href.endsWith(parts[1])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!truncated) {
|
|
|
|
link = `${node.textContent} [URL:${node.href}]`
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
out += link
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return out
|
|
|
|
}
|
2022-08-09 21:30:32 +00:00
|
|
|
|
|
|
|
static getElementSiblings(element) {
|
|
|
|
if (!element || !element.parentNode) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
const children = [ ...element.parentNode.children ]
|
|
|
|
const index = children.indexOf(element);
|
|
|
|
|
|
|
|
return [
|
|
|
|
...children.slice(0, index),
|
|
|
|
...children.slice(index + 1)
|
|
|
|
]
|
|
|
|
}
|
2018-08-16 22:30:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return Helper
|
|
|
|
})
|