snapshot of https://github.com/go-gitea/gitea.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped
This commit is contained in:
24
web_src/js/utils/color.test.ts
Normal file
24
web_src/js/utils/color.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {contrastColor} from './color.ts';
|
||||
|
||||
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('#fff');
|
||||
expect(contrastColor('#ffffff')).toBe('#000');
|
||||
expect(contrastColor('#2b8684')).toBe('#fff');
|
||||
expect(contrastColor('#2b8786')).toBe('#fff');
|
||||
expect(contrastColor('#2c8786')).toBe('#fff');
|
||||
expect(contrastColor('#2bb3b2')).toBe('#fff');
|
||||
expect(contrastColor('#2bb4b3')).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');
|
||||
});
|
||||
38
web_src/js/utils/color.ts
Normal file
38
web_src/js/utils/color.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {colord} from 'colord';
|
||||
import type {AnyColor} from 'colord';
|
||||
|
||||
/** Undoes the sRGB transfer function, channel is in 0..255 range. */
|
||||
function linearizeChannel(channel: number): number {
|
||||
const srgb = channel / 255;
|
||||
return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
/** Returns relative luminance for a SRGB color - https://www.w3.org/TR/WCAG20/#relativeluminancedef */
|
||||
// Keep this in sync with modules/util/color.go
|
||||
function getRelativeLuminance(color: AnyColor): number {
|
||||
const {r, g, b} = colord(color).toRgb();
|
||||
return 0.2126 * linearizeChannel(r) + 0.7152 * linearizeChannel(g) + 0.0722 * linearizeChannel(b);
|
||||
}
|
||||
|
||||
function useLightText(backgroundColor: AnyColor): boolean {
|
||||
return getRelativeLuminance(backgroundColor) < 0.36; // matches APCA better than WCAG's own 0.179
|
||||
}
|
||||
|
||||
/** Given a background color, returns a black or white foreground color with the highest contrast ratio. */
|
||||
export function contrastColor(backgroundColor: AnyColor): string {
|
||||
return useLightText(backgroundColor) ? '#fff' : '#000';
|
||||
}
|
||||
|
||||
function resolveColors(obj: Record<string, string>): Record<string, string> {
|
||||
const styles = window.getComputedStyle(document.documentElement);
|
||||
const getColor = (name: string) => 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',
|
||||
});
|
||||
72
web_src/js/utils/dom.test.ts
Normal file
72
web_src/js/utils/dom.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
createElementFromAttrs, createElementFromHTML,
|
||||
queryElemChildren, querySingleVisibleElem,
|
||||
protectMorphElements, recoverMorphElements,
|
||||
toggleElem,
|
||||
} from './dom.ts';
|
||||
|
||||
test('createElementFromHTML', () => {
|
||||
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
|
||||
expect(createElementFromHTML('<tr data-x="1"><td>foo</td></tr>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
|
||||
expect(createElementFromHTML('<TR data-x="1"><td>foo</td></TR>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
|
||||
expect(createElementFromHTML('<trx></trx>').outerHTML).toEqual('<trx></trx>');
|
||||
});
|
||||
|
||||
test('createElementFromAttrs', () => {
|
||||
const el = createElementFromAttrs('button', {
|
||||
id: 'the-id',
|
||||
class: 'cls-1 cls-2',
|
||||
disabled: true,
|
||||
checked: false,
|
||||
required: null,
|
||||
tabindex: 0,
|
||||
'data-foo': 'the-data',
|
||||
}, 'txt', createElementFromHTML('<span>inner</span>'));
|
||||
expect(el.outerHTML).toEqual('<button id="the-id" class="cls-1 cls-2" disabled="" tabindex="0" data-foo="the-data">txt<span>inner</span></button>');
|
||||
});
|
||||
|
||||
test('querySingleVisibleElem', () => {
|
||||
const el = document.createElement('div');
|
||||
document.body.append(el); // layout, and thus visibility, is only computed in the document
|
||||
expect(querySingleVisibleElem(el, 'span')).toBeNull();
|
||||
el.innerHTML = '<span>foo</span>';
|
||||
expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('foo');
|
||||
el.innerHTML = '<span style="display: none;">foo</span><span>bar</span>';
|
||||
expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('bar');
|
||||
el.innerHTML = '<span class="some-class tw-hidden">foo</span><span>bar</span>';
|
||||
expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('bar');
|
||||
el.innerHTML = '<span>foo</span><span>bar</span>';
|
||||
expect(() => querySingleVisibleElem(el, 'span')).toThrow('Expected exactly one visible element');
|
||||
});
|
||||
|
||||
test('queryElemChildren', () => {
|
||||
const el = createElementFromHTML('<div><span class="a">a</span><span class="b">b</span></div>');
|
||||
const children = queryElemChildren(el, '.a');
|
||||
expect(children.length).toEqual(1);
|
||||
});
|
||||
|
||||
test('toggleElem', () => {
|
||||
const el = createElementFromHTML('<div><div>a</div><div class="tw-hidden">b</div></div>');
|
||||
toggleElem(el.children);
|
||||
expect(el.outerHTML).toEqual('<div><div class="tw-hidden">a</div><div class="">b</div></div>');
|
||||
toggleElem(el.children, false);
|
||||
expect(el.outerHTML).toEqual('<div><div class="tw-hidden">a</div><div class="tw-hidden">b</div></div>');
|
||||
toggleElem(el.children, true);
|
||||
expect(el.outerHTML).toEqual('<div><div class="">a</div><div class="">b</div></div>');
|
||||
});
|
||||
|
||||
test('protectMorphElements', () => {
|
||||
const el = createElementFromHTML('<div><span data-morph-protect="">foo</span></div>');
|
||||
const protectedElems = protectMorphElements(el);
|
||||
|
||||
const span = el.querySelector('span')!;
|
||||
const spanMorphProtectId = span.getAttribute('data-morph-protect');
|
||||
expect(spanMorphProtectId).toBeTruthy();
|
||||
expect(el.outerHTML).toEqual(`<div><span data-morph-protect="${spanMorphProtectId}">foo</span></div>`);
|
||||
span.textContent = 'bar';
|
||||
span.classList.add('new-class');
|
||||
expect(el.outerHTML).toEqual(`<div><span data-morph-protect="${spanMorphProtectId}" class="new-class">bar</span></div>`);
|
||||
|
||||
recoverMorphElements(el, protectedElems);
|
||||
expect(el.outerHTML).toEqual('<div><span data-morph-protect="">foo</span></div>');
|
||||
});
|
||||
429
web_src/js/utils/dom.ts
Normal file
429
web_src/js/utils/dom.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import {debounce} from './func.ts';
|
||||
import type {Promisable} from '../types.ts';
|
||||
import type $ from 'jquery';
|
||||
|
||||
type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
|
||||
type ElementArg = Element | string | ArrayLikeIterable<Element> | ReturnType<typeof $>;
|
||||
type ElementsCallback<T extends Element> = (el: T) => Promisable<any>;
|
||||
type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
|
||||
|
||||
function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]): ArrayLikeIterable<Element> {
|
||||
if (typeof el === 'string') {
|
||||
el = document.querySelectorAll(el);
|
||||
}
|
||||
if (el instanceof Node) {
|
||||
func(el, ...args);
|
||||
return [el];
|
||||
} else if (el.length !== undefined) {
|
||||
// this works for: NodeList, HTMLCollection, Array, jQuery
|
||||
const elems = el as ArrayLikeIterable<Element>;
|
||||
for (const elem of elems) func(elem, ...args);
|
||||
return elems;
|
||||
}
|
||||
throw new Error('invalid argument to be shown/hidden');
|
||||
}
|
||||
|
||||
export function toggleElemClass(el: ElementArg, className: string, force?: boolean): ArrayLikeIterable<Element> {
|
||||
return elementsCall(el, (e: Element) => {
|
||||
if (force === true) {
|
||||
e.classList.add(className);
|
||||
} else if (force === false) {
|
||||
e.classList.remove(className);
|
||||
} else if (force === undefined) {
|
||||
e.classList.toggle(className);
|
||||
} else {
|
||||
throw new Error('invalid force argument');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param el ElementArg
|
||||
* @param force force=true to show or force=false to hide, undefined to toggle
|
||||
*/
|
||||
export function toggleElem(el: ElementArg, force?: boolean): ArrayLikeIterable<Element> {
|
||||
return toggleElemClass(el, 'tw-hidden', force === undefined ? force : !force);
|
||||
}
|
||||
|
||||
export function showElem(el: ElementArg): ArrayLikeIterable<Element> {
|
||||
return toggleElem(el, true);
|
||||
}
|
||||
|
||||
export function hideElem(el: ElementArg): ArrayLikeIterable<Element> {
|
||||
return toggleElem(el, false);
|
||||
}
|
||||
|
||||
function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
|
||||
if (fn) {
|
||||
for (const el of elems) {
|
||||
fn(el);
|
||||
}
|
||||
}
|
||||
return elems;
|
||||
}
|
||||
|
||||
export function queryElemSiblings<T extends Element>(el: Element, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
|
||||
if (!el.parentNode) return [];
|
||||
const elems = Array.from(el.parentNode.children) as T[];
|
||||
return applyElemsCallback<T>(elems.filter((child: Element) => {
|
||||
return child !== el && child.matches(selector);
|
||||
}), fn);
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
export function onDomReady(cb: () => Promisable<void>) {
|
||||
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: Node) {
|
||||
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: HTMLTextAreaElement, {viewportMarginBottom = 0}: {viewportMarginBottom?: number} = {}) {
|
||||
let isUserResized = false;
|
||||
// lastStyleHeight and initialStyleHeight are CSS values like '100px'
|
||||
let lastMouseX: number | undefined;
|
||||
let lastMouseY: number | undefined;
|
||||
let lastStyleHeight: string | undefined;
|
||||
let initialStyleHeight: string | undefined;
|
||||
|
||||
function onUserResize(event: MouseEvent) {
|
||||
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 as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
const scrollY = document.defaultView ? document.defaultView.scrollY : 0;
|
||||
const top = offsetTop - 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;
|
||||
const previousMargin = textarea.style.marginBottom;
|
||||
|
||||
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 = Math.min(bottom, 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;
|
||||
|
||||
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 {
|
||||
// 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 &&
|
||||
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: () => Promisable<void>) {
|
||||
return debounce(fn, 300);
|
||||
}
|
||||
|
||||
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. */
|
||||
export function loadElem(el: LoadableElement, src: string) {
|
||||
return new Promise((resolve) => {
|
||||
el.addEventListener('load', () => resolve(true), {once: true});
|
||||
el.addEventListener('error', () => resolve(false), {once: true});
|
||||
el.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export function isElemVisible(el: HTMLElement): boolean {
|
||||
// Check if an element is visible, equivalent to jQuery's `:visible` pseudo.
|
||||
// This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem"
|
||||
if (!el) return false;
|
||||
return Boolean(!el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
|
||||
}
|
||||
|
||||
export function createElementFromHTML<T extends Element>(htmlString: string): T {
|
||||
htmlString = htmlString.trim();
|
||||
if (!htmlString.startsWith('<')) throw new Error(`Invalid HTML element string: ${htmlString}`);
|
||||
const isLetter = (code: number) => (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
|
||||
const startsWithTag = (s: string, tag: string) => {
|
||||
return s.substring(1, 1 + tag.length).toLowerCase() === tag.toLowerCase() &&
|
||||
!isLetter(s[1 + tag.length].charCodeAt(0));
|
||||
};
|
||||
// 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
|
||||
if (startsWithTag(htmlString, 'tr')) {
|
||||
const container = document.createElement('table');
|
||||
container.innerHTML = htmlString;
|
||||
return container.querySelector<T>('tr')!;
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = htmlString;
|
||||
return div.firstChild as T;
|
||||
}
|
||||
|
||||
export function createElementFromAttrs<T extends HTMLElement>(tagName: string, attrs: Record<string, string | number | boolean | null | undefined> | null, ...children: (Node | string)[]): T {
|
||||
const el = document.createElement(tagName);
|
||||
for (const [key, value] of Object.entries(attrs || {})) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value === 'boolean') {
|
||||
el.toggleAttribute(key, value);
|
||||
} else {
|
||||
el.setAttribute(key, String(value));
|
||||
}
|
||||
}
|
||||
for (const child of children) {
|
||||
el.append(child instanceof Node ? child : document.createTextNode(child));
|
||||
}
|
||||
return el as T;
|
||||
}
|
||||
|
||||
export function animateOnce(el: Element, animationClassName: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
el.addEventListener('animationend', function onAnimationEnd() {
|
||||
el.classList.remove(animationClassName);
|
||||
el.removeEventListener('animationend', onAnimationEnd);
|
||||
resolve();
|
||||
}, {once: true});
|
||||
el.classList.add(animationClassName);
|
||||
});
|
||||
}
|
||||
|
||||
export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, selector: string): T | null {
|
||||
const elems = parent.querySelectorAll<HTMLElement>(selector);
|
||||
const candidates = Array.from(elems).filter(isElemVisible);
|
||||
if (candidates.length > 1) throw new Error(`Expected exactly one visible element matching selector "${selector}", but found ${candidates.length}`);
|
||||
return candidates.length ? candidates[0] as T : null;
|
||||
}
|
||||
|
||||
export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => Promisable<void>, options?: boolean | AddEventListenerOptions) {
|
||||
parent.addEventListener(type, (e: 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 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++}`;
|
||||
}
|
||||
|
||||
export type ActivePageTimerOptions = {
|
||||
once?: boolean;
|
||||
interval: () => number; // if it returns 0, the timer is stopped
|
||||
callback: () => Promise<void>;
|
||||
};
|
||||
|
||||
export class ActivePageTimer {
|
||||
private readonly opts: ActivePageTimerOptions;
|
||||
private readonly onVisibilityChange: () => void;
|
||||
|
||||
private sysTimerId?: number;
|
||||
private startTime?: number;
|
||||
|
||||
constructor(opts: ActivePageTimerOptions) {
|
||||
this.opts = opts;
|
||||
this.onVisibilityChange = () => {
|
||||
if (!this.startTime) return;
|
||||
const interval = this.opts.interval();
|
||||
if (document.hidden) {
|
||||
this.clearSysTimer();
|
||||
} else if (interval) {
|
||||
this.startSysTimer(interval);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async handler() {
|
||||
this.clear();
|
||||
await this.opts.callback();
|
||||
if (!this.opts.once) this.start();
|
||||
}
|
||||
|
||||
start() {
|
||||
const interval = this.opts.interval();
|
||||
if (!interval) return;
|
||||
if (this.sysTimerId) return;
|
||||
if (!this.startTime) {
|
||||
this.startTime = Date.now();
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
if (!document.hidden) this.startSysTimer(interval);
|
||||
}
|
||||
|
||||
private startSysTimer(interval: number) {
|
||||
const remaining = interval - (Date.now() - this.startTime!);
|
||||
if (remaining <= 0) {
|
||||
this.handler();
|
||||
} else {
|
||||
this.sysTimerId = window.setTimeout(() => this.handler(), remaining);
|
||||
}
|
||||
}
|
||||
|
||||
private clearSysTimer() {
|
||||
if (!this.sysTimerId) return;
|
||||
window.clearTimeout(this.sysTimerId);
|
||||
this.sysTimerId = undefined;
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.startTime) return;
|
||||
this.startTime = undefined;
|
||||
this.clearSysTimer();
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
export function activePageTimerRefresh(opts: ActivePageTimerOptions): ActivePageTimer {
|
||||
const timer = new ActivePageTimer(opts);
|
||||
timer.start();
|
||||
return timer;
|
||||
}
|
||||
|
||||
type ProtectedMorphElements = Record<string, string>;
|
||||
|
||||
export function protectMorphElements(el: Element): ProtectedMorphElements {
|
||||
// Some components like Dropdown manage their internal states and styles.
|
||||
// If morph changes any style or layout, then the Dropdown will stop working.
|
||||
// So, protect such fragile components ahead, then morph, then recover.
|
||||
const ret: ProtectedMorphElements = {};
|
||||
for (const it of el.querySelectorAll('.ui.dropdown, [data-morph-protect]')) {
|
||||
const id = generateElemId();
|
||||
ret[id] = it.outerHTML;
|
||||
it.setAttribute('data-morph-protect', id);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function recoverMorphElements(el: Element, protectedElems: ProtectedMorphElements) {
|
||||
for (const [id, html] of Object.entries(protectedElems)) {
|
||||
const it = el.querySelector(`[data-morph-protect="${CSS.escape(id)}"]`);
|
||||
if (!it) continue;
|
||||
it.outerHTML = html;
|
||||
}
|
||||
}
|
||||
74
web_src/js/utils/func.test.ts
Normal file
74
web_src/js/utils/func.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {debounce, throttle} from './func.ts';
|
||||
|
||||
test('debounce', {concurrent: false}, () => {
|
||||
vi.useFakeTimers();
|
||||
const spy = vi.fn();
|
||||
const fn = debounce(spy, 10);
|
||||
fn();
|
||||
fn();
|
||||
fn();
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
vi.advanceTimersByTime(30);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('debounce leading', {concurrent: false}, () => {
|
||||
vi.useFakeTimers();
|
||||
const spy = vi.fn();
|
||||
const fn = debounce(spy, 10, {leading: true, trailing: false});
|
||||
fn();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
fn();
|
||||
vi.advanceTimersByTime(30);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('debounce result', {concurrent: false}, async () => {
|
||||
vi.useFakeTimers();
|
||||
const fn = debounce((value: number) => value * 2, 10);
|
||||
const first = fn(1);
|
||||
const second = fn(2);
|
||||
vi.advanceTimersByTime(10);
|
||||
expect(await first).toEqual(4); // both calls collapse into the last one
|
||||
expect(await second).toEqual(4);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('debounce cancel', {concurrent: false}, () => {
|
||||
vi.useFakeTimers();
|
||||
const spy = vi.fn();
|
||||
const fn = debounce(spy, 10);
|
||||
fn();
|
||||
fn.cancel();
|
||||
vi.advanceTimersByTime(30);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('throttle', {concurrent: false}, () => {
|
||||
vi.useFakeTimers();
|
||||
const spy = vi.fn();
|
||||
const fn = throttle(spy, 10);
|
||||
fn();
|
||||
fn();
|
||||
fn();
|
||||
expect(spy).toHaveBeenCalledTimes(1); // leading
|
||||
vi.advanceTimersByTime(30);
|
||||
expect(spy).toHaveBeenCalledTimes(2); // plus one trailing for the collapsed rest
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('throttle trailing only', {concurrent: false}, () => {
|
||||
vi.useFakeTimers();
|
||||
const spy = vi.fn();
|
||||
const fn = throttle(spy, 10, {leading: false});
|
||||
fn();
|
||||
fn();
|
||||
fn();
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
vi.advanceTimersByTime(30);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
82
web_src/js/utils/func.ts
Normal file
82
web_src/js/utils/func.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {TimeoutId} from '../types.ts';
|
||||
|
||||
/** Options for `debounce` */
|
||||
export type DebounceOpts = {
|
||||
/** Invoke on the leading edge of the wait period. Default: `false` */
|
||||
leading?: boolean,
|
||||
/** Invoke on the trailing edge of the wait period. Default: `true` */
|
||||
trailing?: boolean,
|
||||
};
|
||||
|
||||
/** Options for `throttle` */
|
||||
export type ThrottleOpts = {
|
||||
/** Invoke on the leading edge of the interval. Default: `true` */
|
||||
leading?: boolean,
|
||||
/** Invoke on the trailing edge of the interval. Default: `true` */
|
||||
trailing?: boolean,
|
||||
};
|
||||
|
||||
/** A debounced or throttled function. Calls collapsed into one invocation settle with its result, dropped calls never settle. */
|
||||
export type TimedFunction<T extends (...args: Array<any>) => any> = ((...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>>) & {
|
||||
/** Drop the pending invocation */
|
||||
cancel: () => void,
|
||||
};
|
||||
|
||||
function createTimed<T extends (...args: Array<any>) => any>(func: T, wait: number, leading: boolean, trailing: boolean, isThrottle: boolean): TimedFunction<T> {
|
||||
let timer: TimeoutId | null = null;
|
||||
let pendingArgs: Parameters<T> | null = null;
|
||||
let resolvers: Array<{resolve: (value: Awaited<ReturnType<T>>) => void, reject: (reason: any) => void}> = [];
|
||||
|
||||
const invoke = async (args: Parameters<T>): Promise<void> => {
|
||||
const settling = resolvers;
|
||||
resolvers = [];
|
||||
try {
|
||||
const value = await func(...args);
|
||||
for (const {resolve} of settling) resolve(value);
|
||||
} catch (err) {
|
||||
for (const {reject} of settling) reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
const onTimer = (): void => {
|
||||
timer = null;
|
||||
const args = pendingArgs;
|
||||
pendingArgs = null;
|
||||
if (trailing && args) {
|
||||
invoke(args);
|
||||
if (isThrottle) timer = setTimeout(onTimer, wait); // keep the window open so a burst stays rate-limited
|
||||
} else {
|
||||
resolvers = [];
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = (): void => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
pendingArgs = null;
|
||||
resolvers = [];
|
||||
};
|
||||
|
||||
const wrapper = (...args: Parameters<T>): Promise<Awaited<ReturnType<T>>> => {
|
||||
const promise = new Promise<Awaited<ReturnType<T>>>((resolve, reject) => {
|
||||
resolvers.push({resolve, reject});
|
||||
});
|
||||
const isLeadingCall = leading && timer === null;
|
||||
if (!isThrottle && timer) { clearTimeout(timer); timer = null } // debounce restarts the wait on every call, throttle does not
|
||||
if (isLeadingCall) invoke(args); else pendingArgs = args;
|
||||
if (timer === null) timer = setTimeout(onTimer, wait);
|
||||
return promise;
|
||||
};
|
||||
|
||||
return Object.assign(wrapper, {cancel});
|
||||
}
|
||||
|
||||
/** Debounce a function, delaying invocation until `wait` milliseconds have passed without another call */
|
||||
export function debounce<T extends (...args: Array<any>) => any>(func: T, wait: number, {leading = false, trailing = true}: DebounceOpts = {}): TimedFunction<T> {
|
||||
return createTimed(func, wait, leading, trailing, false);
|
||||
}
|
||||
|
||||
/** Throttle a function to invoke at most once per `interval` milliseconds */
|
||||
export function throttle<T extends (...args: Array<any>) => any>(func: T, interval: number, {leading = true, trailing = true}: ThrottleOpts = {}): TimedFunction<T> {
|
||||
return createTimed(func, interval, leading, trailing, true);
|
||||
}
|
||||
126
web_src/js/utils/glob.test.ts
Normal file
126
web_src/js/utils/glob.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import globTestData from './glob.test.txt';
|
||||
import {globCompile} from './glob.ts';
|
||||
|
||||
function loadGlobTestData(): {caseNames: string[], caseDataMap: Record<string, string>} {
|
||||
const fileLines = globTestData.split('\n');
|
||||
const caseDataMap: Record<string, string> = {};
|
||||
const caseNameMap: Record<string, boolean> = {};
|
||||
for (let line of fileLines) {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const parts = line.split('=', 2);
|
||||
if (parts.length !== 2) throw new Error(`Invalid test case line: ${line}`);
|
||||
|
||||
const key = parts[0].trim();
|
||||
let value = parts[1].trim();
|
||||
value = value.substring(1, value.length - 1); // remove quotes
|
||||
value = value.replace(/\\\//g, '/').replace(/\\\\/g, '\\');
|
||||
caseDataMap[key] = value;
|
||||
if (key.startsWith('pattern_')) caseNameMap[key.substring('pattern_'.length)] = true;
|
||||
}
|
||||
return {caseNames: Object.keys(caseNameMap), caseDataMap};
|
||||
}
|
||||
|
||||
function loadGlobGolangCases() {
|
||||
// https://github.com/gobwas/glob/blob/master/glob_test.go
|
||||
function glob(matched: boolean, pattern: string, input: string, separators: string = '') {
|
||||
return {matched, pattern, input, separators};
|
||||
}
|
||||
return [
|
||||
glob(true, '* ?at * eyes', 'my cat has very bright eyes'),
|
||||
|
||||
glob(true, '', ''),
|
||||
glob(false, '', 'b'),
|
||||
|
||||
glob(true, '*ä', 'åä'),
|
||||
glob(true, 'abc', 'abc'),
|
||||
glob(true, 'a*c', 'abc'),
|
||||
glob(true, 'a*c', 'a12345c'),
|
||||
glob(true, 'a?c', 'a1c'),
|
||||
glob(true, 'a.b', 'a.b', '.'),
|
||||
glob(true, 'a.*', 'a.b', '.'),
|
||||
glob(true, 'a.**', 'a.b.c', '.'),
|
||||
glob(true, 'a.?.c', 'a.b.c', '.'),
|
||||
glob(true, 'a.?.?', 'a.b.c', '.'),
|
||||
glob(true, '?at', 'cat'),
|
||||
glob(true, '?at', 'fat'),
|
||||
glob(true, '*', 'abc'),
|
||||
glob(true, `\\*`, '*'),
|
||||
glob(true, '**', 'a.b.c', '.'),
|
||||
|
||||
glob(false, '?at', 'at'),
|
||||
glob(false, '?at', 'fat', 'f'),
|
||||
glob(false, 'a.*', 'a.b.c', '.'),
|
||||
glob(false, 'a.?.c', 'a.bb.c', '.'),
|
||||
glob(false, '*', 'a.b.c', '.'),
|
||||
|
||||
glob(true, '*test', 'this is a test'),
|
||||
glob(true, 'this*', 'this is a test'),
|
||||
glob(true, '*is *', 'this is a test'),
|
||||
glob(true, '*is*a*', 'this is a test'),
|
||||
glob(true, '**test**', 'this is a test'),
|
||||
glob(true, '**is**a***test*', 'this is a test'),
|
||||
|
||||
glob(false, '*is', 'this is a test'),
|
||||
glob(false, '*no*', 'this is a test'),
|
||||
glob(true, '[!a]*', 'this is a test3'),
|
||||
|
||||
glob(true, '*abc', 'abcabc'),
|
||||
glob(true, '**abc', 'abcabc'),
|
||||
glob(true, '???', 'abc'),
|
||||
glob(true, '?*?', 'abc'),
|
||||
glob(true, '?*?', 'ac'),
|
||||
glob(false, 'sta', 'stagnation'),
|
||||
glob(true, 'sta*', 'stagnation'),
|
||||
glob(false, 'sta?', 'stagnation'),
|
||||
glob(false, 'sta?n', 'stagnation'),
|
||||
|
||||
glob(true, '{abc,def}ghi', 'defghi'),
|
||||
glob(true, '{abc,abcd}a', 'abcda'),
|
||||
glob(true, '{a,ab}{bc,f}', 'abc'),
|
||||
glob(true, '{*,**}{a,b}', 'ab'),
|
||||
glob(false, '{*,**}{a,b}', 'ac'),
|
||||
|
||||
glob(true, '/{rate,[a-z][a-z][a-z]}*', '/rate'),
|
||||
glob(true, '/{rate,[0-9][0-9][0-9]}*', '/rate'),
|
||||
glob(true, '/{rate,[a-z][a-z][a-z]}*', '/usd'),
|
||||
|
||||
glob(true, '{*.google.*,*.yandex.*}', 'www.google.com', '.'),
|
||||
glob(true, '{*.google.*,*.yandex.*}', 'www.yandex.com', '.'),
|
||||
glob(false, '{*.google.*,*.yandex.*}', 'yandex.com', '.'),
|
||||
glob(false, '{*.google.*,*.yandex.*}', 'google.com', '.'),
|
||||
|
||||
glob(true, '{*.google.*,yandex.*}', 'www.google.com', '.'),
|
||||
glob(true, '{*.google.*,yandex.*}', 'yandex.com', '.'),
|
||||
glob(false, '{*.google.*,yandex.*}', 'www.yandex.com', '.'),
|
||||
glob(false, '{*.google.*,yandex.*}', 'google.com', '.'),
|
||||
|
||||
glob(true, '*//{,*.}example.com', 'https://www.example.com'),
|
||||
glob(true, '*//{,*.}example.com', 'http://example.com'),
|
||||
glob(false, '*//{,*.}example.com', 'http://example.com.net'),
|
||||
];
|
||||
}
|
||||
|
||||
test('GlobCompiler', () => {
|
||||
const {caseNames, caseDataMap} = loadGlobTestData();
|
||||
expect(caseNames.length).toBe(10); // should have 10 test cases
|
||||
for (const caseName of caseNames) {
|
||||
const pattern = caseDataMap[`pattern_${caseName}`];
|
||||
const regexp = caseDataMap[`regexp_${caseName}`];
|
||||
expect(globCompile(pattern).regexpPattern).toBe(regexp);
|
||||
}
|
||||
|
||||
const golangCases = loadGlobGolangCases();
|
||||
expect(golangCases.length).toBe(60);
|
||||
for (const c of golangCases) {
|
||||
const compiled = globCompile(c.pattern, c.separators);
|
||||
const msg = `pattern: ${c.pattern}, input: ${c.input}, separators: ${c.separators || '(none)'}, compiled: ${compiled.regexpPattern}`;
|
||||
expect(compiled.regexp.test(c.input), msg).toBe(c.matched);
|
||||
}
|
||||
|
||||
// then our cases
|
||||
expect(globCompile('*/**/x').regexpPattern).toBe('^.*/.*/x$');
|
||||
expect(globCompile('*/**/x', '/').regexpPattern).toBe('^[^/]*/.*/x$');
|
||||
expect(globCompile('[a-b][^-\\]]', '/').regexpPattern).toBe('^[a-b][^-\\]]$');
|
||||
expect(globCompile('.+^$()|', '/').regexpPattern).toBe('^\\.\\+\\^\\$\\(\\)\\|$');
|
||||
});
|
||||
44
web_src/js/utils/glob.test.txt
Normal file
44
web_src/js/utils/glob.test.txt
Normal file
@@ -0,0 +1,44 @@
|
||||
# test cases are from https://github.com/gobwas/glob/blob/master/glob_test.go
|
||||
|
||||
pattern_all = "[a-z][!a-x]*cat*[h][!b]*eyes*"
|
||||
regexp_all = `^[a-z][^a-x].*cat.*[h][^b].*eyes.*$`
|
||||
fixture_all_match = "my cat has very bright eyes"
|
||||
fixture_all_mismatch = "my dog has very bright eyes"
|
||||
|
||||
pattern_plain = "google.com"
|
||||
regexp_plain = `^google\.com$`
|
||||
fixture_plain_match = "google.com"
|
||||
fixture_plain_mismatch = "gobwas.com"
|
||||
|
||||
pattern_multiple = "https://*.google.*"
|
||||
regexp_multiple = `^https:\/\/.*\.google\..*$`
|
||||
fixture_multiple_match = "https://account.google.com"
|
||||
fixture_multiple_mismatch = "https://google.com"
|
||||
|
||||
pattern_alternatives = "{https://*.google.*,*yandex.*,*yahoo.*,*mail.ru}"
|
||||
regexp_alternatives = `^(https:\/\/.*\.google\..*|.*yandex\..*|.*yahoo\..*|.*mail\.ru)$`
|
||||
fixture_alternatives_match = "http://yahoo.com"
|
||||
fixture_alternatives_mismatch = "http://google.com"
|
||||
|
||||
pattern_alternatives_suffix = "{https://*gobwas.com,http://exclude.gobwas.com}"
|
||||
regexp_alternatives_suffix = `^(https:\/\/.*gobwas\.com|http://exclude\.gobwas\.com)$`
|
||||
fixture_alternatives_suffix_first_match = "https://safe.gobwas.com"
|
||||
fixture_alternatives_suffix_first_mismatch = "http://safe.gobwas.com"
|
||||
fixture_alternatives_suffix_second = "http://exclude.gobwas.com"
|
||||
|
||||
pattern_prefix = "abc*"
|
||||
regexp_prefix = `^abc.*$`
|
||||
pattern_suffix = "*def"
|
||||
regexp_suffix = `^.*def$`
|
||||
pattern_prefix_suffix = "ab*ef"
|
||||
regexp_prefix_suffix = `^ab.*ef$`
|
||||
fixture_prefix_suffix_match = "abcdef"
|
||||
fixture_prefix_suffix_mismatch = "af"
|
||||
|
||||
pattern_alternatives_combine_lite = "{abc*def,abc?def,abc[zte]def}"
|
||||
regexp_alternatives_combine_lite = `^(abc.*def|abc.def|abc[zte]def)$`
|
||||
fixture_alternatives_combine_lite = "abczdef"
|
||||
|
||||
pattern_alternatives_combine_hard = "{abc*[a-c]def,abc?[d-g]def,abc[zte]?def}"
|
||||
regexp_alternatives_combine_hard = `^(abc.*[a-c]def|abc.[d-g]def|abc[zte].def)$`
|
||||
fixture_alternatives_combine_hard = "abczqdef"
|
||||
127
web_src/js/utils/glob.ts
Normal file
127
web_src/js/utils/glob.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// Reference: https://github.com/gobwas/glob/blob/master/glob.go
|
||||
//
|
||||
// Compile creates Glob for given pattern and strings (if any present after pattern) as separators.
|
||||
// The pattern syntax is:
|
||||
//
|
||||
// pattern:
|
||||
// { term }
|
||||
//
|
||||
// term:
|
||||
// `*` matches any sequence of non-separator characters
|
||||
// `**` matches any sequence of characters
|
||||
// `?` matches any single non-separator character
|
||||
// `[` [ `!` ] { character-range } `]`
|
||||
// character class (must be non-empty)
|
||||
// `{` pattern-list `}`
|
||||
// pattern alternatives
|
||||
// c matches character c (c != `*`, `**`, `?`, `\`, `[`, `{`, `}`)
|
||||
// `\` c matches character c
|
||||
//
|
||||
// character-range:
|
||||
// c matches character c (c != `\\`, `-`, `]`)
|
||||
// `\` c matches character c
|
||||
// lo `-` hi matches character c for lo <= c <= hi
|
||||
//
|
||||
// pattern-list:
|
||||
// pattern { `,` pattern }
|
||||
// comma-separated (without spaces) patterns
|
||||
//
|
||||
|
||||
class GlobCompiler {
|
||||
nonSeparatorChars: string;
|
||||
globPattern: string;
|
||||
regexpPattern: string;
|
||||
regexp: RegExp;
|
||||
pos: number = 0;
|
||||
|
||||
#compileChars(): string {
|
||||
let result = '';
|
||||
if (this.globPattern[this.pos] === '!') {
|
||||
this.pos++;
|
||||
result += '^';
|
||||
}
|
||||
while (this.pos < this.globPattern.length) {
|
||||
const c = this.globPattern[this.pos];
|
||||
this.pos++;
|
||||
if (c === ']') {
|
||||
return `[${result}]`;
|
||||
}
|
||||
if (c === '\\') {
|
||||
if (this.pos >= this.globPattern.length) {
|
||||
throw new Error('Unterminated character class escape');
|
||||
}
|
||||
this.pos++;
|
||||
result += `\\${this.globPattern[this.pos]}`;
|
||||
} else {
|
||||
result += c;
|
||||
}
|
||||
}
|
||||
throw new Error('Unterminated character class');
|
||||
}
|
||||
|
||||
#compile(subPattern: boolean = false): string {
|
||||
let result = '';
|
||||
while (this.pos < this.globPattern.length) {
|
||||
const c = this.globPattern[this.pos];
|
||||
this.pos++;
|
||||
if (subPattern && c === '}') {
|
||||
return `(${result})`;
|
||||
}
|
||||
switch (c) {
|
||||
case '*':
|
||||
if (this.globPattern[this.pos] !== '*') {
|
||||
result += `${this.nonSeparatorChars}*`; // match any sequence of non-separator characters
|
||||
} else {
|
||||
this.pos++;
|
||||
result += '.*'; // match any sequence of characters
|
||||
}
|
||||
break;
|
||||
case '?':
|
||||
result += this.nonSeparatorChars; // match any single non-separator character
|
||||
break;
|
||||
case '[':
|
||||
result += this.#compileChars();
|
||||
break;
|
||||
case '{':
|
||||
result += this.#compile(true);
|
||||
break;
|
||||
case ',':
|
||||
result += subPattern ? '|' : ',';
|
||||
break;
|
||||
case '\\':
|
||||
if (this.pos >= this.globPattern.length) {
|
||||
throw new Error('No character to escape');
|
||||
}
|
||||
result += `\\${this.globPattern[this.pos]}`;
|
||||
this.pos++;
|
||||
break;
|
||||
case '.': case '+': case '^': case '$': case '(': case ')': case '|':
|
||||
result += `\\${c}`; // escape regexp special characters
|
||||
break;
|
||||
default:
|
||||
result += c;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
constructor(pattern: string, separators: string = '') {
|
||||
const escapedSeparators = separators.replaceAll(/[\^\]\-\\]/g, '\\$&');
|
||||
this.nonSeparatorChars = escapedSeparators ? `[^${escapedSeparators}]` : '.';
|
||||
this.globPattern = pattern;
|
||||
this.regexpPattern = `^${this.#compile()}$`;
|
||||
this.regexp = new RegExp(`^${this.regexpPattern}$`);
|
||||
}
|
||||
}
|
||||
|
||||
export function globCompile(pattern: string, separators: string = ''): GlobCompiler {
|
||||
return new GlobCompiler(pattern, separators);
|
||||
}
|
||||
|
||||
export function globMatch(str: string, pattern: string, separators: string = ''): boolean {
|
||||
try {
|
||||
return globCompile(pattern, separators).regexp.test(str);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
8
web_src/js/utils/html.test.ts
Normal file
8
web_src/js/utils/html.test.ts
Normal file
@@ -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>`);
|
||||
});
|
||||
32
web_src/js/utils/html.ts
Normal file
32
web_src/js/utils/html.ts
Normal file
@@ -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));
|
||||
}
|
||||
30
web_src/js/utils/image.test.ts
Normal file
30
web_src/js/utils/image.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {pngChunks, imageInfo} from './image.ts';
|
||||
|
||||
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: string) {
|
||||
return await (await globalThis.fetch(datauri)).blob(); // eslint-disable-line no-restricted-properties -- decodes a data URI, the fetch wrapper adds nothing here
|
||||
}
|
||||
|
||||
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});
|
||||
expect(await imageInfo(await dataUriToBlob(`data:image/gif;base64,`))).toEqual({});
|
||||
});
|
||||
58
web_src/js/utils/image.ts
Normal file
58
web_src/js/utils/image.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
type PngChunk = {
|
||||
name: string,
|
||||
data: Uint8Array,
|
||||
};
|
||||
|
||||
export async function pngChunks(blob: Blob): Promise<PngChunk[]> {
|
||||
const uint8arr = new Uint8Array(await blob.arrayBuffer());
|
||||
const chunks: PngChunk[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
type ImageInfo = {
|
||||
width?: number,
|
||||
dppx?: number,
|
||||
};
|
||||
|
||||
/** 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
|
||||
|
||||
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 {}
|
||||
} else {
|
||||
return {}; // no image info for non-image files
|
||||
}
|
||||
|
||||
return {width, dppx};
|
||||
}
|
||||
76
web_src/js/utils/match.test.ts
Normal file
76
web_src/js/utils/match.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {matchEmoji, matchMention} from './match.ts';
|
||||
|
||||
vi.mock('../modules/fetch.ts', () => ({GET: vi.fn()}));
|
||||
|
||||
const testMentions = [
|
||||
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
|
||||
{key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
|
||||
{key: 'org3 User 3', value: 'org3', name: 'org3', fullname: 'User 3', avatar: 'https://avatar3.com'},
|
||||
{key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'},
|
||||
{key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'},
|
||||
{key: 'org6 User 6', value: 'org6', name: 'org6', fullname: 'User 6', avatar: 'https://avatar6.com'},
|
||||
{key: 'org7 User 7', value: 'org7', name: 'org7', fullname: 'User 7', avatar: 'https://avatar7.com'},
|
||||
];
|
||||
|
||||
test('matchEmoji', () => {
|
||||
expect(matchEmoji('')).toMatchInlineSnapshot(`
|
||||
[
|
||||
"+1",
|
||||
"-1",
|
||||
"100",
|
||||
"1234",
|
||||
"1st_place_medal",
|
||||
"2nd_place_medal",
|
||||
]
|
||||
`);
|
||||
|
||||
expect(matchEmoji('hea')).toMatchInlineSnapshot(`
|
||||
[
|
||||
"head_shaking_horizontally",
|
||||
"head_shaking_vertically",
|
||||
"headphones",
|
||||
"headstone",
|
||||
"health_worker",
|
||||
"hear_no_evil",
|
||||
]
|
||||
`);
|
||||
|
||||
expect(matchEmoji('hear')).toMatchInlineSnapshot(`
|
||||
[
|
||||
"hear_no_evil",
|
||||
"heard_mcdonald_islands",
|
||||
"heart",
|
||||
"heart_decoration",
|
||||
"heart_eyes",
|
||||
"heart_eyes_cat",
|
||||
]
|
||||
`);
|
||||
|
||||
expect(matchEmoji('poo')).toMatchInlineSnapshot(`
|
||||
[
|
||||
"poodle",
|
||||
"hankey",
|
||||
"spoon",
|
||||
"bowl_with_spoon",
|
||||
]
|
||||
`);
|
||||
|
||||
expect(matchEmoji('1st_')).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1st_place_medal",
|
||||
]
|
||||
`);
|
||||
|
||||
expect(matchEmoji('jellyfis')).toMatchInlineSnapshot(`
|
||||
[
|
||||
"jellyfish",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('matchMention', async () => {
|
||||
vi.mocked(GET).mockResolvedValue({ok: true, json: () => Promise.resolve(testMentions)} as Response);
|
||||
expect(await matchMention('/any-mentions', '')).toEqual(testMentions.slice(0, 6));
|
||||
expect(await matchMention('/any-mentions', 'user4')).toEqual([testMentions[3]]);
|
||||
});
|
||||
82
web_src/js/utils/match.ts
Normal file
82
web_src/js/utils/match.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import emojis from '../../../assets/emoji.json' with {type: 'json'};
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {parseIssuePageInfo} from '../utils.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import type {Issue, Mention} from '../types.ts';
|
||||
|
||||
const maxMatches = 6;
|
||||
|
||||
function sortAndReduce<T>(map: Map<T, number>): T[] {
|
||||
const sortedMap = new Map(Array.from(map).sort((a, b) => a[1] - b[1]));
|
||||
return Array.from(sortedMap.keys()).slice(0, maxMatches);
|
||||
}
|
||||
|
||||
export function matchEmoji(queryText: string): string[] {
|
||||
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<string, number>();
|
||||
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);
|
||||
}
|
||||
|
||||
let cachedMentionsPromise: Promise<Mention[]> | undefined;
|
||||
let cachedMentionsUrl: string;
|
||||
|
||||
export function fetchMentions(mentionsUrl: string): Promise<Mention[]> {
|
||||
if (cachedMentionsPromise && cachedMentionsUrl === mentionsUrl) {
|
||||
return cachedMentionsPromise;
|
||||
}
|
||||
cachedMentionsUrl = mentionsUrl;
|
||||
cachedMentionsPromise = (async () => {
|
||||
try {
|
||||
const issueIndex = parseIssuePageInfo().issueNumber;
|
||||
const query = issueIndex ? `?issue_index=${issueIndex}` : '';
|
||||
const res = await GET(`${mentionsUrl}${query}`);
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return await res.json() as Mention[];
|
||||
} catch (e) {
|
||||
showErrorToast(`Failed to load mentions: ${errorMessage(e)}`);
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
return cachedMentionsPromise;
|
||||
}
|
||||
|
||||
export async function matchMention(mentionsUrl: string, queryText: string): Promise<Mention[]> {
|
||||
const values = await fetchMentions(mentionsUrl);
|
||||
const query = queryText.toLowerCase();
|
||||
|
||||
// results is a map of weights, lower is better
|
||||
const results = new Map<Mention, number>();
|
||||
for (const obj of values) {
|
||||
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);
|
||||
}
|
||||
|
||||
export async function matchIssue(owner: string, repo: string, issueIndexStr: string | undefined, query: string, signal: AbortSignal): Promise<Issue[]> {
|
||||
const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`, {signal});
|
||||
|
||||
const issues: Issue[] = await res.json();
|
||||
const issueNumber = parseInt(issueIndexStr || '');
|
||||
|
||||
// filter out issue with same id
|
||||
return issues.filter((i) => i.number !== issueNumber);
|
||||
}
|
||||
13
web_src/js/utils/string.test.ts
Normal file
13
web_src/js/utils/string.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import {cutString} from './string.ts';
|
||||
|
||||
test('cutString', () => {
|
||||
let [before, after, ok] = cutString('a = b = c', '=');
|
||||
expect(before).toBe('a ');
|
||||
expect(after).toBe(' b = c');
|
||||
expect(ok).toBe(true);
|
||||
|
||||
[before, after, ok] = cutString(' a ', '=');
|
||||
expect(before).toBe(' a ');
|
||||
expect(after).toBe('');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
5
web_src/js/utils/string.ts
Normal file
5
web_src/js/utils/string.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function cutString(s: string, sep: string): [string, string, boolean] {
|
||||
const index = s.indexOf(sep);
|
||||
if (index === -1) return [s, '', false];
|
||||
return [s.substring(0, index), s.substring(index + sep.length), true];
|
||||
}
|
||||
37
web_src/js/utils/testhelper.ts
Normal file
37
web_src/js/utils/testhelper.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {onTestFinished} from 'vitest';
|
||||
|
||||
/** Record and block navigations, as a real browser forbids stubbing "window.location" */
|
||||
export function captureNavigations() {
|
||||
const navigations: Array<{url: string, type: NavigationType}> = [];
|
||||
const onNavigate = (e: NavigateEvent) => {
|
||||
navigations.push({url: e.destination.url, type: e.navigationType});
|
||||
e.preventDefault();
|
||||
};
|
||||
window.navigation.addEventListener('navigate', onNavigate);
|
||||
onTestFinished(() => window.navigation.removeEventListener('navigate', onNavigate));
|
||||
return navigations;
|
||||
}
|
||||
|
||||
/** strip common indentation from a string and trim it */
|
||||
export function dedent(str: string) {
|
||||
const match = str.match(/^[ \t]*(?=\S)/gm);
|
||||
if (!match) return str;
|
||||
|
||||
let minIndent = Infinity;
|
||||
for (const indent of match) {
|
||||
minIndent = Math.min(minIndent, indent.length);
|
||||
}
|
||||
if (minIndent === 0 || minIndent === Infinity) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim();
|
||||
}
|
||||
|
||||
export function normalizeTestHtml(s: string) {
|
||||
const lines = s.replace(/>\s+</g, '>\n<').trim().split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
lines[i] = lines[i].trim();
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
15
web_src/js/utils/time.test.ts
Normal file
15
web_src/js/utils/time.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {startDaysBetween} from './time.ts';
|
||||
|
||||
test('startDaysBetween', () => {
|
||||
expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([
|
||||
1708214400000,
|
||||
1708819200000,
|
||||
1709424000000,
|
||||
1710028800000,
|
||||
1710633600000,
|
||||
1711238400000,
|
||||
1711843200000,
|
||||
1712448000000,
|
||||
1713052800000,
|
||||
]);
|
||||
});
|
||||
90
web_src/js/utils/time.ts
Normal file
90
web_src/js/utils/time.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
import type {ConfigType} from 'dayjs';
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
/**
|
||||
* Returns an array of millisecond-timestamps of start-of-week days (Sundays)
|
||||
*
|
||||
* @param startDate The start date. Can take any type that dayjs accepts.
|
||||
* @param endDate The end date. Can take any type that dayjs accepts.
|
||||
*/
|
||||
export function startDaysBetween(startDate: ConfigType, endDate: ConfigType): number[] {
|
||||
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: number[] = [];
|
||||
while (current.isBefore(end)) {
|
||||
startDays.push(current.valueOf());
|
||||
current = current.add(1, 'week');
|
||||
}
|
||||
|
||||
return startDays;
|
||||
}
|
||||
|
||||
export function firstStartDateAfterDate(inputDate: Date): number {
|
||||
if (!(inputDate instanceof Date)) {
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
const dayOfWeek = inputDate.getUTCDay();
|
||||
const daysUntilSunday = 7 - dayOfWeek;
|
||||
const resultDate = new Date(inputDate);
|
||||
resultDate.setUTCDate(resultDate.getUTCDate() + daysUntilSunday);
|
||||
return resultDate.valueOf();
|
||||
}
|
||||
|
||||
export type DayData = {
|
||||
week: number,
|
||||
additions: number,
|
||||
deletions: number,
|
||||
commits: number,
|
||||
};
|
||||
|
||||
export type DayDataObject = {
|
||||
[timestamp: string]: DayData,
|
||||
};
|
||||
|
||||
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayDataObject): DayData[] {
|
||||
const result: DayDataObject = {};
|
||||
|
||||
for (const startDay of startDays) {
|
||||
result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0};
|
||||
}
|
||||
|
||||
return Object.values(result);
|
||||
}
|
||||
|
||||
let dateFormat: Intl.DateTimeFormat;
|
||||
|
||||
// ISO 8601 UTC with 7-digit fractional seconds, matching Go's `2006-01-02T15:04:05.0000000Z07:00`
|
||||
export function formatDatetimeISO(unixSeconds: number): string {
|
||||
const base = new Date(unixSeconds * 1000).toISOString().slice(0, 19);
|
||||
const frac = unixSeconds - Math.floor(unixSeconds);
|
||||
const fracInt = Math.floor(frac * 10_000_000);
|
||||
return `${base}.${String(fracInt).padStart(7, '0')}Z`;
|
||||
}
|
||||
|
||||
/** Format a Date to a localized format, for example "21 May 2026, 14:30:45". */
|
||||
export function formatDatetime(date: Date | number): string {
|
||||
if (!dateFormat) {
|
||||
dateFormat = new Intl.DateTimeFormat(getCurrentLocale(), {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
hourCycle: new Intl.DateTimeFormat([], {hour: 'numeric'}).resolvedOptions().hourCycle === 'h12' ? 'h12' : 'h23',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
return dateFormat.format(date);
|
||||
}
|
||||
54
web_src/js/utils/url.test.ts
Normal file
54
web_src/js/utils/url.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {pathEscape, pathEscapeSegments, trimUrlPunctuation, urlQueryEscape, urlRawRegex} from './url.ts';
|
||||
|
||||
describe('escape', () => {
|
||||
const queryNonAscii = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
|
||||
test('urlQueryEscape', () => {
|
||||
const expected = '+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~';
|
||||
expect(urlQueryEscape(queryNonAscii)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('pathEscape', () => {
|
||||
const expected = '%20%21%22%23$%25&%27%28%29%2A+%2C-.%2F:%3B%3C=%3E%3F@%5B%5C%5D%5E_%60%7B%7C%7D~';
|
||||
expect(pathEscape(queryNonAscii)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('pathEscapeSegments', () => {
|
||||
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
|
||||
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
|
||||
expect(pathEscapeSegments('a/b+c')).toEqual('a/b+c');
|
||||
});
|
||||
});
|
||||
|
||||
test('matchUrls', () => {
|
||||
const matchUrls = (text: string) => Array.from(text.matchAll(urlRawRegex()), (m) => trimUrlPunctuation(m[0]));
|
||||
expect(matchUrls('visit https://example.com for info')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com.')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com, and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com; and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('(https://example.com)')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('"https://example.com"')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash.')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://x.co')).toEqual(['https://x.co']);
|
||||
expect(matchUrls('https://example.com/path_(wiki)')).toEqual(['https://example.com/path_(wiki)']);
|
||||
expect(matchUrls('https://en.wikipedia.org/wiki/Rust_(programming_language)')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('(https://en.wikipedia.org/wiki/Rust_(programming_language))')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('http://example.com')).toEqual(['http://example.com']);
|
||||
expect(matchUrls('no url here')).toEqual([]);
|
||||
expect(matchUrls('https://a.com and https://b.com')).toEqual(['https://a.com', 'https://b.com']);
|
||||
expect(matchUrls('[](https://www.npmjs.org/package/pkg)')).toEqual(['https://img.shields.io/npm/v/pkg.svg?style=flat', 'https://www.npmjs.org/package/pkg']);
|
||||
});
|
||||
|
||||
test('trimUrlPunctuation', () => {
|
||||
expect(trimUrlPunctuation('https://example.com.')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com,')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com:')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation("https://example.com'")).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com"')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com.,;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com/path')).toEqual('https://example.com/path');
|
||||
expect(trimUrlPunctuation('https://example.com/path_(wiki)')).toEqual('https://example.com/path_(wiki)');
|
||||
expect(trimUrlPunctuation('https://example.com)')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://en.wikipedia.org/wiki/Rust_(lang))')).toEqual('https://en.wikipedia.org/wiki/Rust_(lang)');
|
||||
});
|
||||
46
web_src/js/utils/url.ts
Normal file
46
web_src/js/utils/url.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/** Matches URLs, excluding characters that are never valid unencoded in URLs per RFC 3986. */
|
||||
export const urlRawRegex = () => /\bhttps?:\/\/[^\s<>[\]]+/gi; // JS regexp has internal states, so always use a new instance
|
||||
|
||||
/** Strip trailing punctuation that is likely not part of the URL. */
|
||||
export function trimUrlPunctuation(url: string): string {
|
||||
url = url.replace(/[.,;:'"]+$/, '');
|
||||
// Strip trailing closing parens only if unbalanced (not part of the URL like Wikipedia links),
|
||||
// counted once as a URL can carry as many parens as the text it was found in is long
|
||||
let unbalanced = 0;
|
||||
for (const char of url) unbalanced += char === ')' ? 1 : char === '(' ? -1 : 0;
|
||||
let strip = 0;
|
||||
while (strip < unbalanced && url[url.length - 1 - strip] === ')') strip++;
|
||||
return strip ? url.slice(0, -strip) : url;
|
||||
}
|
||||
|
||||
export function urlQueryEscape(s: string) {
|
||||
// See "TestQueryEscape" in backend
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
||||
return encodeURIComponent(s).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
).replaceAll('%20', '+');
|
||||
}
|
||||
|
||||
export function pathEscape(s: string): string {
|
||||
// See "TestPathEscape" in backend
|
||||
return encodeURIComponent(s).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
).replaceAll(/%(\w\w)/g, (v) => {
|
||||
switch (v) {
|
||||
case '%24': return '$';
|
||||
case '%26': return '&';
|
||||
case '%2B': return '+';
|
||||
case '%3A': return ':';
|
||||
case '%3D': return '=';
|
||||
case '%40': return '@';
|
||||
default: return v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function pathEscapeSegments(s: string): string {
|
||||
// The same as backend's PathEscapeSegments
|
||||
return s.split('/').map(pathEscape).join('/');
|
||||
}
|
||||
Reference in New Issue
Block a user