From 73b33024e35de8ced964aa83b69694dd66ab9bc7 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sat, 7 Dec 2024 21:59:39 -0700 Subject: [PATCH] rewrite atwho in plain JS --- Sources/Actions/Display.php | 5 +- Sources/Actions/Post.php | 9 +- Themes/default/scripts/atwho.js | 1088 ++++++++++++++++++++ Themes/default/scripts/caret.js | 235 +++++ Themes/default/scripts/jquery.atwho.min.js | 1 - Themes/default/scripts/jquery.caret.min.js | 2 - Themes/default/scripts/mentions.js | 107 +- 7 files changed, 1398 insertions(+), 49 deletions(-) create mode 100644 Themes/default/scripts/atwho.js create mode 100644 Themes/default/scripts/caret.js delete mode 100644 Themes/default/scripts/jquery.atwho.min.js delete mode 100644 Themes/default/scripts/jquery.caret.min.js diff --git a/Sources/Actions/Display.php b/Sources/Actions/Display.php index 5c305efdbe..55e959f619 100644 --- a/Sources/Actions/Display.php +++ b/Sources/Actions/Display.php @@ -1009,8 +1009,9 @@ protected function setupTemplate(): void // Mentions if (!empty(Config::$modSettings['enable_mentions']) && User::$me->allowedTo('mention')) { - Theme::loadJavaScriptFile('jquery.atwho.min.js', ['defer' => true], 'smf_atwho'); - Theme::loadJavaScriptFile('jquery.caret.min.js', ['defer' => true], 'smf_caret'); + Theme::loadJavaScriptFile('caret.js', ['defer' => true, 'minimize' => true], 'smf_caret'); + Theme::loadCSSFile('atwho.css', ['minimize' => true], 'smf_atwho'); + Theme::loadJavaScriptFile('atwho.js', ['defer' => true, 'minimize' => true], 'smf_atwho'); Theme::loadJavaScriptFile('mentions.js', ['defer' => true, 'minimize' => true], 'smf_mentions'); } diff --git a/Sources/Actions/Post.php b/Sources/Actions/Post.php index 6d018cfb1c..e2b0a9df67 100644 --- a/Sources/Actions/Post.php +++ b/Sources/Actions/Post.php @@ -347,9 +347,12 @@ public function show(): void // Mentions if (!empty(Config::$modSettings['enable_mentions']) && User::$me->allowedTo('mention')) { - Theme::loadJavaScriptFile('jquery.caret.min.js', ['defer' => true], 'smf_caret'); - Theme::loadJavaScriptFile('jquery.atwho.min.js', ['defer' => true], 'smf_atwho'); - Theme::loadJavaScriptFile('mentions.js', ['defer' => true, 'minimize' => true], 'smf_mentions'); + Theme::loadCSSFile('atwho.css', ['minimize' => true], 'smf_atwho'); + Theme::loadJavaScriptFile('atwho.js', ['demfer' => true, 'minimize' => true], 'smf_atwho'); + Theme::loadJavaScriptFile('caret.js', ['dmefer' => true, 'minimize' => true], 'smf_caret'); + //~ Theme::loadJavaScriptFile('jquery.atwho.min.js', ['defer' => true], 'smf_atwho'); + //~ Theme::loadJavaScriptFile('jquery.caret.min.js', ['defer' => true], 'smf_caret'); + Theme::loadJavaScriptFile('mentions.js', ['mdefer' => true, 'minimize' => true], 'smf_mentions'); } // Load the drafts.js file diff --git a/Themes/default/scripts/atwho.js b/Themes/default/scripts/atwho.js new file mode 100644 index 0000000000..08c9acb38e --- /dev/null +++ b/Themes/default/scripts/atwho.js @@ -0,0 +1,1088 @@ +((root, factory) => { + if (typeof define === 'function' && define.amd) { + define([], () => (root.returnExportsGlobal = factory())); + } else if (typeof exports === 'object') { + module.exports = factory(); + } else { + root.atwho = factory(); + } +})(this, () => { + "use strict"; + + const KEY_CODE = { + ESC: 27, + TAB: 9, + ENTER: 13, + CTRL: 17, + A: 65, + P: 80, + N: 78, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + BACKSPACE: 8, + SPACE: 32 + }; + /** + * Default callback functions used for various operations. + */ + const DEFAULT_CALLBACKS = { + /** + * Processes data before saving. + * + * @function beforeSave + * @param {Array} data - The data to be saved. + * @returns {Object} Processed data in hash format. + */ + beforeSave: (data) => Controller.arrayToDefaultHash(data), + + /** + * Matches the given subtext against a regex pattern. + * + * @function matcher + * @param {string} flag - The flag to create a regex pattern. + * @param {string} subtext - The text to search within. + * @param {boolean} shouldStartWithSpace - Whether the flag should be matched with a leading space. + * @param {boolean} acceptSpaceBar - Whether the space bar is accepted in the match. + * @returns {string|null} Matched text or null if no match is found. + */ + matcher: (flag, subtext, shouldStartWithSpace, acceptSpaceBar) => { + // Escape any special regex characters + const escapedFlag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); + + const startPattern = shouldStartWithSpace ? '(?:^|\\s)' : ""; + const spaceChar = acceptSpaceBar ? "20" : "1F"; + + const regex = new RegExp( + `${startPattern}${escapedFlag}([^\\x00-\\x${spaceChar}\\x80-\\x9F]+)$`, + 'gi' + ); + + const match = regex.exec(subtext); + return match && match[1] !== ' ' ? match[1] : null; + }, + + /** + * Filters an array of data based on a search query. + * + * @function filter + * @param {string} query - The search query. + * @param {Array} items - The array of items to be filtered. + * @param {string} searchKey - The key used to search within each data object. + * @returns {Array} Filtered array of data that matches the query. + */ + filter: (query, items, searchKey) => { + if (!query || !items.length) { + return items; + } + + // Do it the old fashioned way to minimize expensive memory alloocations. + const lowerQuery = query.toLowerCase(); + const filteredItems = new Array(items.length); + for (let i = 0, len = items.length; i < len; i++) { + if (items[i][searchKey].toLowerCase().indexOf(lowerQuery) > -1) { + filteredItems[i] = items[i]; + } + } + + // Remove holes + return filteredItems.filter(() => true); + }, + + /** + * Function to apply remote filtering (not implemented). + * + * @function remoteFilter + */ + remoteFilter: null, + + /** + * Sorts an array of items based on a search query. + * + * @function sorter + * @param {string} query - The search query. + * @param {Array} items - The array of items to be sorted. + * @param {string} searchKey - The key used to search within each item. + * @returns {Array} Sorted array of items based on query relevance. + */ + sorter: (query, items, searchKey) => { + if (!query) { + return items; + } + + const lowerQuery = query.toLowerCase(); + const filteredItems = new Array(items.length); + + for (let i = 0, len = items.length; i < len; i++) { + let item = items[i]; + item.atwho_order = item[searchKey].toLowerCase().indexOf(lowerQuery); + if (item.atwho_order > -1) { + filteredItems[i] = item; + } + } + + return filteredItems.sort((a, b) => a.atwho_order - b.atwho_order); + }, + + /** + * Evaluates a template string by replacing placeholders with map values. + * + * @function tplEval + * @param {string|Function} tpl - The template string or a function returning a template. + * @param {Object} map - The key-value pairs to replace in the template. + * @returns {string} The evaluated template string with values filled in. + */ + tplEval: (tpl, map) => { + try { + const template = typeof tpl === 'string' ? tpl : tpl(map); + return template.replace(/\$\{([^\}]*)\}/g, (_, key) => map[key]); + } catch (error) { + return ""; + } + }, + + /** + * Highlights a query in a list item string. + * + * @function highlighter + * @param {string} li - The list item HTML string. + * @param {string} query - The query to highlight. + * @returns {string} The modified list item with highlighted query. + */ + highlighter(strReplace, strWith) { + var esc = strWith.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + var reg = new RegExp(esc, 'ig'); + return strReplace.replace(reg, '$&'); + }, + + /** + * Processes a value before insertion. + * + * @function beforeInsert + * @param {string} value - The value to be inserted. + * @returns {string} The processed value. + */ + beforeInsert: (value) => value, + + /** + * Adjusts the offset before repositioning an element. + * + * @function beforeReposition + * @param {Object} offset - The current offset. + * @returns {Object} The modified offset. + */ + beforeReposition: (offset) => offset, + + /** + * Callback function after a match failure. + * + * @function afterMatchFailed + */ + afterMatchFailed: () => {} + }; + + const DEFAULT_SETTINGS = { + /** + * Character that triggers the observation (e.g., `@`). + * @type {string|undefined} + */ + at: void 0, + + /** + * Alias name for the `at` trigger. + * This will also serve as the `id` attribute for the popup view. + * @type {string|undefined} + */ + alias: void 0, + + /** + * Data source, which can be: + * - An array of items. + * - A URL to load JSON data remotely. + * If it's an array, it will be used directly. If it's + * a URL, At.js will fetch and load the data. + * @type {Array|URL|null} + */ + data: null, + + /** + * HTML template to render at the top of the dropdown popup. + * This is commonly used for showing instructions or headings. + * @type {string} + */ + headerTpl: "", + + /** + * Template for rendering each item in the dropdown. + * You can use `${}` to interpolate values from the data object, + * e.g., `${name}` for the item's name. Alternatively, this can + * be a function that accepts a data item and returns HTML. + * @type {string|function} + */ + displayTpl: "${name}", + + /** + * Template for inserting the selected item into the input field. + * `${atwho-at}` represents the trigger character (`@` by default) + * and `${name}` is the selected item's name. + * @type {string} + */ + insertTpl: "${atwho-at}${name}", + + /** + * Callback functions for customizing data processing (e.g., + * filtering results). The default set of callbacks + * (`DEFAULT_CALLBACKS`) can be overridden as needed. + * @type {Object} + */ + callbacks: DEFAULT_CALLBACKS, + + /** + * The key in the data object that will be searched and matched + * against the user's query. For example, if set to "name", + * At.js will match items based on the "name" field. + * @type {string} + */ + searchKey: "name", + + /** + * Maximum number of items to display in the dropdown list. + * @type {number} + */ + limit: 5, + + /** + * Minimum length of the query string after the trigger character + * (`at`). Once the query exceeds this length, matching will stop. + * @type {number} + */ + minLen: 0, + + /** + * Maximum length of the query string after the trigger character + * (`at`). Once the query exceeds this length, matching will stop. + * @type {number} + */ + maxLen: 20, + + /** + * If set to `true`, the `at` trigger must be preceded by a space in the input field. + * @type {boolean} + */ + startWithSpace: true, + + /** + * Time in milliseconds to keep the popup open after losing focus from the input field. + * @type {number} + */ + displayTimeout: 300, + + /** + * If `true`, the first suggestion in the dropdown will be automatically highlighted. + * @type {boolean} + */ + highlightFirst: true, + + /** + * Delay time in milliseconds before triggering At.js while typing. + * For example: `delay: 400`. + * @type {number|null} + */ + delay: null, + + /** + * String to append after inserting a matched item. + * @type {string|undefined} + */ + suffix: undefined, + + /** + * If set to `true`, the dropdown will not show unless the user types the suffix. + * @type {boolean} + */ + lookUpOnClick: true, + + /** + * If set to `true`, the dropdown will not show unless the user types the suffix. + * @type {boolean} + */ + hideWithoutSuffix: false, + + /** + * Adds attributes to the inserted element when using contenteditable mode. + * This helps retain additional information about the inserted query. + * More info: {@link https://github.com/ichord/At.js/issues/253#issuecomment-75694945} + * @type {Object} + */ + editableAtwhoQueryAttrs: {} + }; + + class App { + constructor(inputor) { + this.currentFlag = null; + this.controllers = {}; + this.aliasMaps = {}; + this.inputor = typeof inputor !== 'string' ? inputor : document.querySelector(inputor); + this.setupRootElement(); + this.listen(); + } + + setupRootElement(iframe = null, asRoot = false) { + if (iframe) { + this.window = iframe.contentWindow; + this.document = iframe.contentDocument || this.window.document; + this.iframe = iframe; + } else { + this.document = this.inputor.ownerDocument; + this.window = this.document.defaultView || this.document.parentWindow; + try { + this.iframe = this.window.frameElement; + } catch (error) { + console.error("iframe auto-discovery failed. Set target iframe manually."); + } + } + + // Create or reuse containing element + this.el = document.querySelector('.atwho-container'); + if (!this.el) { + this.el = document.createElement('div'); + this.el.classList.add('atwho-container'); + this.document.body.appendChild(this.el); + } + } + + controller(at) { + let current; + if (this.aliasMaps[at]) { + current = this.controllers[this.aliasMaps[at]]; + } else { + for (let currentFlag in this.controllers) { + if (currentFlag === at) { + current = this.controllers[currentFlag]; + break; + } + } + } + return current || this.controllers[this.currentFlag]; + } + + setContextFor(at) { + this.currentFlag = at; + return this; + } + + reg(flag, setting) { + let controller = this.controllers[flag] || + (this.inputor.isContentEditable ? new EditableController(this, flag) : new TextareaController(this, flag)); + + if (setting.alias) { + this.aliasMaps[setting.alias] = flag; + } + controller.init(setting); + this.controllers[flag] = controller; + + return this; + } + + listen() { + this.inputor.addEventListener('compositionstart', function(e) { + let controller = this.controller(); + if (controller) controller.view.hide(); + this.isComposing = true; + }.bind(this)); + + this.inputor.addEventListener('compositionend', function(e) { + this.isComposing = false; + setTimeout(() => this.dispatch(e)); + }.bind(this)); + + this.inputor.addEventListener('keyup', this.onKeyup.bind(this)); + this.inputor.addEventListener('keydown', this.onKeydown.bind(this)); + this.inputor.addEventListener('blur', function(e) { + let controller = this.controller(); + if (controller) { + controller.expectedQueryCBId = null; + controller.view.hide(e, controller.getOpt("displayTimeout")); + } + }.bind(this)); + + this.inputor.addEventListener('click', this.dispatch.bind(this)); + this.inputor.addEventListener('scroll', function() { + let lastScrollTop = this.inputor.scrollTop; + return e => { + let currentScrollTop = e.target.scrollTop; + if (lastScrollTop !== currentScrollTop) { + let controller = this.controller(); + if (controller) controller.view.hide(e); + } + lastScrollTop = currentScrollTop; + }; + }.bind(this)); + } + + shutdown() { + Object.values(this.controllers).forEach(controller => { + controller.destroy(); + delete this.controllers[controller.flag]; + }); + this.inputor.removeEventListener('.atwhoInner'); + this.el.remove(); + } + + dispatch(e) { + if (e === undefined) return; + return Object.values(this.controllers).map(controller => controller.lookUp(e)); + } + + onKeyup(e) { + switch (e.keyCode) { + case KEY_CODE.ESC: + case KEY_CODE.DOWN: + case KEY_CODE.UP: + case KEY_CODE.CTRL: + case KEY_CODE.ENTER: + break; + case KEY_CODE.P: + case KEY_CODE.N: + if (!e.ctrlKey) this.dispatch(e); + break; + default: + this.dispatch(e); + } + } + + onKeydown(e) { + let view = this.controller()?.view; + if (!view?.visible()) return; + + switch (e.keyCode) { + case KEY_CODE.ESC: + view.hide(e); + break; + case KEY_CODE.UP: + view.prev(); + break; + case KEY_CODE.DOWN: + view.next(); + break; + case KEY_CODE.P: + if (!e.ctrlKey) return; + view.prev(); + break; + case KEY_CODE.N: + if (!e.ctrlKey) return; + view.next(); + break; + case KEY_CODE.TAB: + case KEY_CODE.ENTER: + case KEY_CODE.SPACE: + if (!view.visible()) { + return; + } + if (!this.controller().getOpt('spaceSelectsMatch') && e.keyCode === KEY_CODE.SPACE) { + return; + } + if (!this.controller().getOpt('tabSelectsMatch') && e.keyCode === KEY_CODE.TAB) { + return; + } + if (view.highlighted()) { + view.choose(e); + } else { + view.hide(e); + } + break; + default: + return; + } + + // prevent a few navigation keys from + // working when the popup is in view + e.preventDefault(); + e.stopPropagation(); + } + } + + class Controller { + uid() { + return (Math.random().toString(16) + "000000000").substr(2, 8) + (new Date().getTime()); + } + + constructor(app, at) { + this.app = app; + this.at = at; + this.inputor = this.app.inputor; + this.id = this.inputor.id || this.uid(); + this.expectedQueryCBId = null; + this.setting = null; + this.query = null; + this.pos = 0; + this.range = null; + + // Create or reuse ground element + this.el = document.querySelector(`#atwho-ground-${this.id}`); + if (!this.el) { + this.el = document.createElement('div'); + this.el.id = `atwho-ground-${this.id}`; + this.app.el.appendChild(this.el); + } + + this.model = new Model(this); + this.view = new View(this); + } + + init(setting) { + this.setting = Object.assign({}, this.setting || DEFAULT_SETTINGS, setting); + this.view.init(); + return this.model.reload(this.setting.data); + } + + destroy() { + this.trigger('beforeDestroy'); + this.model.destroy(); + this.view.destroy(); + return this.el.remove(); + } + + callDefault(funcName, ...args) { + try { + return DEFAULT_CALLBACKS[funcName].apply(this, args); + } catch (error) { + console.error(`Error: ${error}. Maybe At.js doesn't have the function ${funcName}`); + } + } + + trigger(name, data = []) { + data.push(this); + const alias = this.getOpt('alias'); + const eventName = alias ? `${name}-${alias}.atwho` : `${name}.atwho`; + const event = new CustomEvent(eventName, { detail: data }); + this.inputor.dispatchEvent(event); + } + + callbacks(funcName) { + return this.getOpt('callbacks')[funcName] || DEFAULT_CALLBACKS[funcName]; + } + + getOpt(at, default_value) { + try { + return this.setting[at]; + } catch (e) { + return null; + } + } + + insertContentFor(li) { + const searchKey = this.getOpt("searchKey"); + let data = { 'atwho-at': this.at }; + data[searchKey] = li.dataset.itemData; + const tpl = this.getOpt('insertTpl'); + return this.callbacks('tplEval').call(this, tpl, data, 'onInsert'); + } + + renderView(data) { + const searchKey = this.getOpt('searchKey'); + const sortedData = this.callbacks('sorter').call(this, this.query.text, data, searchKey); + + return this.view.render(sortedData); + } + + static arrayToDefaultHash(items) { + if (!Array.isArray(items)) return items; + + const results = new Array(items.length); + + for (let i = 0, len = items.length; i < len; i++) { + if (typeof items[i] === 'string') { + results[i] = { name: items[i] }; + } else { + results[i] = items[i]; + } + } + + return results; + } + + lookUp(e) { + if (e && e.type === 'click' && !this.getOpt('lookUpOnClick')) return; + if (this.getOpt('suspendOnComposing') && this.app.isComposing) return; + + const query = this.catchQuery(e); + if (!query) { + this.expectedQueryCBId = null; + return query; + } + + this.app.setContextFor(this.at); + const wait = this.getOpt('delay'); + if (wait) { + this._delayLookUp(query, wait); + } else { + this._lookUp(query); + } + return query; + } + + _delayLookUp(query, wait) { + const now = Date.now ? Date.now() : new Date().getTime(); + this.previousCallTime = this.previousCallTime || now; + const remaining = wait - (now - this.previousCallTime); + + if (remaining > 0 && remaining < wait) { + this.previousCallTime = now; + this._stopDelayedCall(); + this.delayedCallTimeout = setTimeout(() => { + this.previousCallTime = 0; + this.delayedCallTimeout = null; + this._lookUp(query); + }, wait); + } else { + this._stopDelayedCall(); + if (this.previousCallTime !== now) this.previousCallTime = 0; + this._lookUp(query); + } + } + + _stopDelayedCall() { + if (this.delayedCallTimeout) { + clearTimeout(this.delayedCallTimeout); + this.delayedCallTimeout = null; + } + } + + _generateQueryCBId() { + return {}; + } + + _lookUp(query) { + const queryCBId = this._generateQueryCBId(); + this.expectedQueryCBId = queryCBId; + this.model.query(query.text, (data) => { + if (queryCBId !== this.expectedQueryCBId) return; + if (data && data.length > 0) { + this.renderView(Controller.arrayToDefaultHash(data)); + } else { + this.view.hide(); + } + }); + } + } + + class TextareaController extends Controller { + catchQuery() { + const content = this.inputor.value; + const caretPos = this.inputor.selectionStart; + const subtext = content.slice(0, caretPos); + const query = this.callbacks('matcher').call(this, this.at, subtext, this.getOpt('startWithSpace'), this.getOpt('acceptSpaceBar')); + + if (typeof query === 'string' && query.length >= this.getOpt('minLen', 0) && query.length <= this.getOpt('maxLen', 20)) { + const start = caretPos - query.length; + const end = start + query.length; + this.pos = start; + this.query = { text: query, headPos: start, endPos: end }; + this.trigger('matched', [this.at, this.query.text]); + } else { + this.query = null; + this.view.hide(); + } + + return this.query; + } + + rect() { + let caretOffset, iframeOffset, scaleBottom; + caretOffset = caret(this.inputor, 'offset', this.pos - 1, { + iframe: this.app.iframe + }); + if (!caretOffset) return; + + if (this.app.iframe && !this.app.iframeAsRoot) { + iframeOffset = this.app.iframe.getBoundingClientRect(); + caretOffset.left += iframeOffset.left; + caretOffset.top += iframeOffset.top; + } + + // If the document is not in selection mode, add scaleBottom for padding + scaleBottom = this.app.document.selection ? 0 : 2; + + return { + left: caretOffset.left, + top: caretOffset.top, + bottom: caretOffset.top + caretOffset.height + scaleBottom + }; + } + + insert(content, li) { + const source = this.inputor.value; + const startStr = source.slice(0, Math.max(this.query.headPos - this.at.length, 0)); + const suffix = this.getOpt('suffix') || ' '; + const newContent = startStr + content + suffix + source.slice(this.query.endPos); + this.inputor.value = newContent; + + const newPos = startStr.length + content.length; + this.inputor.setSelectionRange(newPos, newPos); + this.inputor.focus(); + this.inputor.dispatchEvent(new Event('input')); + } + } + + class EditableController extends Controller { + catchQuery(e) { + const range = this.app.document.getSelection()?.getRangeAt(0); + if (!range || !range.collapsed) return; + + const clonedRange = range.cloneRange(); + clonedRange.setStart(range.startContainer, 0); + const matched = this.callbacks("matcher").call( + this, this.at, clonedRange.toString(), + this.getOpt('startWithSpace'), this.getOpt("acceptSpaceBar") + ); + + if (typeof matched === 'string' && matched.length >= this.getOpt('minLen', 0) && matched.length <= this.getOpt('maxLen', 20)) { + const index = range.startOffset - this.at.length - matched.length; + + if (index >= 0) { + this.trigger("matched", [this.at, matched]); + this.query = { text: matched, range, index }; + return this.query; + } + } + + this.view.hide(); + } + + rect() { + let caretOffset = caret(this.inputor, 'offset', this.pos, { + iframe: this.app.iframe + }); + if (!caretOffset) return; + + if (this.app.iframe && !this.app.iframeAsRoot) { + const iframeOffset = this.app.iframe.getBoundingClientRect(); + caretOffset.left += iframeOffset.left - this.app.window.scrollX + window.scrollX; + caretOffset.top += iframeOffset.top - this.app.window.scrollY + window.scrollY; + } + + return { + left: caretOffset.left, + top: caretOffset.top, + }; + } + + insert(content, li) { + const range = this.query.range; + range.setStart(range.startContainer, this.query.index); + range.deleteContents(); + + // Insert the text node at the caret's current position + const textNode = this.app.document.createTextNode(content); + range.insertNode(textNode); + + // Adjust the range to select the new text node's contents + range.selectNodeContents(textNode); + + // Collapse the range to place the caret at the end of the inserted text + range.collapse(false); + + // Clear all existing selections + const selection = this.app.window.getSelection(); + selection.removeAllRanges(); + + // Apply the updated range as the new selection + selection.addRange(range); + + this.inputor.focus(); + + return this.inputor.dispatchEvent(new Event('change')); + } + } + + class Model { + constructor(context) { + this.context = context; + this.at = context.at; + this.storage = context.inputor; + } + + destroy() { + this.storage[this.at] = null; + } + + saved() { + return this.fetch().length > 0; + } + + query(query, callback) { + let data = this.fetch(); + const searchKey = this.context.getOpt("searchKey"); + data = this.context.callbacks('filter').call(this.context, query, data, searchKey) || []; + + const remoteFilter = this.context.callbacks('remoteFilter'); + if (data.length || !remoteFilter) { + callback(data); + } else { + remoteFilter.call(this.context, query, callback); + } + } + + fetch() { + return this.storage[this.at] || []; + } + + save(data) { + this.storage[this.at] = this.context.callbacks('beforeSave').call(this.context, data || []); + } + + load(data) { + if (!this.saved() && data) { + this._load(data); + } + } + + reload(data) { + this._load(data); + } + + _load(data) { + if (typeof data === 'string') { + fetch(data) + .then(response => response.json()) + .then(fetchedData => this.save(fetchedData)); + } else { + this.save(data); + } + } + } + + class View { + constructor(context) { + this.context = context; + this.el = document.createElement('div'); + this.el.classList.add('atwho-view'); + this.elUl = document.createElement('ul'); + this.el.appendChild(this.elUl); + this.timeoutID = null; + this.context.el.appendChild(this.el); + this.bindEvent(); + } + + init() { + const id = this.context.getOpt("alias") || this.context.at.charCodeAt(0); + const headerTpl = this.context.getOpt("headerTpl"); + + if (headerTpl && this.el.children.length === 1) { + this.el.insertAdjacentHTML('afterbegin', headerTpl); + } + this.el.id = `at-view-${id}`; + } + + destroy() { + this.el.remove(); + } + + bindEvent() { + const menu = this.el.querySelector('ul'); + let lastCoordX = 0; + let lastCoordY = 0; + + menu.addEventListener('mousemove', e => { + const targetLi = e.target.closest('li'); + if (targetLi) { + for (const sibling of targetLi.parentNode.children) { + sibling.classList.remove('cur'); + } + targetLi.classList.add('cur'); + } + }); + + menu.addEventListener('click', this.choose.bind(this)); + } + + visible() { + return this.el.offsetWidth > 0 && this.el.offsetHeight > 0; + } + + highlighted() { + return this.el.querySelectorAll(".cur").length > 0; + } + + choose(e) { + const li = this.el.querySelector(".cur"); + if (li) { + const content = this.context.insertContentFor(li); + this.context._stopDelayedCall(); + const beforeInsertCallback = this.context.callbacks("beforeInsert"); + const insertContent = beforeInsertCallback?.call(this.context, content, li, e); + this.context.insert(insertContent, li); + this.context.trigger("inserted", [li, e]); + this.hide(e); + } + + if (this.context.getOpt("hideWithoutSuffix")) { + this.stopShowing = true; + } + } + + reposition(rect) { + const _window = this.context.app.iframeAsRoot ? this.context.app.window : window; + const elHeight = this.el.offsetHeight; + + // Adjust bottom and left positions based on window dimensions + if (rect.bottom + elHeight - _window.scrollY > _window.innerHeight) { + rect.bottom = rect.top - elHeight; + } + if (rect.left > _window.innerWidth - this.el.offsetWidth - 5) { + rect.left = _window.innerWidth - this.el.offsetWidth - 5; + } + + const beforeReposition = this.context.callbacks("beforeReposition"); + beforeReposition?.call(this.context, rect); + + this.el.style.left = `${rect.left}px`; + this.el.style.top = `${rect.top}px`; + this.context.trigger("reposition", [rect]); + } + + next() { + let cur = this.el.querySelector(".cur"); + cur?.classList.remove("cur"); + + let next = cur?.nextElementSibling || this.el.querySelector("li:first-child"); + next.classList.add("cur"); + + const offset = next.offsetTop + next.offsetHeight + (next.nextElementSibling?.offsetHeight || 0); + this.scrollTop(Math.max(0, offset - this.el.offsetHeight)); + } + + prev() { + let cur = this.el.querySelector(".cur"); + cur?.classList.remove("cur"); + + let prev = cur?.previousElementSibling || this.el.querySelector("li:last-child"); + prev.classList.add("cur"); + + const offset = prev.offsetTop + prev.offsetHeight + (prev.nextElementSibling?.offsetHeight || 0); + this.scrollTop(Math.max(0, offset - this.el.offsetHeight)); + } + + scrollTop(scrollTop) { + const scrollDuration = this.context.getOpt("scrollDuration"); + if (scrollDuration) { + // Smooth scroll using vanilla JS + this.elUl.scrollTo({ top: scrollTop, behavior: "smooth" }); + } else { + this.elUl.scrollTop = scrollTop; + } + } + + show() { + if (this.stopShowing) { + this.stopShowing = false; + return; + } + + if (!this.visible()) { + this.el.style.display = "block"; + this.el.scrollTop = 0; + this.context.trigger("shown"); + } + + const rect = this.context.rect(); + if (rect) { + this.reposition(rect); + } + } + + hide(e, time) { + if (!this.visible()) return; + + if (isNaN(time)) { + this.el.style.display = "none"; + this.context.trigger("hidden", [e]); + } else { + clearTimeout(this.timeoutID); + this.timeoutID = setTimeout(() => this.hide(), time); + } + } + + render(items) { + if (!Array.isArray(items) || items.length === 0) { + this.hide(); + return; + } + + const ul = this.el.querySelector("ul"); + ul.innerHTML = ""; + const limit = this.context.getOpt("limit"); + const tpl = this.context.getOpt("displayTpl"); + const searchKey = this.context.getOpt("searchKey"); + const highlightFirst = this.context.getOpt("highlightFirst"); + + for (let i = 0; i < limit; i++) { + let item = items[i]; + item["atwho-at"] = this.context.at; + const li = this.context.callbacks("tplEval")?.call(this.context, tpl, item, "onDisplay"); + + const liElement = document.createElement("li"); + liElement.innerHTML = this.context.callbacks("highlighter")?.call(this.context, li, this.context.query.text); + liElement.dataset.itemData = item[searchKey]; + ul.appendChild(liElement); + + if (highlightFirst && !i) { + liElement.className = "cur"; + } + } + + this.show(); + } + } + + const methods = { + load(at, data) { + var c = this.controller(at); + if (c) { + return c.model.load(data); + } + }, + isSelecting() { + var ref = this.controller(); + return !!(ref && ref.view.visible()); + }, + hide() { + var ref = this.controller(); + return ref && ref.view.hide(); + }, + reposition() { + var c = this.controller(); + if (c) { + return c.view.reposition(c.rect()); + } + }, + setIframe(iframe, asRoot) { + this.setupRootElement(iframe, asRoot); + return null; + }, + run() { + return this.dispatch(); + }, + destroy() { + this.shutdown(); + return this.inputor.removeAttribute('data-atwho'); + } + }; + + return (element, method, ...args) => { + let app = element.atwhoApp + if (!app) { + element.atwhoApp = new App(element); + app = element.atwhoApp; + } + if (typeof method === 'object' || !method) { + app.reg(method.at, method); + } else if (methods[method] && app) { + return methods[method].apply(app, args); + } else { + console.error("Method " + method + " does not exist on atwho"); + } + }; +}); \ No newline at end of file diff --git a/Themes/default/scripts/caret.js b/Themes/default/scripts/caret.js new file mode 100644 index 0000000000..cc85d23587 --- /dev/null +++ b/Themes/default/scripts/caret.js @@ -0,0 +1,235 @@ +((root, factory) => { + if (typeof define === 'function' && define.amd) { + define([], () => (root.returnExportsGlobal = factory())); + } else if (typeof exports === 'object') { + module.exports = factory(); + } else { + root.caret = factory(); + } +})(this, () => { + "use strict"; + + class Caret { + constructor(opt) { + if (opt.iframe) { + this.window = opt.iframe.contentWindow; + this.document = opt.iframe.contentDocument || this.window.document; + this.iframe = opt.iframe; + } else { + this.document = opt.inputor.ownerDocument; + this.window = this.document.defaultView || this.document.parentWindow; + try { + this.iframe = this.window.frameElement; + } catch (error) { + console.error("iframe auto-discovery failed. Set target iframe manually."); + } + } + + this.inputor = opt.inputor; + } + } + + class EditableCaret extends Caret { + setPos(pos) { + const sel = this.window.getSelection(); + if (sel) { + let offset = 0, found = false; + + const findPosition = (pos, parent) => { + for (const node of parent.childNodes) { + if (found) break; + + if (node.nodeType === 3) { + if (offset + node.length >= pos) { + found = true; + const range = this.document.createRange(); + range.setStart(node, pos - offset); + sel.removeAllRanges(); + sel.addRange(range); + break; + } else { + offset += node.length; + } + } else { + findPosition(pos, node); + } + } + }; + + findPosition(pos, this.inputor); + } + return this.inputor; + } + + getPosition() { + const offset = this.getOffset(); + const inputorOffset = this.inputor.getBoundingClientRect(); + return { + left: offset.left - inputorOffset.left, + top: offset.top - inputorOffset.top + }; + } + + getPos() { + const range = this.range(); + if (range) { + const clonedRange = range.cloneRange(); + clonedRange.selectNodeContents(this.inputor); + clonedRange.setEnd(range.endContainer, range.endOffset); + return clonedRange.toString().length; + } + } + + getOffset() { + const range = this.range(); + if (range) { + let offset; + if (range.endOffset - 1 > 0 && range.endContainer !== this.inputor) { + const clonedRange = range.cloneRange(); + clonedRange.setStart(range.endContainer, range.endOffset - 1); + clonedRange.setEnd(range.endContainer, range.endOffset); + const rect = clonedRange.getBoundingClientRect(); + offset = { + height: rect.height, + left: rect.left + rect.width, + top: rect.top + }; + } + + // Get offset from beginning of line + if (!offset || offset.height === 0) { + const clonedRange = range.cloneRange(); + const shadowCaret = this.document.createTextNode("\u200D"); + clonedRange.insertNode(shadowCaret); + const rect = clonedRange.getBoundingClientRect(); + offset = { + height: rect.height, + left: rect.left, + top: rect.top + }; + shadowCaret.remove(); + } + + return { + left: offset.left + this.window.scrollX, + top: offset.top + this.window.scrollY, + height: offset.height + }; + } + } + + range() { + const sel = this.window.getSelection(); + return sel && sel.rangeCount > 0 ? sel.getRangeAt(0) : null; + } + } + + class InputCaret extends Caret { + getPos() { + return this.inputor.selectionStart; + } + + setPos(pos) { + this.inputor.setSelectionRange(pos, pos); + return this.inputor; + } + + getOffset(pos) { + const offset = this.inputor.getBoundingClientRect(); + const position = this.getPosition(pos); + + return { + left: offset.left + this.window.scrollX + position.left - this.inputor.scrollLeft, + top: offset.top + this.window.scrollY + position.top - this.inputor.scrollTop, + height: position.height + }; + } + + getPosition(pos = this.getPos()) { + const format = value => value + .replace(/[<>`"&]/g, '?') + .replace(/\r?\n/g, "
") + .replace(/\s/g, ' '); + + const html = ` + ${format(this.inputor.value.slice(0, pos))} + \u200D + ${format(this.inputor.value.slice(pos))} + `; + + return this.createMirror(html).rect(); + } + + createMirror(html) { + const attributes = [ + 'borderBottomWidth', 'borderLeftWidth', 'borderRightWidth', 'borderTopStyle', + 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle', 'borderTopWidth', + 'boxSizing', 'fontFamily', 'fontSize', 'fontWeight', 'height', 'letterSpacing', + 'lineHeight', 'marginBottom', 'marginLeft', 'marginRight', 'marginTop', + 'outlineWidth', 'overflow', 'overflowX', 'overflowY', 'paddingBottom', + 'paddingLeft', 'paddingRight', 'paddingTop', 'textAlign', 'textOverflow', + 'textTransform', 'wordBreak', 'wordWrap', + ]; + + const css = { + position: 'absolute', + left: '-9999px', + top: '0', + zIndex: '-2000', + }; + + if (this.inputor.tagName === 'TEXTAREA') { + attributes.push('width'); + } + + attributes.forEach(attr => { + css[attr] = getComputedStyle(this.inputor)[attr]; + }); + + const mirror = this.document.createElement('div'); + Object.assign(mirror.style, css); + mirror.innerHTML = html; + this.document.body.append(mirror); + + return { + rect: () => { + const marker = mirror.ownerDocument.getElementById('caret-position-marker'); + const boundingRect = { + left: marker.offsetLeft, + top: marker.offsetTop, + height: marker.offsetHeight + }; + mirror.remove(); + + return boundingRect; + } + }; + } + } + + const methods = { + pos(pos) { + return pos !== undefined ? this.setPos(pos) : this.getPos(); + }, + position(pos) { + return this.getPosition(pos); + }, + offset(pos) { + return this.getOffset(pos); + } + }; + + return (inputor, method, value, opt = {}) => { + if (typeof value == 'object') { + obj = value; + } + opt.inputor = inputor; + const isContentEditable = inputor => !!(inputor.contentEditable && inputor.contentEditable === 'true'); + const caret = isContentEditable(inputor) ? new EditableCaret(opt) : new InputCaret(opt); + if (methods[method]) { + return methods[method].apply(caret, [value]); + } else { + throw new Error(`Method ${method} does not exist`); + } + }; +}); \ No newline at end of file diff --git a/Themes/default/scripts/jquery.atwho.min.js b/Themes/default/scripts/jquery.atwho.min.js deleted file mode 100644 index e5d9dfada3..0000000000 --- a/Themes/default/scripts/jquery.atwho.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof exports?module.exports=b(require("jquery")):b(jQuery)}(this,function(a){var b,c,d,e,f,g,h,i,j,k=[].slice,l=function(a,b){function c(){this.constructor=a}for(var d in b)m.call(b,d)&&(a[d]=b[d]);return c.prototype=b.prototype,a.prototype=new c,a.__super__=b.prototype,a},m={}.hasOwnProperty;c=function(){function a(a){this.currentFlag=null,this.controllers={},this.aliasMaps={},this.$inputor=$(a),this.setupRootElement(),this.listen()}return a.prototype.createContainer=function(a){var b;return null!=(b=this.$el)&&b.remove(),$(a.body).append(this.$el=$("
"))},a.prototype.setupRootElement=function(a,b){var c;if(null==b&&(b=!1),a)this.window=a.contentWindow,this.document=a.contentDocument||this.window.document,this.iframe=a;else{this.document=this.$inputor[0].ownerDocument,this.window=this.document.defaultView||this.document.parentWindow;try{this.iframe=this.window.frameElement}catch(d){if(c=d,this.iframe=null,$.fn.atwho.debug)throw new Error("iframe auto-discovery is failed.\nPlease use `setIframe` to set the target iframe manually.\n"+c)}}return this.createContainer((this.iframeAsRoot=b)?this.document:document)},a.prototype.controller=function(a){var b,c,d,e;if(this.aliasMaps[a])c=this.controllers[this.aliasMaps[a]];else{e=this.controllers;for(d in e)if(b=e[d],d===a){c=b;break}}return c?c:this.controllers[this.currentFlag]},a.prototype.setContextFor=function(a){return this.currentFlag=a,this},a.prototype.reg=function(a,b){var c,d;return d=(c=this.controllers)[a]||(c[a]=this.$inputor.is("[contentEditable]")?new f(this,a):new i(this,a)),b.alias&&(this.aliasMaps[b.alias]=a),d.init(b),this},a.prototype.listen=function(){return this.$inputor.on("compositionstart",function(a){return function(b){var c;return null!=(c=a.controller())&&c.view.hide(),a.isComposing=!0}}(this)).on("compositionend",function(a){return function(b){return a.isComposing=!1}}(this)).on("keyup.atwhoInner",function(a){return function(b){return a.onKeyup(b)}}(this)).on("keydown.atwhoInner",function(a){return function(b){return a.onKeydown(b)}}(this)).on("scroll.atwhoInner",function(a){return function(b){var c;return null!=(c=a.controller())?c.view.hide(b):void 0}}(this)).on("blur.atwhoInner",function(a){return function(b){var c;return(c=a.controller())?c.view.hide(b,c.getOpt("displayTimeout")):void 0}}(this)).on("click.atwhoInner",function(a){return function(b){return a.dispatch(b)}}(this))},a.prototype.shutdown=function(){var a,b,c;c=this.controllers;for(a in c)b=c[a],b.destroy(),delete this.controllers[a];return this.$inputor.off(".atwhoInner"),this.$el.remove()},a.prototype.dispatch=function(a){var b,c,d,e;d=this.controllers,e=[];for(b in d)c=d[b],e.push(c.lookUp(a));return e},a.prototype.onKeyup=function(a){var b;switch(a.keyCode){case g.ESC:a.preventDefault(),null!=(b=this.controller())&&b.view.hide();break;case g.DOWN:case g.UP:case g.CTRL:$.noop();break;case g.P:case g.N:a.ctrlKey||this.dispatch(a);break;default:this.dispatch(a)}},a.prototype.onKeydown=function(a){var b,c;if(c=null!=(b=this.controller())?b.view:void 0,c&&c.visible())switch(a.keyCode){case g.ESC:a.preventDefault(),c.hide(a);break;case g.UP:a.preventDefault(),c.prev();break;case g.DOWN:a.preventDefault(),c.next();break;case g.P:if(!a.ctrlKey)return;a.preventDefault(),c.prev();break;case g.N:if(!a.ctrlKey)return;a.preventDefault(),c.next();break;case g.TAB:case g.ENTER:case g.SPACE:if(!c.visible())return;if(!this.controller().getOpt("spaceSelectsMatch")&&a.keyCode===g.SPACE)return;c.highlighted()?(a.preventDefault(),c.choose(a)):c.hide(a);break;default:$.noop()}},a}(),d=function(){function a(a,b){this.app=a,this.at=b,this.$inputor=this.app.$inputor,this.id=this.$inputor[0].id||this.uid(),this.setting=null,this.query=null,this.pos=0,this.range=null,0===(this.$el=$("#atwho-ground-"+this.id,this.app.$el)).length&&this.app.$el.append(this.$el=$("
")),this.model=new h(this),this.view=new j(this)}return a.prototype.uid=function(){return(Math.random().toString(16)+"000000000").substr(2,8)+(new Date).getTime()},a.prototype.init=function(a){return this.setting=$.extend({},this.setting||$.fn.atwho["default"],a),this.view.init(),this.model.reload(this.setting.data)},a.prototype.destroy=function(){return this.trigger("beforeDestroy"),this.model.destroy(),this.view.destroy(),this.$el.remove()},a.prototype.callDefault=function(){var a,b,c;c=arguments[0],a=2<=arguments.length?k.call(arguments,1):[];try{return e[c].apply(this,a)}catch(d){return b=d,$.error(b+" Or maybe At.js doesn't have function "+c)}},a.prototype.trigger=function(a,b){var c,d;return null==b&&(b=[]),b.push(this),c=this.getOpt("alias"),d=c?a+"-"+c+".atwho":a+".atwho",this.$inputor.trigger(d,b)},a.prototype.callbacks=function(a){return this.getOpt("callbacks")[a]||e[a]},a.prototype.getOpt=function(a,b){var c;try{return this.setting[a]}catch(d){return c=d,null}},a.prototype.insertContentFor=function(a){var b,c;return c=this.getOpt("insertTpl"),b=$.extend({},a.data("item-data"),{"atwho-at":this.at}),this.callbacks("tplEval").call(this,c,b,"onInsert")},a.prototype.renderView=function(a){var b;return b=this.getOpt("searchKey"),a=this.callbacks("sorter").call(this,this.query.text,a.slice(0,1001),b),this.view.render(a.slice(0,this.getOpt("limit")))},a.arrayToDefaultHash=function(a){var b,c,d,e;if(!$.isArray(a))return a;for(e=[],b=0,d=a.length;d>b;b++)c=a[b],e.push($.isPlainObject(c)?c:{name:c});return e},a.prototype.lookUp=function(a){var b,c;if(b=this.catchQuery(a))return this.app.setContextFor(this.at),(c=this.getOpt("delay"))?this._delayLookUp(b,c):this._lookUp(b),b},a.prototype._delayLookUp=function(a,b){var c,d;return c=Date.now?Date.now():(new Date).getTime(),this.previousCallTime||(this.previousCallTime=c),d=b-(c-this.previousCallTime),d>0&&b>d?(this.previousCallTime=c,this._stopDelayedCall(),this.delayedCallTimeout=setTimeout(function(b){return function(){return b.previousCallTime=0,b.delayedCallTimeout=null,b._lookUp(a)}}(this),b)):(this._stopDelayedCall(),this.previousCallTime!==c&&(this.previousCallTime=0),this._lookUp(a))},a.prototype._stopDelayedCall=function(){return this.delayedCallTimeout?(clearTimeout(this.delayedCallTimeout),this.delayedCallTimeout=null):void 0},a.prototype._lookUp=function(a){var b;return b=function(a){return a&&a.length>0?this.renderView(this.constructor.arrayToDefaultHash(a)):this.view.hide()},this.model.query(a.text,$.proxy(b,this))},a}(),i=function(a){function b(){return b.__super__.constructor.apply(this,arguments)}return l(b,a),b.prototype.catchQuery=function(){var a,b,c,d,e,f;return b=this.$inputor.val(),a=this.$inputor.caret("pos",{iframe:this.app.iframe}),f=b.slice(0,a),d=this.callbacks("matcher").call(this,this.at,f,this.getOpt("startWithSpace")),"string"==typeof d&&d.length<=this.getOpt("maxLen",20)?(e=a-d.length,c=e+d.length,this.pos=e,d={text:d,headPos:e,endPos:c},this.trigger("matched",[this.at,d.text])):(d=null,this.view.hide()),this.query=d},b.prototype.rect=function(){var a,b,c;if(a=this.$inputor.caret("offset",this.pos-1,{iframe:this.app.iframe}))return this.app.iframe&&!this.app.iframeAsRoot&&(b=$(this.app.iframe).offset(),a.left+=b.left,a.top+=b.top),c=this.app.document.selection?0:2,{left:a.left,top:a.top,bottom:a.top+a.height+c}},b.prototype.insert=function(a,b){var c,d,e,f,g;return c=this.$inputor,d=c.val(),e=d.slice(0,Math.max(this.query.headPos-this.at.length,0)),f=""===(f=this.getOpt("suffix"))?f:f||" ",a+=f,g=""+e+a+d.slice(this.query.endPos||0),c.val(g),c.caret("pos",e.length+a.length,{iframe:this.app.iframe}),c.is(":focus")||c.focus(),c.change()},b}(d),f=function(a){function b(){return b.__super__.constructor.apply(this,arguments)}return l(b,a),b.prototype._getRange=function(){var a;return a=this.app.window.getSelection(),a.rangeCount>0?a.getRangeAt(0):void 0},b.prototype._setRange=function(a,b,c){return null==c&&(c=this._getRange()),c?(b=$(b)[0],"after"===a?(c.setEndAfter(b),c.setStartAfter(b)):(c.setEndBefore(b),c.setStartBefore(b)),c.collapse(!1),this._clearRange(c)):void 0},b.prototype._clearRange=function(a){var b;return null==a&&(a=this._getRange()),b=this.app.window.getSelection(),null==this.ctrl_a_pressed?(b.removeAllRanges(),b.addRange(a)):void 0},b.prototype._movingEvent=function(a){var b;return"click"===a.type||(b=a.which)===g.RIGHT||b===g.LEFT||b===g.UP||b===g.DOWN},b.prototype._unwrap=function(a){var b;return a=$(a).unwrap().get(0),(b=a.nextSibling)&&b.nodeValue&&(a.nodeValue+=b.nodeValue,$(b).remove()),a},b.prototype.catchQuery=function(a){var b,c,d,e,f,h,i,j,k,l;if(!this.app.isComposing&&(l=this._getRange())){if(a.which===g.CTRL?this.ctrl_pressed=!0:a.which===g.A?null==this.ctrl_pressed&&(this.ctrl_a_pressed=!0):(delete this.ctrl_a_pressed,delete this.ctrl_pressed),a.which===g.ENTER)return(c=$(l.startContainer).closest(".atwho-query")).contents().unwrap(),c.is(":empty")&&c.remove(),(c=$(".atwho-query",this.app.document)).text(c.text()).contents().last().unwrap(),void this._clearRange();if(/firefox/i.test(navigator.userAgent)){if($(l.startContainer).is(this.$inputor))return void this._clearRange();a.which===g.BACKSPACE&&l.startContainer.nodeType===document.ELEMENT_NODE&&(j=l.startOffset-1)>=0?(d=l.cloneRange(),d.setStart(l.startContainer,j),$(d.cloneContents()).contents().last().is(".atwho-inserted")&&(f=$(l.startContainer).contents().get(j),this._setRange("after",$(f).contents().last()))):a.which===g.LEFT&&l.startContainer.nodeType===document.TEXT_NODE&&(b=$(l.startContainer.previousSibling),b.is(".atwho-inserted")&&0===l.startOffset&&this._setRange("after",b.contents().last()))}return $(l.startContainer).closest(".atwho-inserted").addClass("atwho-query").siblings().removeClass("atwho-query"),(c=$(".atwho-query",this.app.document)).length>0&&c.is(":empty")&&0===c.text().length&&c.remove(),this._movingEvent(a)||c.removeClass("atwho-inserted"),d=l.cloneRange(),d.setStart(l.startContainer,0),i=this.callbacks("matcher").call(this,this.at,d.toString(),this.getOpt("startWithSpace")),0===c.length&&"string"==typeof i&&(e=l.startOffset-this.at.length-i.length)>=0&&(l.setStart(l.startContainer,e),c=$("",this.app.document).attr(this.getOpt("editableAtwhoQueryAttrs")).addClass("atwho-query"),l.surroundContents(c.get(0)),h=c.contents().last().get(0),/firefox/i.test(navigator.userAgent)?(l.setStart(h,h.length),l.setEnd(h,h.length),this._clearRange(l)):this._setRange("after",h,l)),"string"==typeof i&&i.length<=this.getOpt("maxLen",20)?(k={text:i,el:c},this.trigger("matched",[this.at,k.text]),this.query=k):(this.view.hide(),this.query={el:c},c.text().indexOf(this.at)>=0&&(this._movingEvent(a)&&c.hasClass("atwho-inserted")?c.removeClass("atwho-query"):!1!==this.callbacks("afterMatchFailed").call(this,this.at,c)&&this._setRange("after",this._unwrap(c.text(c.text()).contents().first()))),null)}},b.prototype.rect=function(){var a,b,c;return c=this.query.el.offset(),this.app.iframe&&!this.app.iframeAsRoot&&(b=(a=$(this.app.iframe)).offset(),c.left+=b.left-this.$inputor.scrollLeft(),c.top+=b.top-this.$inputor.scrollTop()),c.bottom=c.top+this.query.el.height(),c},b.prototype.insert=function(a,b){var c,d,e;return d=(d=this.getOpt("suffix"))?d:d||" ",this.query.el.removeClass("atwho-query").addClass("atwho-inserted").html(a),(c=this._getRange())&&(c.setEndAfter(this.query.el[0]),c.collapse(!1),c.insertNode(e=this.app.document.createTextNode(d)),this._setRange("after",e,c)),this.$inputor.is(":focus")||this.$inputor.focus(),this.$inputor.change()},b}(d),h=function(){function a(a){this.context=a,this.at=this.context.at,this.storage=this.context.$inputor}return a.prototype.destroy=function(){return this.storage.data(this.at,null)},a.prototype.saved=function(){return this.fetch()>0},a.prototype.query=function(a,b){var c,d,e;return d=this.fetch(),e=this.context.getOpt("searchKey"),d=this.context.callbacks("filter").call(this.context,a,d,e)||[],c=this.context.callbacks("remoteFilter"),d.length>0||!c&&0===d.length?b(d):c.call(this.context,a,b)},a.prototype.fetch=function(){return this.storage.data(this.at)||[]},a.prototype.save=function(a){return this.storage.data(this.at,this.context.callbacks("beforeSave").call(this.context,a||[]))},a.prototype.load=function(a){return!this.saved()&&a?this._load(a):void 0},a.prototype.reload=function(a){return this._load(a)},a.prototype._load=function(a){return"string"==typeof a?$.ajax(a,{dataType:"json"}).done(function(a){return function(b){return a.save(b)}}(this)):this.save(a)},a}(),j=function(){function a(a){this.context=a,this.$el=$("
    "),this.timeoutID=null,this.context.$el.append(this.$el),this.bindEvent()}return a.prototype.init=function(){var a;return a=this.context.getOpt("alias")||this.context.at.charCodeAt(0),this.$el.attr({id:"at-view-"+a})},a.prototype.destroy=function(){return this.$el.remove()},a.prototype.bindEvent=function(){var a;return a=this.$el.find("ul"),a.on("mouseenter.atwho-view","li",function(b){return a.find(".cur").removeClass("cur"),$(b.currentTarget).addClass("cur")}).on("click.atwho-view","li",function(b){return function(c){return a.find(".cur").removeClass("cur"),$(c.currentTarget).addClass("cur"),b.choose(c),c.preventDefault()}}(this))},a.prototype.visible=function(){return this.$el.is(":visible")},a.prototype.highlighted=function(){return this.$el.find(".cur").length>0},a.prototype.choose=function(a){var b,c;return(b=this.$el.find(".cur")).length&&(c=this.context.insertContentFor(b),this.context.insert(this.context.callbacks("beforeInsert").call(this.context,c,b),b),this.context.trigger("inserted",[b,a]),this.hide(a)),this.context.getOpt("hideWithoutSuffix")?this.stopShowing=!0:void 0},a.prototype.reposition=function(a){var b,c,d,e;return b=this.context.app.iframeAsRoot?this.context.app.window:window,a.bottom+this.$el.height()-$(b).scrollTop()>$(b).height()&&(a.bottom=a.top-this.$el.height()),a.left>(d=$(b).width()-this.$el.width()-5)&&(a.left=d),c={left:a.left,top:a.bottom},null!=(e=this.context.callbacks("beforeReposition"))&&e.call(this.context,c),this.$el.offset(c),this.context.trigger("reposition",[c])},a.prototype.next=function(){var a,b;return a=this.$el.find(".cur").removeClass("cur"),b=a.next(),b.length||(b=this.$el.find("li:first")),b.addClass("cur"),this.scrollTop(Math.max(0,a.innerHeight()*(b.index()+2)-this.$el.height()))},a.prototype.prev=function(){var a,b;return a=this.$el.find(".cur").removeClass("cur"),b=a.prev(),b.length||(b=this.$el.find("li:last")),b.addClass("cur"),this.scrollTop(Math.max(0,a.innerHeight()*(b.index()+2)-this.$el.height()))},a.prototype.scrollTop=function(a){var b;return b=this.context.getOpt("scrollDuration"),b?this.$el.animate({scrollTop:a},b):this.$el.scrollTop(a)},a.prototype.show=function(){var a;return this.stopShowing?void(this.stopShowing=!1):(this.visible()||(this.$el.show(),this.$el.scrollTop(0),this.context.trigger("shown")),(a=this.context.rect())?this.reposition(a):void 0)},a.prototype.hide=function(a,b){var c;if(this.visible())return isNaN(b)?(this.$el.hide(),this.context.trigger("hidden",[a])):(c=function(a){return function(){return a.hide()}}(this),clearTimeout(this.timeoutID),this.timeoutID=setTimeout(c,b))},a.prototype.render=function(a){var b,c,d,e,f,g,h;if(!($.isArray(a)&&a.length>0))return void this.hide();for(this.$el.find("ul").empty(),c=this.$el.find("ul"),h=this.context.getOpt("displayTpl"),d=0,f=a.length;f>d;d++)e=a[d],e=$.extend({},e,{"atwho-at":this.context.at}),g=this.context.callbacks("tplEval").call(this.context,h,e,"onDisplay"),b=$(this.context.callbacks("highlighter").call(this.context,g,this.context.query.text)),b.data("item-data",e),c.append(b);return this.show(),this.context.getOpt("highlightFirst")?c.find("li:first").addClass("cur"):void 0},a}(),g={DOWN:40,UP:38,ESC:27,TAB:9,ENTER:13,CTRL:17,A:65,P:80,N:78,LEFT:37,UP:38,RIGHT:39,DOWN:40,BACKSPACE:8,SPACE:32},e={beforeSave:function(a){return d.arrayToDefaultHash(a)},matcher:function(a,b,c,d){var e,f,g,h,i;return a=a.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&"),c&&(a="(?:^|\\s)"+a),e=decodeURI("%C3%80"),f=decodeURI("%C3%BF"),i=d?" ":"",h=new RegExp(a+"([A-Za-z"+e+"-"+f+"0-9_"+i+".+-]*)$|"+a+"([^\\x00-\\xff]*)$","gi"),g=h.exec(b),g?g[2]||g[1]:null},filter:function(a,b,c){var d,e,f,g;for(d=[],e=0,g=b.length;g>e;e++)f=b[e],~new String(f[c]).toLowerCase().indexOf(a.toLowerCase())&&d.push(f);return d},remoteFilter:null,sorter:function(a,b,c){var d,e,f,g;if(!a)return b;for(d=[],e=0,g=b.length;g>e;e++)f=b[e],f.atwho_order=new String(f[c]).toLowerCase().indexOf(a.toLowerCase()),f.atwho_order>-1&&d.push(f);return d.sort(function(a,b){return a.atwho_order-b.atwho_order})},tplEval:function(a,b){var c,d;d=a;try{return"string"!=typeof a&&(d=a(b)),d.replace(/\$\{([^\}]*)\}/g,function(a,c,d){return b[c]})}catch(e){return c=e,""}},highlighter:function(a,b){var c;return b?(c=new RegExp(">\\s*(\\w*?)("+b.replace("+","\\+")+")(\\w*)\\s*<","ig"),a.replace(c,function(a,b,c,d){return"> "+b+""+c+""+d+" <"})):a},beforeInsert:function(a,b){return a},beforeReposition:function(a){return a},afterMatchFailed:function(a,b){}},b={load:function(a,b){var c;return(c=this.controller(a))?c.model.load(b):void 0},isSelecting:function(){var a;return null!=(a=this.controller())?a.view.visible():void 0},hide:function(){var a;return null!=(a=this.controller())?a.view.hide():void 0},reposition:function(){var a;return(a=this.controller())?(a.view.reposition(a.rect()),console.log("reposition",a)):void 0},setIframe:function(a,b){return this.setupRootElement(a,b),null},run:function(){return this.dispatch()},destroy:function(){return this.shutdown(),this.$inputor.data("atwho",null)}},$.fn.atwho=function(a){var d,e;return d=arguments,e=null,this.filter('textarea, input, [contenteditable=""], [contenteditable=true]').each(function(){var f,g;return(g=(f=$(this)).data("atwho"))||f.data("atwho",g=new c(this)),"object"!=typeof a&&a?b[a]&&g?e=b[a].apply(g,Array.prototype.slice.call(d,1)):$.error("Method "+a+" does not exist on jQuery.atwho"):g.reg(a.at,a)}),e||this},$.fn.atwho["default"]={at:void 0,alias:void 0,data:null,displayTpl:"
  • ${name}
  • ",insertTpl:"${atwho-at}${name}",callbacks:e,searchKey:"name",suffix:void 0,hideWithoutSuffix:!1,startWithSpace:!0,highlightFirst:!0,limit:5,maxLen:20,displayTimeout:300,delay:null,spaceSelectsMatch:!1,editableAtwhoQueryAttrs:{},scrollDuration:150},$.fn.atwho.debug=!1}); \ No newline at end of file diff --git a/Themes/default/scripts/jquery.caret.min.js b/Themes/default/scripts/jquery.caret.min.js deleted file mode 100644 index a4d02eae24..0000000000 --- a/Themes/default/scripts/jquery.caret.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! jquery.caret 2015-02-01 */ -!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],function(c){return a.returnExportsGlobal=b(c)}):"object"==typeof exports?module.exports=b(require("jquery")):b(jQuery)}(this,function(a){"use strict";var b,c,d,e,f,g,h,i,j,k,l;k="caret",b=function(){function b(a){this.$inputor=a,this.domInputor=this.$inputor[0]}return b.prototype.setPos=function(){return this.domInputor},b.prototype.getIEPosition=function(){return this.getPosition()},b.prototype.getPosition=function(){var a,b;return b=this.getOffset(),a=this.$inputor.offset(),b.left-=a.left,b.top-=a.top,b},b.prototype.getOldIEPos=function(){var a,b;return b=h.selection.createRange(),a=h.body.createTextRange(),a.moveToElementText(this.domInputor),a.setEndPoint("EndToEnd",b),a.text.length},b.prototype.getPos=function(){var a,b,c;return(c=this.range())?(a=c.cloneRange(),a.selectNodeContents(this.domInputor),a.setEnd(c.endContainer,c.endOffset),b=a.toString().length,a.detach(),b):h.selection?this.getOldIEPos():void 0},b.prototype.getOldIEOffset=function(){var a,b;return a=h.selection.createRange().duplicate(),a.moveStart("character",-1),b=a.getBoundingClientRect(),{height:b.bottom-b.top,left:b.left,top:b.top}},b.prototype.getOffset=function(){var b,c,d,e,f;return j.getSelection&&(d=this.range())?(d.endOffset-1>0&&d.endContainer===!this.domInputor&&(b=d.cloneRange(),b.setStart(d.endContainer,d.endOffset-1),b.setEnd(d.endContainer,d.endOffset),e=b.getBoundingClientRect(),c={height:e.height,left:e.left+e.width,top:e.top},b.detach()),c&&0!==(null!=c?c.height:void 0)||(b=d.cloneRange(),f=a(h.createTextNode("|")),b.insertNode(f[0]),b.selectNode(f[0]),e=b.getBoundingClientRect(),c={height:e.height,left:e.left,top:e.top},f.remove(),b.detach())):h.selection&&(c=this.getOldIEOffset()),c&&(c.top+=a(j).scrollTop(),c.left+=a(j).scrollLeft()),c},b.prototype.range=function(){var a;if(j.getSelection)return a=j.getSelection(),a.rangeCount>0?a.getRangeAt(0):null},b}(),c=function(){function b(a){this.$inputor=a,this.domInputor=this.$inputor[0]}return b.prototype.getIEPos=function(){var a,b,c,d,e,f,g;return b=this.domInputor,f=h.selection.createRange(),e=0,f&&f.parentElement()===b&&(d=b.value.replace(/\r\n/g,"\n"),c=d.length,g=b.createTextRange(),g.moveToBookmark(f.getBookmark()),a=b.createTextRange(),a.collapse(!1),e=g.compareEndPoints("StartToEnd",a)>-1?c:-g.moveStart("character",-c)),e},b.prototype.getPos=function(){return h.selection?this.getIEPos():this.domInputor.selectionStart},b.prototype.setPos=function(a){var b,c;return b=this.domInputor,h.selection?(c=b.createTextRange(),c.move("character",a),c.select()):b.setSelectionRange&&b.setSelectionRange(a,a),b},b.prototype.getIEOffset=function(a){var b,c,d,e;return c=this.domInputor.createTextRange(),a||(a=this.getPos()),c.move("character",a),d=c.boundingLeft,e=c.boundingTop,b=c.boundingHeight,{left:d,top:e,height:b}},b.prototype.getOffset=function(b){var c,d,e;return c=this.$inputor,h.selection?(d=this.getIEOffset(b),d.top+=a(j).scrollTop()+c.scrollTop(),d.left+=a(j).scrollLeft()+c.scrollLeft(),d):(d=c.offset(),e=this.getPosition(b),d={left:d.left+e.left-c.scrollLeft(),top:d.top+e.top-c.scrollTop(),height:e.height})},b.prototype.getPosition=function(a){var b,c,e,f,g,h,i;return b=this.$inputor,f=function(a){return a=a.replace(/<|>|`|"|&/g,"?").replace(/\r\n|\r|\n/g,"
    "),/firefox/i.test(navigator.userAgent)&&(a=a.replace(/\s/g," ")),a},void 0===a&&(a=this.getPos()),i=b.val().slice(0,a),e=b.val().slice(a),g=""+f(i)+"",g+="|",g+=""+f(e)+"",h=new d(b),c=h.create(g).rect()},b.prototype.getIEPosition=function(a){var b,c,d,e,f;return d=this.getIEOffset(a),c=this.$inputor.offset(),e=d.left-c.left,f=d.top-c.top,b=d.height,{left:e,top:f,height:b}},b}(),d=function(){function b(a){this.$inputor=a}return b.prototype.css_attr=["borderBottomWidth","borderLeftWidth","borderRightWidth","borderTopStyle","borderRightStyle","borderBottomStyle","borderLeftStyle","borderTopWidth","boxSizing","fontFamily","fontSize","fontWeight","height","letterSpacing","lineHeight","marginBottom","marginLeft","marginRight","marginTop","outlineWidth","overflow","overflowX","overflowY","paddingBottom","paddingLeft","paddingRight","paddingTop","textAlign","textOverflow","textTransform","whiteSpace","wordBreak","wordWrap"],b.prototype.mirrorCss=function(){var b,c=this;return b={position:"absolute",left:-9999,top:0,zIndex:-2e4},"TEXTAREA"===this.$inputor.prop("tagName")&&this.css_attr.push("width"),a.each(this.css_attr,function(a,d){return b[d]=c.$inputor.css(d)}),b},b.prototype.create=function(b){return this.$mirror=a("
    "),this.$mirror.css(this.mirrorCss()),this.$mirror.html(b),this.$inputor.after(this.$mirror),this},b.prototype.rect=function(){var a,b,c;return a=this.$mirror.find("#caret"),b=a.position(),c={left:b.left,top:b.top,height:a.height()},this.$mirror.remove(),c},b}(),e={contentEditable:function(a){return!(!a[0].contentEditable||"true"!==a[0].contentEditable)}},g={pos:function(a){return a||0===a?this.setPos(a):this.getPos()},position:function(a){return h.selection?this.getIEPosition(a):this.getPosition(a)},offset:function(a){var b;return b=this.getOffset(a)}},h=null,j=null,i=null,l=function(a){var b;return(b=null!=a?a.iframe:void 0)?(i=b,j=b.contentWindow,h=b.contentDocument||j.document):(i=void 0,j=window,h=document)},f=function(a){var b;h=a[0].ownerDocument,j=h.defaultView||h.parentWindow;try{return i=j.frameElement}catch(c){b=c}},a.fn.caret=function(d,f,h){var i;return g[d]?(a.isPlainObject(f)?(l(f),f=void 0):l(h),i=e.contentEditable(this)?new b(this):new c(this),g[d].apply(i,[f])):a.error("Method "+d+" does not exist on jQuery.caret")},a.fn.caret.EditableCaret=b,a.fn.caret.InputCaret=c,a.fn.caret.Utils=e,a.fn.caret.apis=g}); \ No newline at end of file diff --git a/Themes/default/scripts/mentions.js b/Themes/default/scripts/mentions.js index 7ca21fd2e5..27baed0fe5 100644 --- a/Themes/default/scripts/mentions.js +++ b/Themes/default/scripts/mentions.js @@ -1,56 +1,81 @@ -var fails = []; +let fails = []; -var atwhoConfig = { +const atwhoConfig = { at: '@', data: [], show_the_at: true, startWithSpace: true, limit: 10, callbacks: { - remoteFilter: function (query, callback) { - if (typeof query == 'undefined' || query.length < 2 || query.length > 60) - return; + remoteFilter: (query, callback) => { + if (!query || query.length < 2 || query.length > 60) return; - for (i in fails) - if (query.substr(0, fails[i].length) == fails[i]) - return; + // Check if query starts with any failed query prefix + if (fails.some(fail => query.startsWith(fail))) return; - $.ajax({ - url: smf_scripturl + '?action=suggest;' + smf_session_var + '=' + smf_session_id + ';xml', - method: 'GET', + const params = new URLSearchParams({ + action: 'suggest', + search: query, + 'suggest_type': 'member', + [smf_session_var]: smf_session_id + }); + fetch(smf_scripturl + '?' + params + ';xml', { headers: { - "X-SMF-AJAX": 1 - }, - xhrFields: { - withCredentials: typeof allow_xhjr_credentials !== "undefined" ? allow_xhjr_credentials : false + "X-SMF-AJAX": 1, + 'Accept': 'application/xml' }, - data: { - search: query, - suggest_type: 'member' - }, - success: function (data) { - var members = $(data).find('smf > items > item'); - if (members.length == 0) - fails[fails.length] = query; - - var callbackArray = []; - $.each(members, function (index, item) { - callbackArray[callbackArray.length] = { - name: $(item).text() - }; - }); - - callback(callbackArray); + credentials: typeof allow_xhjr_credentials !== "undefined" ? 'include' : 'same-origin' + }) + .then(response => { + if (!response.ok) { + throw new Error("HTTP error " + response.status); } - }); + return response.text(); + }) + .then(responseText => new window.DOMParser().parseFromString(responseText, "text/xml")) + .then(responseXml => { + const members = responseXml.getElementsByTagName('item'); + + if (members.length === 0) { + fails.push(query); // Cache failed queries + } + + let callbackArray = Array.from(members).map(member => ({ name: member.textContent })); + callback(callbackArray); + }) + .catch(error => console.error('Error fetching suggestions:', error)); } } }; -$(function() -{ - $('textarea[name=message]').atwho(atwhoConfig); - $('.sceditor-container').find('textarea').atwho(atwhoConfig); - var iframe = $('.sceditor-container').find('iframe')[0]; - if (typeof iframe != 'undefined') - $(iframe.contentDocument.body).atwho(atwhoConfig); -}); \ No newline at end of file + +window.addEventListener('load', () => { + const textArea = document.querySelector('textarea[name=message]'); + if (typeof sceditor === 'undefined') { + if (textArea) { + atwho(textArea, atwhoConfig); + } + } +}); + +if (typeof sceditor !== 'undefined') { + sceditor.plugins.mentions = function() { + let base = this, + editor; + + base.init = function () { + editor = this; + }; + + base.signalReady = function() { + const sceditor_textarea = editor.getContentAreaContainer().nextSibling; + atwho(sceditor_textarea, atwhoConfig); + + if (!editor.opts.runWithoutWysiwygSupport) { + let iframe = editor.getContentAreaContainer(), + iframeBody = iframe.contentDocument.body; + + atwho(iframeBody, atwhoConfig); + } + }; + } +} \ No newline at end of file