diff options
Diffstat (limited to 'web_src/js/utils')
-rw-r--r-- | web_src/js/utils/color.ts | 7 | ||||
-rw-r--r-- | web_src/js/utils/dom.ts | 58 | ||||
-rw-r--r-- | web_src/js/utils/html.test.ts | 8 | ||||
-rw-r--r-- | web_src/js/utils/html.ts | 32 | ||||
-rw-r--r-- | web_src/js/utils/image.ts | 4 | ||||
-rw-r--r-- | web_src/js/utils/time.ts | 4 | ||||
-rw-r--r-- | web_src/js/utils/url.ts | 4 |
7 files changed, 88 insertions, 29 deletions
diff --git a/web_src/js/utils/color.ts b/web_src/js/utils/color.ts index a0409353d2..57c909b8a0 100644 --- a/web_src/js/utils/color.ts +++ b/web_src/js/utils/color.ts @@ -1,7 +1,7 @@ import tinycolor from 'tinycolor2'; import type {ColorInput} from 'tinycolor2'; -// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance +/** Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance */ // Keep this in sync with modules/util/color.go function getRelativeLuminance(color: ColorInput): number { const {r, g, b} = tinycolor(color).toRgb(); @@ -12,8 +12,9 @@ function useLightText(backgroundColor: ColorInput): boolean { return getRelativeLuminance(backgroundColor) < 0.453; } -// Given a background color, returns a black or white foreground color that the highest -// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better. +/** Given a background color, returns a black or white foreground color that the highest + * contrast ratio. */ +// In the future, the APCA contrast function, or CSS `contrast-color` will be better. // https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42 export function contrastColor(backgroundColor: ColorInput): string { return useLightText(backgroundColor) ? '#fff' : '#000'; diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 83a0d9c8df..8b7219c678 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -25,7 +25,7 @@ function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: a throw new Error('invalid argument to be shown/hidden'); } -export function toggleClass(el: ElementArg, className: string, force?: boolean): ArrayLikeIterable<Element> { +export function toggleElemClass(el: ElementArg, className: string, force?: boolean): ArrayLikeIterable<Element> { return elementsCall(el, (e: Element) => { if (force === true) { e.classList.add(className); @@ -44,7 +44,7 @@ export function toggleClass(el: ElementArg, className: string, force?: boolean): * @param force force=true to show or force=false to hide, undefined to toggle */ export function toggleElem(el: ElementArg, force?: boolean): ArrayLikeIterable<Element> { - return toggleClass(el, 'tw-hidden', force === undefined ? force : !force); + return toggleElemClass(el, 'tw-hidden', force === undefined ? force : !force); } export function showElem(el: ElementArg): ArrayLikeIterable<Element> { @@ -71,7 +71,7 @@ export function queryElemSiblings<T extends Element>(el: Element, selector = '*' }), fn); } -// it works like jQuery.children: only the direct children are selected +/** it works like jQuery.children: only the direct children are selected */ export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> { if (isInFrontendUnitTest()) { // https://github.com/capricorn86/happy-dom/issues/1620 : ":scope" doesn't work @@ -81,7 +81,7 @@ export function queryElemChildren<T extends Element>(parent: Element | ParentNod return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn); } -// it works like parent.querySelectorAll: all descendants are selected +/** it works like parent.querySelectorAll: all descendants are selected */ // in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent if the targets are not for page-level components. export function queryElems<T extends HTMLElement>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback<T>): ArrayLikeIterable<T> { return applyElemsCallback<T>(parent.querySelectorAll(selector), fn); @@ -95,8 +95,8 @@ export function onDomReady(cb: () => Promisable<void>) { } } -// checks whether an element is owned by the current document, and whether it is a document fragment or element node -// if it is, it means it is a "normal" element managed by us, which can be modified safely. +/** checks whether an element is owned by the current document, and whether it is a document fragment or element node + * if it is, it means it is a "normal" element managed by us, which can be modified safely. */ export function isDocumentFragmentOrElementNode(el: Node) { try { return el.ownerDocument === document && el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.DOCUMENT_FRAGMENT_NODE; @@ -106,8 +106,8 @@ export function isDocumentFragmentOrElementNode(el: Node) { } } -// autosize a textarea to fit content. Based on -// https://github.com/github/textarea-autosize +/** autosize a textarea to fit content. */ +// Based on https://github.com/github/textarea-autosize // --------------------------------------------------------------------- // Copyright (c) 2018 GitHub, Inc. // @@ -161,6 +161,7 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = function resizeToFit() { if (isUserResized) return; if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return; + const previousMargin = textarea.style.marginBottom; try { const {top, bottom} = overflowOffset(); @@ -176,6 +177,9 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = const curHeight = parseFloat(computedStyle.height); const maxHeight = curHeight + bottom - adjustedViewportMarginBottom; + // In Firefox, setting auto height momentarily may cause the page to scroll up + // unexpectedly, prevent this by setting a temporary margin. + textarea.style.marginBottom = `${textarea.clientHeight}px`; textarea.style.height = 'auto'; let newHeight = textarea.scrollHeight + borderAddOn; @@ -196,6 +200,12 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = textarea.style.height = `${newHeight}px`; lastStyleHeight = textarea.style.height; } finally { + // restore previous margin + if (previousMargin) { + textarea.style.marginBottom = previousMargin; + } else { + textarea.style.removeProperty('margin-bottom'); + } // ensure that the textarea is fully scrolled to the end, when the cursor // is at the end during an input event if (textarea.selectionStart === textarea.selectionEnd && @@ -236,8 +246,8 @@ export function onInputDebounce(fn: () => Promisable<any>) { type LoadableElement = HTMLEmbedElement | HTMLIFrameElement | HTMLImageElement | HTMLScriptElement | HTMLTrackElement; -// Set the `src` attribute on an element and returns a promise that resolves once the element -// has loaded or errored. +/** Set the `src` attribute on an element and returns a promise that resolves once the element + * has loaded or errored. */ export function loadElem(el: LoadableElement, src: string) { return new Promise((resolve) => { el.addEventListener('load', () => resolve(true), {once: true}); @@ -273,21 +283,19 @@ export function isElemVisible(el: HTMLElement): boolean { // This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem" if (!el) return false; // checking el.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout - return !el.classList.contains('tw-hidden') && Boolean((el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none'); + return !el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none'; } -// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this +/** replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this */ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) { const before = textarea.value.slice(0, textarea.selectionStart ?? undefined); const after = textarea.value.slice(textarea.selectionEnd ?? undefined); - let success = true; + let success = false; textarea.contentEditable = 'true'; try { success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated - } catch { - success = false; - } + } catch {} // ignore the error if execCommand is not supported or failed textarea.contentEditable = 'false'; if (success && !textarea.value.slice(0, textarea.selectionStart ?? undefined).endsWith(text)) { @@ -300,10 +308,10 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st } } -// Warning: Do not enter any unsanitized variables here export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T { htmlString = htmlString.trim(); - // some tags like "tr" are special, it must use a correct parent container to create + // There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js + // eslint-disable-next-line github/unescaped-html-literal if (htmlString.startsWith('<tr')) { const container = document.createElement('table'); container.innerHTML = htmlString; @@ -353,9 +361,19 @@ export function addDelegatedEventListener<T extends HTMLElement, E extends Event const elem = (e.target as HTMLElement).closest(selector); // It strictly checks "parent contains the target elem" to avoid side effects of selector running on outside the parent. // Keep in mind that the elem could have been removed from parent by other event handlers before this event handler is called. - // For example: tippy popup item, the tippy popup could be hidden and removed from DOM before this. - // It is caller's responsibility make sure the elem is still in parent's DOM when this event handler is called. + // For example, tippy popup item, the tippy popup could be hidden and removed from DOM before this. + // It is the caller's responsibility to make sure the elem is still in parent's DOM when this event handler is called. if (!elem || (parent !== document && !parent.contains(elem))) return; listener(elem as T, e as E); }, options); } + +/** Returns whether a click event is a left-click without any modifiers held */ +export function isPlainClick(e: MouseEvent) { + return e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; +} + +let elemIdCounter = 0; +export function generateElemId(prefix: string = ''): string { + return `${prefix}${elemIdCounter++}`; +} diff --git a/web_src/js/utils/html.test.ts b/web_src/js/utils/html.test.ts new file mode 100644 index 0000000000..3028b7bb0a --- /dev/null +++ b/web_src/js/utils/html.test.ts @@ -0,0 +1,8 @@ +import {html, htmlEscape, htmlRaw} from './html.ts'; + +test('html', async () => { + expect(html`<a>${'<>&\'"'}</a>`).toBe(`<a><>&'"</a>`); + expect(html`<a>${htmlRaw('<img>')}</a>`).toBe(`<a><img></a>`); + expect(html`<a>${htmlRaw`<img ${'&'}>`}</a>`).toBe(`<a><img &></a>`); + expect(htmlEscape(`<a></a>`)).toBe(`<a></a>`); +}); diff --git a/web_src/js/utils/html.ts b/web_src/js/utils/html.ts new file mode 100644 index 0000000000..1252032ee0 --- /dev/null +++ b/web_src/js/utils/html.ts @@ -0,0 +1,32 @@ +export function htmlEscape(s: string, ...args: Array<any>): string { + if (args.length !== 0) throw new Error('use html or htmlRaw instead of htmlEscape'); // check legacy usages + return s.replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +class rawObject { + private readonly value: string; + constructor(v: string) { this.value = v } + toString(): string { return this.value } +} + +export function html(tmpl: TemplateStringsArray, ...parts: Array<any>): string { + let output = tmpl[0]; + for (let i = 0; i < parts.length; i++) { + const value = parts[i]; + const valueEscaped = (value instanceof rawObject) ? value.toString() : htmlEscape(String(value)); + output = output + valueEscaped + tmpl[i + 1]; + } + return output; +} + +export function htmlRaw(s: string|TemplateStringsArray, ...tmplParts: Array<any>): rawObject { + if (typeof s === 'string') { + if (tmplParts.length !== 0) throw new Error("either htmlRaw('str') or htmlRaw`tmpl`"); + return new rawObject(s); + } + return new rawObject(html(s, ...tmplParts)); +} diff --git a/web_src/js/utils/image.ts b/web_src/js/utils/image.ts index 558a63f22e..5cd5052b40 100644 --- a/web_src/js/utils/image.ts +++ b/web_src/js/utils/image.ts @@ -29,8 +29,8 @@ type ImageInfo = { dppx?: number, } -// decode a image and try to obtain width and dppx. It will never throw but instead -// return default values. +/** decode a image and try to obtain width and dppx. It will never throw but instead + * return default values. */ export async function imageInfo(blob: Blob): Promise<ImageInfo> { let width = 0, dppx = 1; // dppx: 1 dot per pixel for non-HiDPI screens diff --git a/web_src/js/utils/time.ts b/web_src/js/utils/time.ts index c63498345f..262cc23a52 100644 --- a/web_src/js/utils/time.ts +++ b/web_src/js/utils/time.ts @@ -65,8 +65,8 @@ export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayDataO let dateFormat: Intl.DateTimeFormat; -// format a Date object to document's locale, but with 24h format from user's current locale because this -// option is a personal preference of the user, not something that the document's locale should dictate. +/** Format a Date object to document's locale, but with 24h format from user's current locale because this + * option is a personal preference of the user, not something that the document's locale should dictate. */ export function formatDatetime(date: Date | number): string { if (!dateFormat) { // TODO: replace `hour12` with `Intl.Locale.prototype.getHourCycles` once there is broad browser support diff --git a/web_src/js/utils/url.ts b/web_src/js/utils/url.ts index a7d61c5e83..9991da7472 100644 --- a/web_src/js/utils/url.ts +++ b/web_src/js/utils/url.ts @@ -14,8 +14,8 @@ export function isUrl(url: string): boolean { } } -// Convert an absolute or relative URL to an absolute URL with the current origin. It only -// processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. +/** Convert an absolute or relative URL to an absolute URL with the current origin. It only + * processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. */ export function toOriginUrl(urlStr: string) { try { if (urlStr.startsWith('http://') || urlStr.startsWith('https://') || urlStr.startsWith('/')) { |