code.gitea.io/gitea@v1.22.3/web_src/js/utils/dom.js (about)

     1  import {debounce} from 'throttle-debounce';
     2  
     3  function elementsCall(el, func, ...args) {
     4    if (typeof el === 'string' || el instanceof String) {
     5      el = document.querySelectorAll(el);
     6    }
     7    if (el instanceof Node) {
     8      func(el, ...args);
     9    } else if (el.length !== undefined) {
    10      // this works for: NodeList, HTMLCollection, Array, jQuery
    11      for (const e of el) {
    12        func(e, ...args);
    13      }
    14    } else {
    15      throw new Error('invalid argument to be shown/hidden');
    16    }
    17  }
    18  
    19  /**
    20   * @param el string (selector), Node, NodeList, HTMLCollection, Array or jQuery
    21   * @param force force=true to show or force=false to hide, undefined to toggle
    22   */
    23  function toggleShown(el, force) {
    24    if (force === true) {
    25      el.classList.remove('tw-hidden');
    26    } else if (force === false) {
    27      el.classList.add('tw-hidden');
    28    } else if (force === undefined) {
    29      el.classList.toggle('tw-hidden');
    30    } else {
    31      throw new Error('invalid force argument');
    32    }
    33  }
    34  
    35  export function showElem(el) {
    36    elementsCall(el, toggleShown, true);
    37  }
    38  
    39  export function hideElem(el) {
    40    elementsCall(el, toggleShown, false);
    41  }
    42  
    43  export function toggleElem(el, force) {
    44    elementsCall(el, toggleShown, force);
    45  }
    46  
    47  export function isElemHidden(el) {
    48    const res = [];
    49    elementsCall(el, (e) => res.push(e.classList.contains('tw-hidden')));
    50    if (res.length > 1) throw new Error(`isElemHidden doesn't work for multiple elements`);
    51    return res[0];
    52  }
    53  
    54  function applyElemsCallback(elems, fn) {
    55    if (fn) {
    56      for (const el of elems) {
    57        fn(el);
    58      }
    59    }
    60    return elems;
    61  }
    62  
    63  export function queryElemSiblings(el, selector = '*', fn) {
    64    return applyElemsCallback(Array.from(el.parentNode.children).filter((child) => child !== el && child.matches(selector)), fn);
    65  }
    66  
    67  // it works like jQuery.children: only the direct children are selected
    68  export function queryElemChildren(parent, selector = '*', fn) {
    69    return applyElemsCallback(parent.querySelectorAll(`:scope > ${selector}`), fn);
    70  }
    71  
    72  export function queryElems(selector, fn) {
    73    return applyElemsCallback(document.querySelectorAll(selector), fn);
    74  }
    75  
    76  export function onDomReady(cb) {
    77    if (document.readyState === 'loading') {
    78      document.addEventListener('DOMContentLoaded', cb);
    79    } else {
    80      cb();
    81    }
    82  }
    83  
    84  // checks whether an element is owned by the current document, and whether it is a document fragment or element node
    85  // if it is, it means it is a "normal" element managed by us, which can be modified safely.
    86  export function isDocumentFragmentOrElementNode(el) {
    87    try {
    88      return el.ownerDocument === document && el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
    89    } catch {
    90      // in case the el is not in the same origin, then the access to nodeType would fail
    91      return false;
    92    }
    93  }
    94  
    95  // autosize a textarea to fit content. Based on
    96  // https://github.com/github/textarea-autosize
    97  // ---------------------------------------------------------------------
    98  // Copyright (c) 2018 GitHub, Inc.
    99  //
   100  // Permission is hereby granted, free of charge, to any person obtaining
   101  // a copy of this software and associated documentation files (the
   102  // "Software"), to deal in the Software without restriction, including
   103  // without limitation the rights to use, copy, modify, merge, publish,
   104  // distribute, sublicense, and/or sell copies of the Software, and to
   105  // permit persons to whom the Software is furnished to do so, subject to
   106  // the following conditions:
   107  //
   108  // The above copyright notice and this permission notice shall be
   109  // included in all copies or substantial portions of the Software.
   110  // ---------------------------------------------------------------------
   111  export function autosize(textarea, {viewportMarginBottom = 0} = {}) {
   112    let isUserResized = false;
   113    // lastStyleHeight and initialStyleHeight are CSS values like '100px'
   114    let lastMouseX, lastMouseY, lastStyleHeight, initialStyleHeight;
   115  
   116    function onUserResize(event) {
   117      if (isUserResized) return;
   118      if (lastMouseX !== event.clientX || lastMouseY !== event.clientY) {
   119        const newStyleHeight = textarea.style.height;
   120        if (lastStyleHeight && lastStyleHeight !== newStyleHeight) {
   121          isUserResized = true;
   122        }
   123        lastStyleHeight = newStyleHeight;
   124      }
   125  
   126      lastMouseX = event.clientX;
   127      lastMouseY = event.clientY;
   128    }
   129  
   130    function overflowOffset() {
   131      let offsetTop = 0;
   132      let el = textarea;
   133  
   134      while (el !== document.body && el !== null) {
   135        offsetTop += el.offsetTop || 0;
   136        el = el.offsetParent;
   137      }
   138  
   139      const top = offsetTop - document.defaultView.scrollY;
   140      const bottom = document.documentElement.clientHeight - (top + textarea.offsetHeight);
   141      return {top, bottom};
   142    }
   143  
   144    function resizeToFit() {
   145      if (isUserResized) return;
   146      if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return;
   147  
   148      try {
   149        const {top, bottom} = overflowOffset();
   150        const isOutOfViewport = top < 0 || bottom < 0;
   151  
   152        const computedStyle = getComputedStyle(textarea);
   153        const topBorderWidth = parseFloat(computedStyle.borderTopWidth);
   154        const bottomBorderWidth = parseFloat(computedStyle.borderBottomWidth);
   155        const isBorderBox = computedStyle.boxSizing === 'border-box';
   156        const borderAddOn = isBorderBox ? topBorderWidth + bottomBorderWidth : 0;
   157  
   158        const adjustedViewportMarginBottom = bottom < viewportMarginBottom ? bottom : viewportMarginBottom;
   159        const curHeight = parseFloat(computedStyle.height);
   160        const maxHeight = curHeight + bottom - adjustedViewportMarginBottom;
   161  
   162        textarea.style.height = 'auto';
   163        let newHeight = textarea.scrollHeight + borderAddOn;
   164  
   165        if (isOutOfViewport) {
   166          // it is already out of the viewport:
   167          // * if the textarea is expanding: do not resize it
   168          if (newHeight > curHeight) {
   169            newHeight = curHeight;
   170          }
   171          // * if the textarea is shrinking, shrink line by line (just use the
   172          //   scrollHeight). do not apply max-height limit, otherwise the page
   173          //   flickers and the textarea jumps
   174        } else {
   175          // * if it is in the viewport, apply the max-height limit
   176          newHeight = Math.min(maxHeight, newHeight);
   177        }
   178  
   179        textarea.style.height = `${newHeight}px`;
   180        lastStyleHeight = textarea.style.height;
   181      } finally {
   182        // ensure that the textarea is fully scrolled to the end, when the cursor
   183        // is at the end during an input event
   184        if (textarea.selectionStart === textarea.selectionEnd &&
   185            textarea.selectionStart === textarea.value.length) {
   186          textarea.scrollTop = textarea.scrollHeight;
   187        }
   188      }
   189    }
   190  
   191    function onFormReset() {
   192      isUserResized = false;
   193      if (initialStyleHeight !== undefined) {
   194        textarea.style.height = initialStyleHeight;
   195      } else {
   196        textarea.style.removeProperty('height');
   197      }
   198    }
   199  
   200    textarea.addEventListener('mousemove', onUserResize);
   201    textarea.addEventListener('input', resizeToFit);
   202    textarea.form?.addEventListener('reset', onFormReset);
   203    initialStyleHeight = textarea.style.height ?? undefined;
   204    if (textarea.value) resizeToFit();
   205  
   206    return {
   207      resizeToFit,
   208      destroy() {
   209        textarea.removeEventListener('mousemove', onUserResize);
   210        textarea.removeEventListener('input', resizeToFit);
   211        textarea.form?.removeEventListener('reset', onFormReset);
   212      },
   213    };
   214  }
   215  
   216  export function onInputDebounce(fn) {
   217    return debounce(300, fn);
   218  }
   219  
   220  // Set the `src` attribute on an element and returns a promise that resolves once the element
   221  // has loaded or errored. Suitable for all elements mention in:
   222  // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/load_event
   223  export function loadElem(el, src) {
   224    return new Promise((resolve) => {
   225      el.addEventListener('load', () => resolve(true), {once: true});
   226      el.addEventListener('error', () => resolve(false), {once: true});
   227      el.src = src;
   228    });
   229  }
   230  
   231  // some browsers like PaleMoon don't have "SubmitEvent" support, so polyfill it by a tricky method: use the last clicked button as submitter
   232  // it can't use other transparent polyfill patches because PaleMoon also doesn't support "addEventListener(capture)"
   233  const needSubmitEventPolyfill = typeof SubmitEvent === 'undefined';
   234  
   235  export function submitEventSubmitter(e) {
   236    e = e.originalEvent ?? e; // if the event is wrapped by jQuery, use "originalEvent", otherwise, use the event itself
   237    return needSubmitEventPolyfill ? (e.target._submitter || null) : e.submitter;
   238  }
   239  
   240  function submitEventPolyfillListener(e) {
   241    const form = e.target.closest('form');
   242    if (!form) return;
   243    form._submitter = e.target.closest('button:not([type]), button[type="submit"], input[type="submit"]');
   244  }
   245  
   246  export function initSubmitEventPolyfill() {
   247    if (!needSubmitEventPolyfill) return;
   248    console.warn(`This browser doesn't have "SubmitEvent" support, use a tricky method to polyfill`);
   249    document.body.addEventListener('click', submitEventPolyfillListener);
   250    document.body.addEventListener('focus', submitEventPolyfillListener);
   251  }
   252  
   253  /**
   254   * Check if an element is visible, equivalent to jQuery's `:visible` pseudo.
   255   * Note: This function doesn't account for all possible visibility scenarios.
   256   * @param {HTMLElement} element The element to check.
   257   * @returns {boolean} True if the element is visible.
   258   */
   259  export function isElemVisible(element) {
   260    if (!element) return false;
   261  
   262    return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
   263  }
   264  
   265  // extract text and images from "paste" event
   266  export function getPastedContent(e) {
   267    const images = [];
   268    for (const item of e.clipboardData?.items ?? []) {
   269      if (item.type?.startsWith('image/')) {
   270        images.push(item.getAsFile());
   271      }
   272    }
   273    const text = e.clipboardData?.getData?.('text') ?? '';
   274    return {text, images};
   275  }
   276  
   277  // replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
   278  export function replaceTextareaSelection(textarea, text) {
   279    const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
   280    const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
   281    let success = true;
   282  
   283    textarea.contentEditable = 'true';
   284    try {
   285      success = document.execCommand('insertText', false, text);
   286    } catch {
   287      success = false;
   288    }
   289    textarea.contentEditable = 'false';
   290  
   291    if (success && !textarea.value.slice(0, textarea.selectionStart ?? undefined).endsWith(text)) {
   292      success = false;
   293    }
   294  
   295    if (!success) {
   296      textarea.value = `${before}${text}${after}`;
   297      textarea.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true}));
   298    }
   299  }
   300  
   301  // Warning: Do not enter any unsanitized variables here
   302  export function createElementFromHTML(htmlString) {
   303    const div = document.createElement('div');
   304    div.innerHTML = htmlString.trim();
   305    return div.firstChild;
   306  }