UNPKG

tiny-essentials

Version:

Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.

425 lines (424 loc) 17.7 kB
/** * A full-featured text range editor for `<input>` and `<textarea>` elements, * including advanced utilities for BBCode or similar tag-based markup editing. */ class TinyTextRangeEditor { /** @type {HTMLInputElement | HTMLTextAreaElement} */ #el; /** @type {string} */ #openTag; /** @type {string} */ #closeTag; /** * @param {HTMLInputElement | HTMLTextAreaElement} elem - The target editable input or textarea element. * @param {Object} [settings={}] - Optional tag symbol customization. * @param {string} [settings.openTag='['] - The character or symbol used to start a tag (e.g., `'['`). * @param {string} [settings.closeTag=']'] - The character or symbol used to end a tag (e.g., `']'`). */ constructor(elem, { openTag = '[', closeTag = ']' } = {}) { if (!(elem instanceof HTMLInputElement || elem instanceof HTMLTextAreaElement)) throw new TypeError('Element must be an input or textarea.'); if (typeof openTag !== 'string') throw new TypeError('openTag must be a string.'); if (typeof closeTag !== 'string') throw new TypeError('closeTag must be a string.'); this.#el = elem; this.#openTag = openTag; this.#closeTag = closeTag; } /** @returns {string} The current open tag symbol. */ getOpenTag() { return this.#openTag; } /** @returns {string} The current close tag symbol. */ getCloseTag() { return this.#closeTag; } /** @param {string} tag - New open tag symbol to use (e.g., `'['`). */ setOpenTag(tag) { if (typeof tag !== 'string') throw new TypeError('Open tag must be a string.'); this.#openTag = tag; } /** @param {string} tag - New close tag symbol to use (e.g., `']'`). */ setCloseTag(tag) { if (typeof tag !== 'string') throw new TypeError('Close tag must be a string.'); this.#closeTag = tag; } /** * Ensures the element has focus. * @returns {TinyTextRangeEditor} */ ensureFocus() { if (document.activeElement !== this.#el) this.#el.focus(); return this; } /** * Focus the element. * @returns {TinyTextRangeEditor} */ focus() { this.#el.focus(); return this; } /** @returns {{ start: number, end: number }} The current selection range. */ getSelectionRange() { return { start: this.#el.selectionStart ?? NaN, end: this.#el.selectionEnd ?? NaN, }; } /** * Sets the current selection range. * @param {number} start - Start index. * @param {number} end - End index. * @param {boolean} [preserveScroll=true] - Whether to preserve scroll position. * @returns {TinyTextRangeEditor} */ setSelectionRange(start, end, preserveScroll = true) { if (typeof start !== 'number' || typeof end !== 'number') throw new TypeError('start and end must be numbers.'); if (typeof preserveScroll !== 'boolean') throw new TypeError('preserveScroll must be a boolean.'); const scrollTop = this.#el.scrollTop; const scrollLeft = this.#el.scrollLeft; this.#el.setSelectionRange(start, end); if (preserveScroll) { this.#el.scrollTop = scrollTop; this.#el.scrollLeft = scrollLeft; } return this; } /** @returns {string} The full current text value. */ getValue() { return this.#el.value; } /** * Sets the full value of the element. * @param {string} value - The new value to assign. * @returns {TinyTextRangeEditor} */ setValue(value) { if (typeof value !== 'string') throw new TypeError('Value must be a string.'); this.#el.value = value; return this; } /** @returns {string} The currently selected text. */ getSelectedText() { const { start, end } = this.getSelectionRange(); return this.#el.value.slice(start, end); } /** * Inserts text at the current selection, replacing any selected content. * @param {string} text - The text to insert. * @param {Object} [settings={}] - Optional auto-spacing behavior. * @param {'start' | 'end' | 'preserve'} [settings.newCursor='end'] - Controls caret position after insertion. * @param {boolean} [settings.autoSpacing=false] * @param {boolean} [settings.autoSpaceLeft=false] * @param {boolean} [settings.autoSpaceRight=false] * @returns {TinyTextRangeEditor} */ insertText(text, { newCursor = 'end', autoSpacing = false, autoSpaceLeft = autoSpacing, autoSpaceRight = autoSpacing, } = {}) { if (typeof text !== 'string') throw new TypeError('Text must be a string.'); if (!['start', 'end', 'preserve'].includes(newCursor)) throw new TypeError("newCursor must be one of 'start', 'end', or 'preserve'."); if (typeof autoSpacing !== 'boolean') throw new TypeError('autoSpacing must be a boolean.'); if (typeof autoSpaceLeft !== 'boolean') throw new TypeError('autoSpaceLeft must be a boolean.'); if (typeof autoSpaceRight !== 'boolean') throw new TypeError('autoSpaceRight must be a boolean.'); const { start, end } = this.getSelectionRange(); const value = this.#el.value; const leftChar = value[start - 1] || ''; const rightChar = value[end] || ''; const addLeft = autoSpaceLeft && leftChar && !/\s/.test(leftChar); const addRight = autoSpaceRight && rightChar && !/\s/.test(rightChar); const finalText = `${addLeft ? ' ' : ''}${text}${addRight ? ' ' : ''}`; const newValue = value.slice(0, start) + finalText + value.slice(end); this.setValue(newValue); let cursorPos = start; if (newCursor === 'end') cursorPos = start + finalText.length; else if (newCursor === 'preserve') cursorPos = start; this.setSelectionRange(cursorPos, cursorPos); return this; } /** * Deletes the currently selected text. * @returns {TinyTextRangeEditor} */ deleteSelection() { this.insertText(''); return this; } /** * Replaces the selection using a transformation function. * @param {(selected: string) => string} transformer - Function that modifies the selected text. * @returns {TinyTextRangeEditor} */ transformSelection(transformer) { if (typeof transformer !== 'function') throw new TypeError('transformer must be a function.'); const { start } = this.getSelectionRange(); const selected = this.getSelectedText(); const transformed = transformer(selected); this.insertText(transformed); this.setSelectionRange(start, start + transformed.length); return this; } /** * Surrounds current selection with prefix and suffix. * @param {string} prefix - Text to insert before. * @param {string} suffix - Text to insert after. * @returns {TinyTextRangeEditor} */ surroundSelection(prefix, suffix) { if (typeof prefix !== 'string' || typeof suffix !== 'string') throw new TypeError('prefix and suffix must be strings.'); const selected = this.getSelectedText(); this.insertText(`${prefix}${selected}${suffix}`); return this; } /** * Moves the caret by a given offset. * @param {number} offset - Characters to move. * @returns {TinyTextRangeEditor} */ moveCaret(offset) { if (typeof offset !== 'number') throw new TypeError('offset must be a number.'); const { start } = this.getSelectionRange(); const pos = Math.max(0, start + offset); this.setSelectionRange(pos, pos); return this; } /** * Selects all content in the field. * @returns {TinyTextRangeEditor} */ selectAll() { this.setSelectionRange(0, this.#el.value.length); return this; } /** * Expands the current selection by character amounts. * @param {number} before - Characters to expand to the left. * @param {number} after - Characters to expand to the right. * @returns {TinyTextRangeEditor} */ expandSelection(before, after) { if (typeof before !== 'number' || typeof after !== 'number') throw new TypeError('before and after must be numbers.'); const { start, end } = this.getSelectionRange(); const newStart = Math.max(0, start - before); const newEnd = Math.min(this.#el.value.length, end + after); this.setSelectionRange(newStart, newEnd); return this; } /** * Replaces all regex matches in the content. * @param {RegExp} regex - Regex to match. * @param {(match: string) => string} replacer - Replacement function. * @returns {TinyTextRangeEditor} */ replaceAll(regex, replacer) { if (!(regex instanceof RegExp)) throw new TypeError('regex must be a RegExp.'); if (typeof replacer !== 'function') throw new TypeError('replacer must be a function.'); const newValue = this.#el.value.replace(regex, replacer); this.setValue(newValue); return this; } /** * Replaces all regex matches within the currently selected text. * * @param {RegExp} regex - Regular expression to match inside selection. * @param {(match: string) => string} replacer - Function to replace each match. * @returns {TinyTextRangeEditor} */ replaceInSelection(regex, replacer) { if (!(regex instanceof RegExp)) throw new TypeError('regex must be a RegExp.'); if (typeof replacer !== 'function') throw new TypeError('replacer must be a function.'); const { start, end } = this.getSelectionRange(); const original = this.#el.value; const selected = original.slice(start, end); const replaced = selected.replace(regex, replacer); const updated = original.slice(0, start) + replaced + original.slice(end); this.setValue(updated); this.setSelectionRange(start, start + replaced.length); return this; } /** * Toggles a code around the current selection. * If it's already wrapped, unwraps it. * @param {string} codeName - The code to toggle. * @returns {TinyTextRangeEditor} */ toggleCode(codeName) { if (typeof codeName !== 'string') throw new TypeError('codeName must be a string.'); const selected = this.getSelectedText(); if (selected.startsWith(codeName) && selected.endsWith(codeName)) { const unwrapped = selected.slice(codeName.length, selected.length - codeName.length); this.insertText(unwrapped); } else { this.insertText(`${codeName}${selected}${codeName}`); } return this; } /** * Converts a list of attributes into a string suitable for tag insertion. * * This method supports both standard key-value attribute objects (e.g., `{ key: "value" }`) * and boolean-style attribute arrays (e.g., `[ "disabled", "autofocus" ]`). * * - Attributes passed as an array will render as boolean attributes (e.g., `disabled autofocus`) * - Attributes passed as an object will render as `key="value"` pairs (or just `key` if the value is an empty string) * * @param {Record<string, string> | string[]} attributes - The attributes to serialize into a tag string. * - If an array: treated as a list of boolean-style attributes. * - If an object: treated as key-value pairs. * * @throws {TypeError} If the array contains non-strings, or the object contains non-string values. * @returns {string} A string of serialized attributes for use inside a tag. * * @example * // Using object attributes * _insertAttr({ size: "12", color: "red" }); * // Returns: 'size="12" color="red"' * * @example * // Using boolean attributes * _insertAttr(["disabled", "autofocus"]); * // Returns: 'disabled autofocus' * * @example * // Using mixed/empty object values * _insertAttr({ checked: "", class: "btn" }); * // Returns: 'checked class="btn"' */ _insertAttr(attributes) { // Reuse attribute logic let attrStr = ''; if (Array.isArray(attributes)) { // string[] if (!attributes.every((attr) => typeof attr === 'string')) throw new TypeError('All entries in attributes array must be strings.'); attrStr = attributes.map((attr) => `${attr}`).join(' '); } else if (typeof attributes === 'object' && attributes !== null) { // Record<string, string> attrStr = Object.entries(attributes) .map(([key, val]) => { if (typeof val !== 'string') throw new TypeError('All entries in attributes object must be strings.'); return `${key}${val.length > 0 ? `="${val}"` : ''}`; }) .join(' '); } else { throw new TypeError('attributes must be an object or an array of strings.'); } return attrStr; } /** * Wraps the current selection with a tag, optionally including attributes. * * @param {string} tagName - The tag name (e.g., `b`, `color`, etc.). * @param {Record<string,string> | string[]} [attributes={}] - Optional attributes for the opening tag. * - If an object: key-value pairs (e.g., `{ color: "red" }` → `color="red"`). * - If an array: boolean attributes (e.g., `["disabled", "readonly"]`). * * @returns {TinyTextRangeEditor} */ wrapWithTag(tagName, attributes = {}) { if (typeof tagName !== 'string') throw new TypeError('tagName must be a string.'); const attrStr = this._insertAttr(attributes); const openTag = attrStr ? `${this.#openTag}${tagName} ${attrStr}${this.#closeTag}` : `${this.#openTag}${tagName}${this.#closeTag}`; const closeTag = `${this.#openTag}/${tagName}${this.#closeTag}`; this.surroundSelection(openTag, closeTag); return this; } /** * Inserts a tag with optional inner content. * @param {string} tagName - The tag to insert. * @param {string} [content=''] - Optional content between tags. * @param {Record<string,string> | string[]} [attributes={}] - Optional attributes or list of empty attributes. * @returns {TinyTextRangeEditor} */ insertTag(tagName, content = '', attributes = {}) { if (typeof tagName !== 'string') throw new TypeError('tagName must be a string.'); if (typeof content !== 'string') throw new TypeError('content must be a string.'); const attrStr = this._insertAttr(attributes); const open = attrStr ? `${this.#openTag}${tagName} ${attrStr}${this.#closeTag}` : `${this.#openTag}${tagName}${this.#closeTag}`; const close = `${this.#openTag}/${tagName}${this.#closeTag}`; this.insertText(`${open}${content}${close}`); return this; } /** * Inserts a self-closing tag. * @param {string} tagName - The tag name. * @param {Record<string,string> | string[]} [attributes={}] - Optional attributes or list of empty attributes. * @returns {TinyTextRangeEditor} */ insertSelfClosingTag(tagName, attributes = {}) { if (typeof tagName !== 'string') throw new TypeError('tagName must be a string.'); const attrStr = this._insertAttr(attributes); const tag = attrStr ? `${this.#openTag}${tagName} ${attrStr}${this.#closeTag}` : `${this.#openTag}${tagName}${this.#closeTag}`; this.insertText(tag); return this; } /** * Toggles a tag around the current selection. * Supports tags with attributes. If already wrapped, it unwraps. * @param {string} tagName - The tag to toggle. * @param {Record<string,string> | string[]} [attributes={}] - Optional attributes to apply when wrapping. * @returns {TinyTextRangeEditor} */ toggleTag(tagName, attributes = {}) { if (typeof tagName !== 'string') throw new TypeError('tagName must be a string.'); const selected = this.getSelectedText(); // Regex: opening tag with optional attributes, and closing tag const openRegex = new RegExp(`^\\[${tagName}(\\s+[^\\]]*)?\\]`); const closeRegex = new RegExp(`\\[/${tagName}\\]$`); const hasOpen = openRegex.test(selected); const hasClose = closeRegex.test(selected); if (hasOpen && hasClose) { const unwrapped = selected .replace(openRegex, '') // remove opening tag .replace(closeRegex, ''); // remove closing tag this.insertText(unwrapped); } else { const attrStr = this._insertAttr(attributes); const open = attrStr ? `${this.#openTag}${tagName} ${attrStr}${this.#closeTag}` : `${this.#openTag}${tagName}${this.#closeTag}`; const close = `${this.#openTag}/${tagName}${this.#closeTag}`; this.insertText(`${open}${selected}${close}`); } return this; } } export default TinyTextRangeEditor;