diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /web_src/js/utils | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r-- | web_src/js/utils.js | 144 | ||||
-rw-r--r-- | web_src/js/utils.test.js | 189 | ||||
-rw-r--r-- | web_src/js/utils/color.js | 33 | ||||
-rw-r--r-- | web_src/js/utils/color.test.js | 22 | ||||
-rw-r--r-- | web_src/js/utils/dom.js | 305 | ||||
-rw-r--r-- | web_src/js/utils/dom.test.js | 5 | ||||
-rw-r--r-- | web_src/js/utils/image.js | 47 | ||||
-rw-r--r-- | web_src/js/utils/image.test.js | 29 | ||||
-rw-r--r-- | web_src/js/utils/match.js | 43 | ||||
-rw-r--r-- | web_src/js/utils/match.test.js | 50 | ||||
-rw-r--r-- | web_src/js/utils/time.js | 72 | ||||
-rw-r--r-- | web_src/js/utils/time.test.js | 40 | ||||
-rw-r--r-- | web_src/js/utils/url.js | 15 | ||||
-rw-r--r-- | web_src/js/utils/url.test.js | 13 |
14 files changed, 1007 insertions, 0 deletions
diff --git a/web_src/js/utils.js b/web_src/js/utils.js new file mode 100644 index 0000000..ce0fb66 --- /dev/null +++ b/web_src/js/utils.js @@ -0,0 +1,144 @@ +import {encode, decode} from 'uint8-to-base64'; + +// transform /path/to/file.ext to file.ext +export function basename(path = '') { + const lastSlashIndex = path.lastIndexOf('/'); + return lastSlashIndex < 0 ? path : path.substring(lastSlashIndex + 1); +} + +// transform /path/to/file.ext to .ext +export function extname(path = '') { + const lastPointIndex = path.lastIndexOf('.'); + return lastPointIndex < 0 ? '' : path.substring(lastPointIndex); +} + +// test whether a variable is an object +export function isObject(obj) { + return Object.prototype.toString.call(obj) === '[object Object]'; +} + +// returns whether a dark theme is enabled +export function isDarkTheme() { + const style = window.getComputedStyle(document.documentElement); + return style.getPropertyValue('--is-dark-theme').trim().toLowerCase() === 'true'; +} + +// strip <tags> from a string +export function stripTags(text) { + return text.replace(/<[^>]*>?/g, ''); +} + +export function parseIssueHref(href) { + const path = (href || '').replace(/[#?].*$/, ''); + const [_, owner, repo, type, index] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || []; + return {owner, repo, type, index}; +} + +// parse a URL, either relative '/path' or absolute 'https://localhost/path' +export function parseUrl(str) { + return new URL(str, str.startsWith('http') ? undefined : window.location.origin); +} + +// return current locale chosen by user +export function getCurrentLocale() { + return document.documentElement.lang; +} + +// given a month (0-11), returns it in the documents language +export function translateMonth(month) { + return new Date(Date.UTC(2022, month, 12)).toLocaleString(getCurrentLocale(), {month: 'short', timeZone: 'UTC'}); +} + +// given a weekday (0-6, Sunday to Saturday), returns it in the documents language +export function translateDay(day) { + return new Date(Date.UTC(2022, 7, day)).toLocaleString(getCurrentLocale(), {weekday: 'short', timeZone: 'UTC'}); +} + +// convert a Blob to a DataURI +export function blobToDataURI(blob) { + return new Promise((resolve, reject) => { + try { + const reader = new FileReader(); + reader.addEventListener('load', (e) => { + resolve(e.target.result); + }); + reader.addEventListener('error', () => { + reject(new Error('FileReader failed')); + }); + reader.readAsDataURL(blob); + } catch (err) { + reject(err); + } + }); +} + +// convert image Blob to another mime-type format. +export function convertImage(blob, mime) { + return new Promise(async (resolve, reject) => { + try { + const img = new Image(); + const canvas = document.createElement('canvas'); + img.addEventListener('load', () => { + try { + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const context = canvas.getContext('2d'); + context.drawImage(img, 0, 0); + canvas.toBlob((blob) => { + if (!(blob instanceof Blob)) return reject(new Error('imageBlobToPng failed')); + resolve(blob); + }, mime); + } catch (err) { + reject(err); + } + }); + img.addEventListener('error', () => { + reject(new Error('imageBlobToPng failed')); + }); + img.src = await blobToDataURI(blob); + } catch (err) { + reject(err); + } + }); +} + +export function toAbsoluteUrl(url) { + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + if (url.startsWith('//')) { + return `${window.location.protocol}${url}`; // it's also a somewhat absolute URL (with the current scheme) + } + if (url && !url.startsWith('/')) { + throw new Error('unsupported url, it should either start with / or http(s)://'); + } + return `${window.location.origin}${url}`; +} + +// Encode an ArrayBuffer into a URLEncoded base64 string. +export function encodeURLEncodedBase64(arrayBuffer) { + return encode(arrayBuffer) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +// Decode a URLEncoded base64 to an ArrayBuffer string. +export function decodeURLEncodedBase64(base64url) { + return decode(base64url + .replace(/_/g, '/') + .replace(/-/g, '+')); +} + +const domParser = new DOMParser(); +const xmlSerializer = new XMLSerializer(); + +export function parseDom(text, contentType) { + return domParser.parseFromString(text, contentType); +} + +export function serializeXml(node) { + return xmlSerializer.serializeToString(node); +} + +export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js new file mode 100644 index 0000000..c49bb2a --- /dev/null +++ b/web_src/js/utils.test.js @@ -0,0 +1,189 @@ +import { + basename, extname, isObject, stripTags, parseIssueHref, + parseUrl, translateMonth, translateDay, blobToDataURI, + toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, + isDarkTheme, getCurrentLocale, parseDom, serializeXml, sleep, +} from './utils.js'; + +afterEach(() => { + // Reset head and body sections of the document + document.documentElement.innerHTML = '<head></head><body></body>'; + + // Remove 'lang' and 'style' attributes of html tag + document.documentElement.removeAttribute('lang'); + document.documentElement.removeAttribute('style'); +}); + +test('basename', () => { + expect(basename('/path/to/file.js')).toEqual('file.js'); + expect(basename('/path/to/file')).toEqual('file'); + expect(basename('file.js')).toEqual('file.js'); +}); + +test('extname', () => { + expect(extname('/path/to/file.js')).toEqual('.js'); + expect(extname('/path/')).toEqual(''); + expect(extname('/path')).toEqual(''); + expect(extname('file.js')).toEqual('.js'); +}); + +test('isObject', () => { + expect(isObject({})).toBeTruthy(); + expect(isObject([])).toBeFalsy(); +}); + +test('should return true if dark theme is enabled', () => { + // When --is-dark-theme var is defined with value true + document.documentElement.style.setProperty('--is-dark-theme', 'true'); + expect(isDarkTheme()).toBeTruthy(); + + // when --is-dark-theme var is defined with value TRUE + document.documentElement.style.setProperty('--is-dark-theme', 'TRUE'); + expect(isDarkTheme()).toBeTruthy(); +}); + +test('should return false if dark theme is disabled', () => { + // when --is-dark-theme var is defined with value false + document.documentElement.style.setProperty('--is-dark-theme', 'false'); + expect(isDarkTheme()).toBeFalsy(); + + // when --is-dark-theme var is defined with value FALSE + document.documentElement.style.setProperty('--is-dark-theme', 'FALSE'); + expect(isDarkTheme()).toBeFalsy(); +}); + +test('should return false if dark theme is not defined', () => { + // when --is-dark-theme var is not exist + expect(isDarkTheme()).toBeFalsy(); +}); + +test('stripTags', () => { + expect(stripTags('<a>test</a>')).toEqual('test'); +}); + +test('parseIssueHref', () => { + expect(parseIssueHref('/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); + expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'}); + expect(parseIssueHref('/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); + expect(parseIssueHref('/sub/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); + expect(parseIssueHref('/sub/sub2/owner/repo/pulls/1')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'}); + expect(parseIssueHref('/sub/sub2/owner/repo/issues/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); + expect(parseIssueHref('/sub/sub2/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); + expect(parseIssueHref('https://example.com/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); + expect(parseIssueHref('https://example.com/owner/repo/pulls/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'}); + expect(parseIssueHref('https://example.com/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); + expect(parseIssueHref('https://example.com/sub/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); + expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/pulls/1')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'}); + expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); + expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); + expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined}); +}); + +test('parseUrl', () => { + expect(parseUrl('').pathname).toEqual('/'); + expect(parseUrl('/path').pathname).toEqual('/path'); + expect(parseUrl('/path?search').pathname).toEqual('/path'); + expect(parseUrl('/path?search').search).toEqual('?search'); + expect(parseUrl('/path?search#hash').hash).toEqual('#hash'); + expect(parseUrl('https://localhost/path').pathname).toEqual('/path'); + expect(parseUrl('https://localhost/path?search').pathname).toEqual('/path'); + expect(parseUrl('https://localhost/path?search').search).toEqual('?search'); + expect(parseUrl('https://localhost/path?search#hash').hash).toEqual('#hash'); +}); + +test('getCurrentLocale', () => { + // HTML document without explicit lang + expect(getCurrentLocale()).toEqual(''); + + // HTML document with explicit lang + document.documentElement.setAttribute('lang', 'en-US'); + expect(getCurrentLocale()).toEqual('en-US'); +}); + +test('translateMonth', () => { + const originalLang = document.documentElement.lang; + document.documentElement.lang = 'en-US'; + expect(translateMonth(0)).toEqual('Jan'); + expect(translateMonth(4)).toEqual('May'); + document.documentElement.lang = 'es-ES'; + expect(translateMonth(5)).toEqual('jun'); + expect(translateMonth(6)).toEqual('jul'); + document.documentElement.lang = originalLang; +}); + +test('translateDay', () => { + const originalLang = document.documentElement.lang; + document.documentElement.lang = 'fr-FR'; + expect(translateDay(1)).toEqual('lun.'); + expect(translateDay(5)).toEqual('ven.'); + document.documentElement.lang = 'pl-PL'; + expect(translateDay(1)).toEqual('pon.'); + expect(translateDay(5)).toEqual('pt.'); + document.documentElement.lang = originalLang; +}); + +test('blobToDataURI', async () => { + const blob = new Blob([JSON.stringify({test: true})], {type: 'application/json'}); + expect(await blobToDataURI(blob)).toEqual('data:application/json;base64,eyJ0ZXN0Ijp0cnVlfQ=='); +}); + +test('toAbsoluteUrl', () => { + expect(toAbsoluteUrl('//host/dir')).toEqual('http://host/dir'); + expect(toAbsoluteUrl('https://host/dir')).toEqual('https://host/dir'); + expect(toAbsoluteUrl('http://host/dir')).toEqual('http://host/dir'); + expect(toAbsoluteUrl('')).toEqual('http://localhost:3000'); + expect(toAbsoluteUrl('/user/repo')).toEqual('http://localhost:3000/user/repo'); + expect(() => toAbsoluteUrl('path')).toThrowError('unsupported'); +}); + +test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => { + // TextEncoder is Node.js API while Uint8Array is jsdom API and their outputs are not + // structurally comparable, so we convert to array to compare. The conversion can be + // removed once https://github.com/jsdom/jsdom/issues/2524 is resolved. + const encoder = new TextEncoder(); + const uint8array = encoder.encode.bind(encoder); + + expect(encodeURLEncodedBase64(uint8array('AA?'))).toEqual('QUE_'); // standard base64: "QUE/" + expect(encodeURLEncodedBase64(uint8array('AA~'))).toEqual('QUF-'); // standard base64: "QUF+" + + expect(Array.from(decodeURLEncodedBase64('QUE/'))).toEqual(Array.from(uint8array('AA?'))); + expect(Array.from(decodeURLEncodedBase64('QUF+'))).toEqual(Array.from(uint8array('AA~'))); + expect(Array.from(decodeURLEncodedBase64('QUE_'))).toEqual(Array.from(uint8array('AA?'))); + expect(Array.from(decodeURLEncodedBase64('QUF-'))).toEqual(Array.from(uint8array('AA~'))); + + expect(encodeURLEncodedBase64(uint8array('a'))).toEqual('YQ'); // standard base64: "YQ==" + expect(Array.from(decodeURLEncodedBase64('YQ'))).toEqual(Array.from(uint8array('a'))); + expect(Array.from(decodeURLEncodedBase64('YQ=='))).toEqual(Array.from(uint8array('a'))); +}); + +test('parseDom', () => { + const paragraphStr = 'This is sample paragraph'; + const paragraphTagStr = `<p>${paragraphStr}</p>`; + const content = parseDom(paragraphTagStr, 'text/html'); + expect(content.body.innerHTML).toEqual(paragraphTagStr); + + // Content should have only one paragraph + const paragraphs = content.getElementsByTagName('p'); + expect(paragraphs.length).toEqual(1); + expect(paragraphs[0].textContent).toEqual(paragraphStr); +}); + +test('serializeXml', () => { + const textStr = 'This is a sample text'; + const tagName = 'item'; + const node = document.createElement(tagName); + node.textContent = textStr; + expect(serializeXml(node)).toEqual(`<${tagName}>${textStr}</${tagName}>`); +}); + +test('sleep', async () => { + await testSleep(2000); +}); + +async function testSleep(ms) { + const startTime = Date.now(); // Record the start time + await sleep(ms); + const endTime = Date.now(); // Record the end time + const actualSleepTime = endTime - startTime; + expect(actualSleepTime >= ms).toBeTruthy(); +} diff --git a/web_src/js/utils/color.js b/web_src/js/utils/color.js new file mode 100644 index 0000000..198f97c --- /dev/null +++ b/web_src/js/utils/color.js @@ -0,0 +1,33 @@ +import tinycolor from 'tinycolor2'; + +// 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) { + const {r, g, b} = tinycolor(color).toRgb(); + return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255; +} + +function useLightText(backgroundColor) { + 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. +// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42 +export function contrastColor(backgroundColor) { + return useLightText(backgroundColor) ? '#fff' : '#000'; +} + +function resolveColors(obj) { + const styles = window.getComputedStyle(document.documentElement); + const getColor = (name) => styles.getPropertyValue(name).trim(); + return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)])); +} + +export const chartJsColors = resolveColors({ + text: '--color-text', + border: '--color-secondary-alpha-60', + commits: '--color-primary-alpha-60', + additions: '--color-green', + deletions: '--color-red', +}); diff --git a/web_src/js/utils/color.test.js b/web_src/js/utils/color.test.js new file mode 100644 index 0000000..fee9afc --- /dev/null +++ b/web_src/js/utils/color.test.js @@ -0,0 +1,22 @@ +import {contrastColor} from './color.js'; + +test('contrastColor', () => { + expect(contrastColor('#d73a4a')).toBe('#fff'); + expect(contrastColor('#0075ca')).toBe('#fff'); + expect(contrastColor('#cfd3d7')).toBe('#000'); + expect(contrastColor('#a2eeef')).toBe('#000'); + expect(contrastColor('#7057ff')).toBe('#fff'); + expect(contrastColor('#008672')).toBe('#fff'); + expect(contrastColor('#e4e669')).toBe('#000'); + expect(contrastColor('#d876e3')).toBe('#000'); + expect(contrastColor('#ffffff')).toBe('#000'); + expect(contrastColor('#2b8684')).toBe('#fff'); + expect(contrastColor('#2b8786')).toBe('#fff'); + expect(contrastColor('#2c8786')).toBe('#000'); + expect(contrastColor('#3bb6b3')).toBe('#000'); + expect(contrastColor('#7c7268')).toBe('#fff'); + expect(contrastColor('#7e716c')).toBe('#fff'); + expect(contrastColor('#81706d')).toBe('#fff'); + expect(contrastColor('#807070')).toBe('#fff'); + expect(contrastColor('#84b6eb')).toBe('#000'); +}); diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js new file mode 100644 index 0000000..44adc79 --- /dev/null +++ b/web_src/js/utils/dom.js @@ -0,0 +1,305 @@ +import {debounce} from 'throttle-debounce'; + +function elementsCall(el, func, ...args) { + if (typeof el === 'string' || el instanceof String) { + el = document.querySelectorAll(el); + } + if (el instanceof Node) { + func(el, ...args); + } else if (el.length !== undefined) { + // this works for: NodeList, HTMLCollection, Array, jQuery + for (const e of el) { + func(e, ...args); + } + } else { + throw new Error('invalid argument to be shown/hidden'); + } +} + +/** + * @param el string (selector), Node, NodeList, HTMLCollection, Array or jQuery + * @param force force=true to show or force=false to hide, undefined to toggle + */ +function toggleShown(el, force) { + if (force === true) { + el.classList.remove('tw-hidden'); + } else if (force === false) { + el.classList.add('tw-hidden'); + } else if (force === undefined) { + el.classList.toggle('tw-hidden'); + } else { + throw new Error('invalid force argument'); + } +} + +export function showElem(el) { + elementsCall(el, toggleShown, true); +} + +export function hideElem(el) { + elementsCall(el, toggleShown, false); +} + +export function toggleElem(el, force) { + elementsCall(el, toggleShown, force); +} + +export function isElemHidden(el) { + const res = []; + elementsCall(el, (e) => res.push(e.classList.contains('tw-hidden'))); + if (res.length > 1) throw new Error(`isElemHidden doesn't work for multiple elements`); + return res[0]; +} + +function applyElemsCallback(elems, fn) { + if (fn) { + for (const el of elems) { + fn(el); + } + } + return elems; +} + +export function queryElemSiblings(el, selector = '*', fn) { + return applyElemsCallback(Array.from(el.parentNode.children).filter((child) => child !== el && child.matches(selector)), fn); +} + +// it works like jQuery.children: only the direct children are selected +export function queryElemChildren(parent, selector = '*', fn) { + return applyElemsCallback(parent.querySelectorAll(`:scope > ${selector}`), fn); +} + +export function queryElems(selector, fn) { + return applyElemsCallback(document.querySelectorAll(selector), fn); +} + +export function onDomReady(cb) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', cb); + } else { + cb(); + } +} + +// 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) { + try { + return el.ownerDocument === document && el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.DOCUMENT_FRAGMENT_NODE; + } catch { + // in case the el is not in the same origin, then the access to nodeType would fail + return false; + } +} + +// autosize a textarea to fit content. Based on +// https://github.com/github/textarea-autosize +// --------------------------------------------------------------------- +// Copyright (c) 2018 GitHub, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// --------------------------------------------------------------------- +export function autosize(textarea, {viewportMarginBottom = 0} = {}) { + let isUserResized = false; + // lastStyleHeight and initialStyleHeight are CSS values like '100px' + let lastMouseX, lastMouseY, lastStyleHeight, initialStyleHeight; + + function onUserResize(event) { + if (isUserResized) return; + if (lastMouseX !== event.clientX || lastMouseY !== event.clientY) { + const newStyleHeight = textarea.style.height; + if (lastStyleHeight && lastStyleHeight !== newStyleHeight) { + isUserResized = true; + } + lastStyleHeight = newStyleHeight; + } + + lastMouseX = event.clientX; + lastMouseY = event.clientY; + } + + function overflowOffset() { + let offsetTop = 0; + let el = textarea; + + while (el !== document.body && el !== null) { + offsetTop += el.offsetTop || 0; + el = el.offsetParent; + } + + const top = offsetTop - document.defaultView.scrollY; + const bottom = document.documentElement.clientHeight - (top + textarea.offsetHeight); + return {top, bottom}; + } + + function resizeToFit() { + if (isUserResized) return; + if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return; + + try { + const {top, bottom} = overflowOffset(); + const isOutOfViewport = top < 0 || bottom < 0; + + const computedStyle = getComputedStyle(textarea); + const topBorderWidth = parseFloat(computedStyle.borderTopWidth); + const bottomBorderWidth = parseFloat(computedStyle.borderBottomWidth); + const isBorderBox = computedStyle.boxSizing === 'border-box'; + const borderAddOn = isBorderBox ? topBorderWidth + bottomBorderWidth : 0; + + const adjustedViewportMarginBottom = bottom < viewportMarginBottom ? bottom : viewportMarginBottom; + const curHeight = parseFloat(computedStyle.height); + const maxHeight = curHeight + bottom - adjustedViewportMarginBottom; + + textarea.style.height = 'auto'; + let newHeight = textarea.scrollHeight + borderAddOn; + + if (isOutOfViewport) { + // it is already out of the viewport: + // * if the textarea is expanding: do not resize it + if (newHeight > curHeight) { + newHeight = curHeight; + } + // * if the textarea is shrinking, shrink line by line (just use the + // scrollHeight). do not apply max-height limit, otherwise the page + // flickers and the textarea jumps + } else { + // * if it is in the viewport, apply the max-height limit + newHeight = Math.min(maxHeight, newHeight); + } + + textarea.style.height = `${newHeight}px`; + lastStyleHeight = textarea.style.height; + } finally { + // 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 && + textarea.selectionStart === textarea.value.length) { + textarea.scrollTop = textarea.scrollHeight; + } + } + } + + function onFormReset() { + isUserResized = false; + if (initialStyleHeight !== undefined) { + textarea.style.height = initialStyleHeight; + } else { + textarea.style.removeProperty('height'); + } + } + + textarea.addEventListener('mousemove', onUserResize); + textarea.addEventListener('input', resizeToFit); + textarea.form?.addEventListener('reset', onFormReset); + initialStyleHeight = textarea.style.height ?? undefined; + if (textarea.value) resizeToFit(); + + return { + resizeToFit, + destroy() { + textarea.removeEventListener('mousemove', onUserResize); + textarea.removeEventListener('input', resizeToFit); + textarea.form?.removeEventListener('reset', onFormReset); + }, + }; +} + +export function onInputDebounce(fn) { + return debounce(300, fn); +} + +// Set the `src` attribute on an element and returns a promise that resolves once the element +// has loaded or errored. Suitable for all elements mention in: +// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/load_event +export function loadElem(el, src) { + return new Promise((resolve) => { + el.addEventListener('load', () => resolve(true), {once: true}); + el.addEventListener('error', () => resolve(false), {once: true}); + el.src = src; + }); +} + +// some browsers like PaleMoon don't have "SubmitEvent" support, so polyfill it by a tricky method: use the last clicked button as submitter +// it can't use other transparent polyfill patches because PaleMoon also doesn't support "addEventListener(capture)" +const needSubmitEventPolyfill = typeof SubmitEvent === 'undefined'; + +export function submitEventSubmitter(e) { + return needSubmitEventPolyfill ? (e.target._submitter || null) : e.submitter; +} + +function submitEventPolyfillListener(e) { + const form = e.target.closest('form'); + if (!form) return; + form._submitter = e.target.closest('button:not([type]), button[type="submit"], input[type="submit"]'); +} + +export function initSubmitEventPolyfill() { + if (!needSubmitEventPolyfill) return; + console.warn(`This browser doesn't have "SubmitEvent" support, use a tricky method to polyfill`); + document.body.addEventListener('click', submitEventPolyfillListener); + document.body.addEventListener('focus', submitEventPolyfillListener); +} + +/** + * Check if an element is visible, equivalent to jQuery's `:visible` pseudo. + * Note: This function doesn't account for all possible visibility scenarios. + * @param {HTMLElement} element The element to check. + * @returns {boolean} True if the element is visible. + */ +export function isElemVisible(element) { + if (!element) return false; + + return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); +} + +// extract text and images from "paste" event +export function getPastedContent(e) { + const images = []; + for (const item of e.clipboardData?.items ?? []) { + if (item.type?.startsWith('image/')) { + images.push(item.getAsFile()); + } + } + const text = e.clipboardData?.getData?.('text') ?? ''; + return {text, images}; +} + +// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this +export function replaceTextareaSelection(textarea, text) { + const before = textarea.value.slice(0, textarea.selectionStart ?? undefined); + const after = textarea.value.slice(textarea.selectionEnd ?? undefined); + let success = true; + + textarea.contentEditable = 'true'; + try { + success = document.execCommand('insertText', false, text); + } catch { + success = false; + } + textarea.contentEditable = 'false'; + + if (success && !textarea.value.slice(0, textarea.selectionStart ?? undefined).endsWith(text)) { + success = false; + } + + if (!success) { + textarea.value = `${before}${text}${after}`; + textarea.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true})); + } +} + +// Warning: Do not enter any unsanitized variables here +export function createElementFromHTML(htmlString) { + const div = document.createElement('div'); + div.innerHTML = htmlString.trim(); + return div.firstChild; +} diff --git a/web_src/js/utils/dom.test.js b/web_src/js/utils/dom.test.js new file mode 100644 index 0000000..fd7d97c --- /dev/null +++ b/web_src/js/utils/dom.test.js @@ -0,0 +1,5 @@ +import {createElementFromHTML} from './dom.js'; + +test('createElementFromHTML', () => { + expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>'); +}); diff --git a/web_src/js/utils/image.js b/web_src/js/utils/image.js new file mode 100644 index 0000000..ed5d98e --- /dev/null +++ b/web_src/js/utils/image.js @@ -0,0 +1,47 @@ +export async function pngChunks(blob) { + const uint8arr = new Uint8Array(await blob.arrayBuffer()); + const chunks = []; + if (uint8arr.length < 12) return chunks; + const view = new DataView(uint8arr.buffer); + if (view.getBigUint64(0) !== 9894494448401390090n) return chunks; + + const decoder = new TextDecoder(); + let index = 8; + while (index < uint8arr.length) { + const len = view.getUint32(index); + chunks.push({ + name: decoder.decode(uint8arr.slice(index + 4, index + 8)), + data: uint8arr.slice(index + 8, index + 8 + len), + }); + index += len + 12; + } + + return chunks; +} + +// decode a image and try to obtain width and dppx. If will never throw but instead +// return default values. +export async function imageInfo(blob) { + let width = 0; // 0 means no width could be determined + let dppx = 1; // 1 dot per pixel for non-HiDPI screens + + if (blob.type === 'image/png') { // only png is supported currently + try { + for (const {name, data} of await pngChunks(blob)) { + const view = new DataView(data.buffer); + if (name === 'IHDR' && data?.length) { + // extract width from mandatory IHDR chunk + width = view.getUint32(0); + } else if (name === 'pHYs' && data?.length) { + // extract dppx from optional pHYs chunk, assuming pixels are square + const unit = view.getUint8(8); + if (unit === 1) { + dppx = Math.round(view.getUint32(0) / 39.3701) / 72; // meter to inch to dppx + } + } + } + } catch {} + } + + return {width, dppx}; +} diff --git a/web_src/js/utils/image.test.js b/web_src/js/utils/image.test.js new file mode 100644 index 0000000..ba47582 --- /dev/null +++ b/web_src/js/utils/image.test.js @@ -0,0 +1,29 @@ +import {pngChunks, imageInfo} from './image.js'; + +const pngNoPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAADUlEQVQIHQECAP3/AAAAAgABzePRKwAAAABJRU5ErkJggg=='; +const pngPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAEElEQVQI12OQNZcAIgYIBQAL8gGxdzzM0A=='; +const pngEmpty = 'data:image/png;base64,'; + +async function dataUriToBlob(datauri) { + return await (await globalThis.fetch(datauri)).blob(); +} + +test('pngChunks', async () => { + expect(await pngChunks(await dataUriToBlob(pngNoPhys))).toEqual([ + {name: 'IHDR', data: new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0])}, + {name: 'IDAT', data: new Uint8Array([8, 29, 1, 2, 0, 253, 255, 0, 0, 0, 2, 0, 1])}, + {name: 'IEND', data: new Uint8Array([])}, + ]); + expect(await pngChunks(await dataUriToBlob(pngPhys))).toEqual([ + {name: 'IHDR', data: new Uint8Array([0, 0, 0, 2, 0, 0, 0, 2, 8, 2, 0, 0, 0])}, + {name: 'pHYs', data: new Uint8Array([0, 0, 22, 37, 0, 0, 22, 37, 1])}, + {name: 'IDAT', data: new Uint8Array([8, 215, 99, 144, 53, 151, 0, 34, 6, 8, 5, 0, 11, 242, 1, 177])}, + ]); + expect(await pngChunks(await dataUriToBlob(pngEmpty))).toEqual([]); +}); + +test('imageInfo', async () => { + expect(await imageInfo(await dataUriToBlob(pngNoPhys))).toEqual({width: 1, dppx: 1}); + expect(await imageInfo(await dataUriToBlob(pngPhys))).toEqual({width: 2, dppx: 2}); + expect(await imageInfo(await dataUriToBlob(pngEmpty))).toEqual({width: 0, dppx: 1}); +}); diff --git a/web_src/js/utils/match.js b/web_src/js/utils/match.js new file mode 100644 index 0000000..17fdfed --- /dev/null +++ b/web_src/js/utils/match.js @@ -0,0 +1,43 @@ +import emojis from '../../../assets/emoji.json'; + +const maxMatches = 6; + +function sortAndReduce(map) { + const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1])); + return Array.from(sortedMap.keys()).slice(0, maxMatches); +} + +export function matchEmoji(queryText) { + const query = queryText.toLowerCase().replaceAll('_', ' '); + if (!query) return emojis.slice(0, maxMatches).map((e) => e.aliases[0]); + + // results is a map of weights, lower is better + const results = new Map(); + for (const {aliases} of emojis) { + const mainAlias = aliases[0]; + for (const [aliasIndex, alias] of aliases.entries()) { + const index = alias.replaceAll('_', ' ').indexOf(query); + if (index === -1) continue; + const existing = results.get(mainAlias); + const rankedIndex = index + aliasIndex; + results.set(mainAlias, existing ? existing - rankedIndex : rankedIndex); + } + } + + return sortAndReduce(results); +} + +export function matchMention(queryText) { + const query = queryText.toLowerCase(); + + // results is a map of weights, lower is better + const results = new Map(); + for (const obj of window.config.mentionValues ?? []) { + const index = obj.key.toLowerCase().indexOf(query); + if (index === -1) continue; + const existing = results.get(obj); + results.set(obj, existing ? existing - index : index); + } + + return sortAndReduce(results); +} diff --git a/web_src/js/utils/match.test.js b/web_src/js/utils/match.test.js new file mode 100644 index 0000000..1e30b45 --- /dev/null +++ b/web_src/js/utils/match.test.js @@ -0,0 +1,50 @@ +import {matchEmoji, matchMention} from './match.js'; + +test('matchEmoji', () => { + expect(matchEmoji('')).toEqual([ + '+1', + '-1', + '100', + '1234', + '1st_place_medal', + '2nd_place_medal', + ]); + + expect(matchEmoji('hea')).toEqual([ + 'headphones', + 'headstone', + 'health_worker', + 'hear_no_evil', + 'heard_mcdonald_islands', + 'heart', + ]); + + expect(matchEmoji('hear')).toEqual([ + 'hear_no_evil', + 'heard_mcdonald_islands', + 'heart', + 'heart_decoration', + 'heart_eyes', + 'heart_eyes_cat', + ]); + + expect(matchEmoji('poo')).toEqual([ + 'poodle', + 'hankey', + 'spoon', + 'bowl_with_spoon', + ]); + + expect(matchEmoji('1st_')).toEqual([ + '1st_place_medal', + ]); + + expect(matchEmoji('jellyfis')).toEqual([ + 'jellyfish', + ]); +}); + +test('matchMention', () => { + expect(matchMention('')).toEqual(window.config.mentionValues.slice(0, 6)); + expect(matchMention('user4')).toEqual([window.config.mentionValues[3]]); +}); diff --git a/web_src/js/utils/time.js b/web_src/js/utils/time.js new file mode 100644 index 0000000..7c7eabd --- /dev/null +++ b/web_src/js/utils/time.js @@ -0,0 +1,72 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; +import {getCurrentLocale} from '../utils.js'; + +dayjs.extend(utc); + +/** + * Returns an array of millisecond-timestamps of start-of-week days (Sundays) + * + * @param startConfig The start date. Can take any type that `Date` accepts. + * @param endConfig The end date. Can take any type that `Date` accepts. + */ +export function startDaysBetween(startDate, endDate) { + const start = dayjs.utc(startDate); + const end = dayjs.utc(endDate); + + let current = start; + + // Ensure the start date is a Sunday + while (current.day() !== 0) { + current = current.add(1, 'day'); + } + + const startDays = []; + while (current.isBefore(end)) { + startDays.push(current.valueOf()); + current = current.add(1, 'week'); + } + + return startDays; +} + +export function firstStartDateAfterDate(inputDate) { + if (!(inputDate instanceof Date)) { + throw new Error('Invalid date'); + } + const dayOfWeek = inputDate.getUTCDay(); + const daysUntilSunday = 7 - dayOfWeek; + const resultDate = new Date(inputDate.getTime()); + resultDate.setUTCDate(resultDate.getUTCDate() + daysUntilSunday); + return resultDate.valueOf(); +} + +export function fillEmptyStartDaysWithZeroes(startDays, data) { + const result = {}; + + for (const startDay of startDays) { + result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0}; + } + + return Object.values(result); +} + +let dateFormat; + +// 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) { + if (!dateFormat) { + // TODO: replace `hour12` with `Intl.Locale.prototype.getHourCycles` once there is broad browser support + dateFormat = new Intl.DateTimeFormat(getCurrentLocale(), { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: 'numeric', + hour12: !Number.isInteger(Number(new Intl.DateTimeFormat([], {hour: 'numeric'}).format())), + minute: '2-digit', + timeZoneName: 'short', + }); + } + return dateFormat.format(date); +} diff --git a/web_src/js/utils/time.test.js b/web_src/js/utils/time.test.js new file mode 100644 index 0000000..dbe5d7d --- /dev/null +++ b/web_src/js/utils/time.test.js @@ -0,0 +1,40 @@ +import {firstStartDateAfterDate, startDaysBetween, fillEmptyStartDaysWithZeroes} from './time.js'; + +test('startDaysBetween', () => { + expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([ + 1708214400000, + 1708819200000, + 1709424000000, + 1710028800000, + 1710633600000, + 1711238400000, + 1711843200000, + 1712448000000, + 1713052800000, + ]); +}); + +test('firstStartDateAfterDate', () => { + const expectedDate = new Date('2024-02-18').getTime(); + expect(firstStartDateAfterDate(new Date('2024-02-15'))).toEqual(expectedDate); + + expect(() => firstStartDateAfterDate('2024-02-15')).toThrowError('Invalid date'); +}); +test('fillEmptyStartDaysWithZeroes with data', () => { + expect(fillEmptyStartDaysWithZeroes([1708214400000, 1708819200000, 1708819300000], { + 1708214400000: {'week': 1708214400000, 'additions': 1, 'deletions': 2, 'commits': 3}, + 1708819200000: {'week': 1708819200000, 'additions': 4, 'deletions': 5, 'commits': 6}, + })).toEqual([ + {'week': 1708214400000, 'additions': 1, 'deletions': 2, 'commits': 3}, + {'week': 1708819200000, 'additions': 4, 'deletions': 5, 'commits': 6}, + { + 'additions': 0, + 'commits': 0, + 'deletions': 0, + 'week': 1708819300000, + }]); +}); + +test('fillEmptyStartDaysWithZeroes with empty array', () => { + expect(fillEmptyStartDaysWithZeroes([], {})).toEqual([]); +}); diff --git a/web_src/js/utils/url.js b/web_src/js/utils/url.js new file mode 100644 index 0000000..470ece3 --- /dev/null +++ b/web_src/js/utils/url.js @@ -0,0 +1,15 @@ +export function pathEscapeSegments(s) { + return s.split('/').map(encodeURIComponent).join('/'); +} + +function stripSlash(url) { + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +export function isUrl(url) { + try { + return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim(); + } catch { + return false; + } +} diff --git a/web_src/js/utils/url.test.js b/web_src/js/utils/url.test.js new file mode 100644 index 0000000..08c6373 --- /dev/null +++ b/web_src/js/utils/url.test.js @@ -0,0 +1,13 @@ +import {pathEscapeSegments, isUrl} from './url.js'; + +test('pathEscapeSegments', () => { + expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c'); + expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c'); +}); + +test('isUrl', () => { + expect(isUrl('https://example.com')).toEqual(true); + expect(isUrl('https://example.com/')).toEqual(true); + expect(isUrl('https://example.com/index.html')).toEqual(true); + expect(isUrl('/index.html')).toEqual(false); +}); |