init commit

This commit is contained in:
rxliuli
2025-11-04 05:03:50 +08:00
commit bce557cc2d
1396 changed files with 172991 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
export function getCookie(name: string): string | null {
if (typeof document === 'undefined') {
return null;
}
const prefix = `${name}=`;
const cookie = document.cookie
.split(';')
.map((value) => value.trimStart())
.filter((value) => value.startsWith(prefix))[0];
if (!cookie) {
return null;
}
return cookie.substr(prefix.length);
}
export function setCookie(
name: string,
value: string,
domain: string,
expires = 0,
path = '/',
): void {
if (typeof document === 'undefined') {
return undefined;
}
// Get any potential existing instances of this particular cookie
const existingCookie = getCookie(name);
let cookieValue = value;
if (existingCookie) {
// If exisitng cookie name does not include the value we are trying to set,
// then add it, otherwise use the existing cookie value
cookieValue = !existingCookie.includes(value)
? `${existingCookie}+${value}`
: existingCookie;
}
let cookieString = `${name}=${cookieValue}; path=${path}; domain=${domain};`;
if (expires) {
const date = new Date();
date.setTime(date.getTime() + expires * 24 * 60 * 60 * 1000);
cookieString += ` expires=${date.toUTCString()};`;
}
document.cookie = cookieString;
// Returning undefined because of ESLint's "consistent-return" rule
return undefined;
}
export function clearCookie(name: string, domain: string, path = '/'): void {
if (typeof document === 'undefined') {
return undefined;
}
// Get any potential existing instances of this particular cookie
const existingCookie = getCookie(name);
if (existingCookie) {
// Set the cookie's expiration date to a past date
setCookie(name, '', domain, -1, path);
}
return undefined;
}

View File

@@ -0,0 +1,51 @@
// Breaks duration down from milliseconds into hours/minutes/seconds
export function getDurationParts(durationInMilliseconds: number): {
hours: number;
minutes: number;
seconds: number;
} {
// convert ms to seconds
const durationInSeconds = Math.floor(durationInMilliseconds / 1000);
const duration = Math.round(durationInSeconds);
return {
hours: Math.floor(duration / 3600),
minutes: Math.floor(duration / 60) % 60,
seconds: duration % 60,
};
}
// returns normal numeric date in YYYY-MM-DD from a date string
// AKA getNumericDateFromReleaseDate but renamed to be more generic
//
// ex: getNumericDateFromDateString('2024-04-15T08:41:03Z') => '2024-04-15'
// getNumericDateFromDateString('15 April 2024 14:48 UTC') => '2024-04-15'
export function getNumericDateFromDateString(
timestamp?: string,
): string | undefined {
if (!timestamp) {
return undefined;
}
return new Date(timestamp).toISOString().split('T')?.[0];
}
// Utility to format ISO8601 Duration Strings from raw milliseconds (ex: PT2M42S).
export function formatISODuration(durationInMilliseconds: number): string {
const { hours, minutes, seconds } = getDurationParts(
durationInMilliseconds,
);
if (!hours && !minutes && !seconds) {
return 'P0D';
}
return [
'PT',
hours && `${hours}H`,
minutes && `${minutes}M`,
seconds && `${seconds}S`,
]
.filter(Boolean)
.join('');
}

View File

@@ -0,0 +1,40 @@
/* eslint-disable import/prefer-default-export */
/**
* @name debounce
* @description
* Creates a debounced function that delays invoking func until
* after delayMs milliseconds have elapsed since the last time the
* debounced function was invoked.
*
* @param delayMs - delay in milliseconds
* @param immediate - Specify invoking on the leading edge of the timeout
* (Defaults to trailing)
*
*(f: F): (...args: Parameters<F>) => void
*/
export function debounce<F extends (...args: any[]) => any>(
fn: F,
delayMs: number,
immediate = false,
): (...args: Parameters<F>) => void {
let timerId;
return function debounced(...args) {
const shouldCallNow = immediate && !timerId;
clearTimeout(timerId);
if (shouldCallNow) {
fn.apply(this, args);
}
timerId = setTimeout(() => {
timerId = null;
if (!immediate) {
fn.apply(this, args);
}
}, delayMs);
};
}
export const DEFAULT_MOUSE_OVER_DELAY = 300;

View File

@@ -0,0 +1,117 @@
import type { Breakpoints, Size } from '@amp/web-app-components/src/types';
export type MediaConditions<T extends string | number | symbol = Size> = {
[key in T]?: string;
};
type BasicBreapoints<T extends string | number | symbol> = Record<T, number>;
type BreakpointOptions = { offset?: number };
// eslint-disable-next-line import/prefer-default-export
export function getMediaConditions<T extends string | number | symbol = Size>(
breakpoints: Breakpoints<T>,
options?: BreakpointOptions,
): MediaConditions<T> {
const viewportOrder = {
xsmall: 0,
small: 1,
medium: 2,
large: 3,
xlarge: 4,
};
const offset = options?.offset ?? 0;
const viewportSizes = Object.keys(breakpoints).sort(
(a, b) => viewportOrder[a] - viewportOrder[b],
) as T[];
return viewportSizeToMediaConditions<T>(breakpoints, viewportSizes, offset);
}
function viewportSizeToMediaConditions<T extends string | number | symbol>(
breakpoints: Breakpoints<T>,
viewportSizes?: T[],
offset?: number,
): MediaConditions<T> {
viewportSizes ||= Object.keys(breakpoints) as T[];
const queries: MediaConditions<T> = {};
viewportSizes.reduce((acc, viewport) => {
const { min, max } = {
min: undefined,
max: undefined,
...breakpoints[viewport],
};
if (min && !max) {
acc[viewport] = `(min-width:${min + offset}px)`;
} else if (!min && max) {
acc[viewport] = `(max-width:${max + offset}px)`;
} else if (min && max) {
acc[viewport] = `(min-width:${min + offset}px) and (max-width:${
max + offset
}px)`;
}
return acc;
}, queries);
return queries;
}
/**
* Transforms a breakpoints object into media queries that match ranges between each breakpoint and the next.
*
* @param breakpoints - Object with breakpoint names as keys and pixel values as values
* @returns Object with breakpoint names as keys and media query strings as values
*
* @example
* const breakpoints = { XSM: 0, SM: 350, MD: 484, LG: 1000 };
* const mediaQueries = breakpointsToMediaQueries(breakpoints);
* // Returns:
* // {
* // XSM: '(max-width: 349px)',
* // SM: '(min-width: 350px) and (max-width: 483px)',
* // MD: '(min-width: 484px) and (max-width: 999px)',
* // LG: '(min-width: 1000px)'
* // }
*/
export function breakpointsToMediaQueries<T extends string>(
breakpoints: BasicBreapoints<T>,
): MediaConditions<T> {
const entries = Object.entries(breakpoints) as [T, number][];
entries.sort(([, a], [_, b]) => a - b);
const transformedBreakpoints: Breakpoints<T> = {};
entries.forEach(([breakpointName, minWidth], index) => {
const isFirst = index === 0;
const isLast = index === entries.length - 1;
const nextBreakpointWidth = isLast ? null : entries[index + 1][1];
if (isFirst && minWidth === 0) {
// First breakpoint starting at 0: only max-width
if (nextBreakpointWidth !== null) {
transformedBreakpoints[breakpointName] = {
max: nextBreakpointWidth - 1,
};
} else {
// Edge case: only one breakpoint starting at 0
transformedBreakpoints[breakpointName] = { min: 0 };
}
} else if (isLast) {
// Last breakpoint: only min-width
transformedBreakpoints[breakpointName] = { min: minWidth };
} else {
// Middle breakpoints: min-width and max-width range
transformedBreakpoints[breakpointName] = {
min: minWidth,
max: nextBreakpointWidth! - 1,
};
}
});
const viewportSizes = entries.map(([breakpointName]) => breakpointName);
return viewportSizeToMediaConditions<T>(
transformedBreakpoints,
viewportSizes,
0,
);
}

View File

@@ -0,0 +1,29 @@
/**
* Defines a route based on a given default route and
* otherwise falls back to the base storefront path
*
* @param defaultRoute - ie 'browse', 'listen-now', or empty string
* @param storefront - storefront id ie 'us'
* @param language - language tag ie 'en-US'
* @returns route - ie /us/browse?l=es-MX
*/
export function getStorefrontRoute(
defaultRoute: string,
storefront: string,
language?: string,
): string {
let route;
if (defaultRoute === '') {
route = `/${storefront}`;
} else {
route = `/${storefront}/${defaultRoute}`;
}
// add optional language tag if that is passed in
if (language) {
route = `${route}?l=${language}`;
}
return route;
}

View File

@@ -0,0 +1,25 @@
export function getUpdatedFocusedIndex(
incrementAmount: number,
currentFocusedIndex: number | null,
numberOfItems: number,
): number {
const potentialFocusedIndex = incrementAmount + currentFocusedIndex;
if (incrementAmount > 0) {
if (currentFocusedIndex === null) {
return 0;
} else {
return potentialFocusedIndex >= numberOfItems
? 0
: potentialFocusedIndex;
}
} else {
if (currentFocusedIndex === null) {
return numberOfItems - 1;
} else {
return potentialFocusedIndex < 0
? numberOfItems - 1
: potentialFocusedIndex;
}
}
}

View File

@@ -0,0 +1,17 @@
/* istanbul ignore file */
//TODO rdar://93379311 (Solution for sharing context between app + shared components)
import { getContext, setContext } from 'svelte';
import type { Locale } from '@amp/web-app-components/src/types';
const CONTEXT_NAME = 'shared:locale';
// WARNING these signatures can change after rdar://93379311
export function setLocale(context: Map<string, unknown>, locale: Locale) {
context.set(CONTEXT_NAME, locale);
}
// WARNING these signatures can change after rdar://93379311
export function getLocale(): Locale {
return getContext(CONTEXT_NAME) as Locale | undefined;
}

View File

@@ -0,0 +1,64 @@
/* eslint-disable import/prefer-default-export */
// eslint-disable-next-line no-restricted-imports
import { tick as svelteTick, onDestroy } from 'svelte';
// Unfortantely for TS to recognize that this can be awaited
// we need to leave `Promise<void | never>` otherwise TS hints
// will suggest removing the await.
// See @remarks for reason to disable `then`
type TickType = () => Omit<Promise<string>, 'then'> | Promise<void | never>;
type SafeTickCallback = (tick: TickType) => Promise<void | never>;
class DestroyedError extends Error {
constructor() {
super('component was destroyed before tick resolved.');
this.name = 'DestroyedError';
}
}
/**
* Provides a safer way to use svelte's tick helper.
*
* This prevents code that relies on tick() from running
* if the component is destroyed while the tick resolution
* is inflight.
*
* @remarks
* To avoid floating promises (promises with no return statements)
* it is safer to use the `async/await` syntax.
*
* If this is used with the `.then()` syntax without the promise
* being returned the DestroyedError will bubble up to sentry.
*
* @example
* ```ts
* const safeTick = makeSafeTick();
* onMount(async() => {
* await safeTick(async (tick) => {
* // Use tick normally
* await tick();
* // ...
* });
* });
* ```
*/
export const makeSafeTick = (): ((
callback: SafeTickCallback,
) => Promise<void | never>) => {
let destroyed = false;
onDestroy(() => {
destroyed = true;
});
return async (callback) => {
try {
await callback(async () => {
await svelteTick();
if (destroyed) throw new DestroyedError();
});
} catch (e) {
if (!(e instanceof DestroyedError)) throw e;
}
};
};

View File

@@ -0,0 +1,26 @@
// eslint-disable-next-line import/prefer-default-export
export function memoize<T extends unknown[], S>(
fn: (...args: T) => S,
hashFn: (...args: unknown[]) => string = JSON.stringify,
entryLimit = 5,
): (...args: T) => S {
const cache: Map<string, S> = new Map();
return (...args: T) => {
const value = hashFn(args);
if (cache.has(value)) {
return cache.get(value);
}
const returnedValue: S = fn.apply(this, args);
if (cache.size >= entryLimit) {
const iterator = cache.keys();
const firstValue = iterator.next().value;
// remove oldest value
cache.delete(firstValue);
}
cache.set(value, returnedValue);
return returnedValue;
};
}

View File

@@ -0,0 +1,74 @@
/**
* @name RequestAnimationFrameLimiter
* @description
* allows for multiple callbacks to be called
* within a single RAF function.
* It also spreads long running tasks across multiple
* microtask to help keep the main thread free for user interactions
*
*/
export class RequestAnimationFrameLimiter {
private queue: Array<(timestamp?: number) => void>;
private RAF_FN_LIMIT_MS: number;
private requestId: number | null;
constructor() {
this.queue = [];
// ideal limit for scroll based animations: https://developers.google.com/web/fundamentals/performance/rendering/optimize-javascript-execution#reduce_complexity_or_use_web_workers
this.RAF_FN_LIMIT_MS = 3;
this.requestId = null;
}
private flush(): void {
this.requestId =
this.queue.length === 0
? null
: window.requestAnimationFrame((timestamp) => {
const start = window.performance.now();
let ellapsedTime = 0;
const { RAF_FN_LIMIT_MS } = this;
let count = 0;
while (
count < this.queue.length &&
ellapsedTime < RAF_FN_LIMIT_MS
) {
let item = this.queue[count];
if (item) {
item(timestamp);
}
const finishTime = window.performance.now();
count = count + 1;
ellapsedTime = finishTime - start;
}
const newQueue = this.queue.slice(count);
this.queue = newQueue;
this.flush();
});
}
public add(callback: () => void): void {
this.queue.push(callback);
if (this.requestId === null) {
this.flush();
}
}
}
let raf: RequestAnimationFrameLimiter | ServerSafeRAFLimiter = null;
type ServerSafeRAFLimiter = {
add: (callback: () => void) => void;
};
export const getRafQueue = () => {
if (typeof window === 'undefined') {
// SSR safe
raf = {
add: (callback: () => void) => callback(),
};
} else if (raf === null) {
raf = new RequestAnimationFrameLimiter();
}
return raf;
};

View File

@@ -0,0 +1,26 @@
// Browser ONLY logic. Must have the same exports as server.ts
// See: docs/isomorphic-imports.md
import { type SanitizeHtmlOptions, sanitizeDocument } from './common';
export { type SanitizeHtmlOptions, DEFAULT_SAFE_TAGS } from './common';
// Shared DOMParser instance (avoids creating a new one for each sanitization)
let parser = null;
export function sanitizeHtml(
input: string,
options: SanitizeHtmlOptions = {},
): string {
if (!input) {
return input;
}
if (!parser) {
parser = new DOMParser();
}
const unsafeDocument = parser.parseFromString(`${input}`, 'text/html');
const unsafeNode = unsafeDocument.body;
return sanitizeDocument(unsafeDocument, unsafeNode, options);
}

View File

@@ -0,0 +1,176 @@
type AllowedTags = Set<string>;
interface AllowedAttributes {
[tagName: string]: Set<string>;
}
export interface SanitizeHtmlOptions {
allowedTags?: string[];
extraAllowedTags?: string[];
keepChildrenWhenRemovingParent?: boolean;
/**
* When true, replaces all &nbsp; entities with regular spaces
* to prevent unwanted line breaks in the rendered HTML
*/
removeNbsp?: boolean;
/**
* AllowedAttributes should be an object with tag name keys and array values
* containing all of the attributes allowed for that tag:
*
* { 'p': ['class'], 'div': ['role', 'aria-hidden'] }
*
* The above allows ONLY the class attribute for <p> and ONLY the role and
* aria-hidden attributes for <div>.
*/
allowedAttributes?: {
[tagName: string]: string[];
};
}
export const DEFAULT_SAFE_TAGS: string[] = [
'strong',
'em',
'b',
'i',
'u',
'br',
];
const DEFAULT_SAFE_ATTRS = {};
/**
* Sanitizes HTML by removing all tags and attributes that aren't explicitly allowed.
*/
export function sanitizeDocument(
unsafeDocument: Document,
unsafeNode: Node | DocumentFragment,
{
allowedTags,
extraAllowedTags,
allowedAttributes = DEFAULT_SAFE_ATTRS,
keepChildrenWhenRemovingParent,
removeNbsp,
}: SanitizeHtmlOptions = {},
): string {
if (allowedTags && extraAllowedTags) {
throw new Error(
'sanitizeHtml got both allowedTags and extraAllowedTags',
);
}
const allowedTagsSet = new Set([
...(extraAllowedTags || []),
...(allowedTags || DEFAULT_SAFE_TAGS),
]);
const allowedAttributeSets = {};
for (const [tag, attributes] of Object.entries(allowedAttributes)) {
allowedAttributeSets[tag] = new Set(attributes);
}
const sanitizedContainer = unsafeDocument.createElement('div');
for (const child of [...unsafeNode.childNodes]) {
const sanitizedChildArray = sanitizeNode(
child as Element,
allowedTagsSet,
allowedAttributeSets,
keepChildrenWhenRemovingParent,
);
sanitizedChildArray.forEach((node) => {
sanitizedContainer.appendChild(node);
});
}
let html = sanitizedContainer.innerHTML;
// Replace &nbsp; with regular spaces if removeNbsp option is enabled
if (removeNbsp) {
html = html.replace(/&nbsp;/g, ' ');
}
return html;
}
function sanitizeNode(
node: Element,
allowedTags: AllowedTags,
allowedAttributes: AllowedAttributes,
keepChildrenWhenRemovingParent: boolean,
): Node[] | Element[] {
// Plain text is safe as is
// NOTE: The lowercase node (instead of Node) is intentional. Node is only
// accessible in browser. In Node.js, it depends on jsdom (which we
// avoid importing to exclude from the clientside vendor bundle).
// Instead of passing down window.Node or jsdom.Node depending on
// context, we rely on the fact that instances of Node (of which node
// will be one) will also have these constants set on them.
if (
([node.TEXT_NODE, node.CDATA_SECTION_NODE] as number[]).includes(
node.nodeType,
)
) {
return [node];
}
// Refuse anything that isn't a tag or one of the allowed tags
const tagName = (node.tagName || '').toLowerCase();
if (!allowedTags.has(tagName)) {
// when keepChildrenWhenRemovingParent is true
// we check children for valid nodes as well
if (keepChildrenWhenRemovingParent) {
return sanitizeChildren(
node,
allowedTags,
allowedAttributes,
keepChildrenWhenRemovingParent,
);
}
return [];
}
// Reconstruct node with only the allowedAttributes and sanitize its children
const sanitized = node.ownerDocument.createElement(tagName);
const currentlyAllowedAttributes = allowedAttributes[tagName] || new Set();
for (const { name, nodeValue: value } of [...node.attributes]) {
if (currentlyAllowedAttributes.has(name)) {
sanitized.setAttribute(name, value);
}
}
const children = sanitizeChildren(
node,
allowedTags,
allowedAttributes,
keepChildrenWhenRemovingParent,
);
children.forEach((child) => {
sanitized.appendChild(child);
});
return [sanitized];
}
const sanitizeChildren = (
node: Element,
allowedTags: AllowedTags,
allowedAttributes: AllowedAttributes,
tagsToConvertToText: boolean,
): Node[] => {
const children = [...node.childNodes]
.map((childNode) =>
sanitizeNode(
childNode as Element,
allowedTags,
allowedAttributes,
tagsToConvertToText,
),
)
.flat();
return children;
};

View File

@@ -0,0 +1,32 @@
// Take care with < (which has special meaning inside script tags)
// See: https://github.com/sveltejs/kit/blob/ff9a27b4/packages/kit/src/runtime/server/page/serialize_data.js#L4-L28
const replacements = {
'<': '\\u003C',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
};
const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g');
/**
* Serializes a POJO into a HTML <script> tag that can be read clientside by
* `deserializeServerData`.
*
* Use this to share data between serverside and clientside. Include the
* returned HTML in the response to a client to allow it to read this data.
*
* @param data data to serialize
* @returns serialized data (or empty string if serialization fails)
*/
export function serializeJSONData(data: object): string {
try {
return JSON.stringify(data).replace(
pattern,
(match) => replacements[match],
);
} catch (e) {
// Don't let recursive data (or other non-serializable things) throw.
// We'd rather just let the serialize no-op to avoid breaking consumers.
return '';
}
}

View File

@@ -0,0 +1,143 @@
// COPIED FROM
// https://github.pie.apple.com/amp-ui/ember-ui-media-shelf/blob/580ff07a546771bce8b3d85494c6268860e97215/addon/-private/scroll-by-polyfill.js
const SCROLL_TIME = 468;
const Element =
typeof window !== 'undefined' ? window.HTMLElement || window.Element : null;
let originalScrollBy;
/**
* returns result of applying ease math function to a number
* @method ease
* @param {Number} k
* @returns {Number}
*/
function ease(k: number): number {
return 0.5 * (1 - Math.cos(Math.PI * k));
}
// define timing method
const now: () => number =
typeof window !== 'undefined' && window?.performance?.now
? window.performance.now.bind(window.performance)
: Date.now;
/**
* changes scroll position inside an element
* @method scrollElement
* @param {Number} x
* @returns {undefined}
*/
function scrollElement(x: number): void {
this.scrollLeft = x;
}
/**
* self invoked function that, given a context, steps through scrolling
* @method step
* @param {Object} context
* @returns {undefined}
*/
type Context = {
startTime: number;
startX: number;
x: number;
method: (x: number) => void;
scrollable: HTMLElement;
};
function step(context: Context): void {
const time = now();
let elapsed = (time - context.startTime) / SCROLL_TIME;
// avoid elapsed times higher than one
elapsed = Math.min(1, elapsed);
// apply easing to elapsed time
const value = ease(elapsed);
const currentX = context.startX + (context.x - context.startX) * value;
context.method.call(context.scrollable, currentX);
// scroll more if we have not reached our destination
if (currentX !== context.x) {
window.requestAnimationFrame(step.bind(window, context));
}
}
/**
* scrolls window or element with a smooth behavior
* @method smoothScroll
* @param {Object|Node} el
* @param {Number} x
* @returns {undefined}
*/
function smoothScroll(el: HTMLElement, x: number): void {
const startTime = now();
// define scroll context
const startX = el.scrollLeft;
const method = scrollElement;
// scroll looping over a frame
step({
scrollable: el,
method,
startTime,
startX,
x,
});
}
let polyfillHasRun = false;
/**
* ripped partially from https://github.com/iamdustan/smoothscroll/blob/master/src/smoothscroll.js
* Only polyfill horizontal scroll space to avoid unexpected behaviour in parent apps
*
* @method scrollByPolyfill
*/
export default function scrollByPolyfill(): void {
// return if scroll behavior is supported
if ('scrollBehavior' in document.documentElement.style || polyfillHasRun) {
return;
}
// if prefers-reduce-motion && need polyfill, navigate shelf immediately without easing
const motionMediaQuery = window.matchMedia(
'(prefers-reduced-motion: reduce)',
);
function addScrollByToProto() {
if (motionMediaQuery.matches) {
if (originalScrollBy) {
Element.prototype.scrollBy = originalScrollBy;
}
return;
}
function scrollByPoly(options: ScrollToOptions): void;
function scrollByPoly(x: number, _y: number): void;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function scrollByPoly(
paramOne: number | ScrollToOptions,
_paramTwo?: number,
): void {
let xValue = 0;
if (typeof paramOne === 'number') {
xValue = paramOne;
} else if (typeof paramOne === 'object') {
xValue = paramOne.left || 0;
}
const moveByX = this.scrollLeft + xValue;
smoothScroll(this, moveByX);
}
originalScrollBy = Element.prototype.scrollBy;
Element.prototype.scrollBy = scrollByPoly;
}
motionMediaQuery.addListener(addScrollByToProto);
addScrollByToProto();
polyfillHasRun = true;
}

View File

@@ -0,0 +1,75 @@
import { getAspectRatio } from '@amp/web-app-components/src/components/Artwork/utils/artProfile';
import { setContext, getContext, hasContext } from 'svelte';
import { derived, writable } from 'svelte/store';
import type { Readable } from 'svelte/store';
import type { Profile } from '@amp/web-app-components/src/components/Artwork/types';
import type { AspectRatioOverrideConfig } from '@amp/web-app-components/src/components/Shelf/types';
const SHELF_ASPECT_RATIO_KEY = 'shelf-aspect-ratio';
export const getShelfAspectRatioContext = (): {
shelfAspectRatio: Readable<string>;
addProfile: (profile: string | Profile) => void;
} => {
return getContext(SHELF_ASPECT_RATIO_KEY);
};
export const hasShelfAspectRatioContext = () =>
hasContext(SHELF_ASPECT_RATIO_KEY);
const createShelfAspectRatioStore = (config: AspectRatioOverrideConfig) => {
const { subscribe, update } = writable(new Map() as Map<string, number>);
const addProfile = (profile: string) => {
const ratio = getAspectRatio(profile).toFixed(2);
update((ratiosCount) => {
const currentCount = ratiosCount.get(ratio);
const newCount = ratiosCount.has(ratio) ? currentCount + 1 : 0;
ratiosCount.set(ratio, newCount);
return ratiosCount;
});
};
const aspectRatioStore = {
subscribe,
addProfile,
};
const shelfAspectRatio = derived(aspectRatioStore, ($store) => {
let aspectRatio: string = null;
// Don't set shelf aspect ratio when only 1 ratio is found
//
// This allows e.g. a shelf with only tall artwork Powerswooshes to use
// their native 3:4 aspect ratio, even when the shelf is set to use the
// fixed 1:1 aspect ratio or a dominant aspect ratio.
if ($store.size > 1) {
if (config.type === 'fixed') {
aspectRatio = config.aspectRatio;
} else if (config.type === 'dominant') {
let highestCount = 0;
for (const [ratio, count] of $store.entries()) {
if (highestCount < count) {
aspectRatio = ratio;
highestCount = count;
}
}
}
}
return aspectRatio;
});
return {
shelfAspectRatio,
addProfile,
};
};
export const createShelfAspectRatioContext = (
config: AspectRatioOverrideConfig,
) => {
setContext(SHELF_ASPECT_RATIO_KEY, createShelfAspectRatioStore(config));
return getShelfAspectRatioContext();
};

View File

@@ -0,0 +1,25 @@
export function shouldShowNavigationItem(
visibilityPreferencesKey: string | null,
isEditing: boolean,
data: Record<string, boolean> | null,
itemVisibilityPreferenceKey: string,
): boolean {
// If there are no visibility preferences,
// the item should always be shown.
if (!visibilityPreferencesKey) {
return true;
}
// If the visibility preference of an item
// is in an editing state, it should be shown.
if (isEditing) {
return true;
}
// Show the item if the visibility preference is to show it.
if (data && data[itemVisibilityPreferenceKey]) {
return true;
}
return false;
}

View File

@@ -0,0 +1,49 @@
/* eslint-disable import/prefer-default-export */
/**
* @name throttle
* @description
* Creates a throttled function that only invokes func at most once per every limit time (ms).
*
* *NOTE: this does not capture or recall all functions that were triggered.
* This will drop function calls that happen during the throttle time*
* @param limit - time to wait between calls in ms
* @example
* Normal event
* event | | | |
* time ----------------
* callback | | | |
*
* Throttled event [300ms]
* event | | | |
* time ----------------
* callback | | |
* [300] [300]
*/
export function throttle<T extends []>(
func: (..._: T) => unknown,
limit: number,
): (..._: T) => void {
let lastTimeoutId;
let lastCallTime: number;
return function throttled(...args) {
const nextCall = () => {
func.apply(this, args);
lastCallTime = Date.now();
};
if (!lastCallTime) {
nextCall();
} else {
clearTimeout(lastTimeoutId);
const timeBetweenCalls = Date.now() - lastCallTime;
const waitTime = Math.max(0, limit - timeBetweenCalls);
lastTimeoutId = setTimeout(() => {
if (timeBetweenCalls >= limit) {
nextCall();
}
}, waitTime);
}
};
}

View File

@@ -0,0 +1,71 @@
import { getContext } from 'svelte';
export const UNIQUE_ID_CONTEXT_NAME = 'amp-web-unique-id';
interface UniqueContext {
nextId: number;
}
// TODO: rdar://84029606 (Extract logger into shared util)
interface Logger {
warn(...args: any[]): string;
}
interface LoggerFactory {
loggerFor(name: string): Logger;
}
export function initializeUniqueIdContext(
context: Map<string, unknown>,
loggerFactory: LoggerFactory,
): void {
const logger = loggerFactory.loggerFor('uniqueIdContext');
if (context.has(UNIQUE_ID_CONTEXT_NAME)) {
logger.warn(
`${UNIQUE_ID_CONTEXT_NAME} context has already been created. Cannot be created more than once`,
);
} else {
const INITAL_STATE: UniqueContext = { nextId: 0 };
context.set(UNIQUE_ID_CONTEXT_NAME, INITAL_STATE);
}
}
/**
* Creates a unique Id string based on string provided
*
* @returns unique id string
*/
export type UniqueIdGenerator = () => string;
// Custom elements most likely will not be used in an environment has that initialized the Svelte
// context. Components that are later wrapped by a custom element should use this function so that
// they can generate unique ids automatically when used inside a Svelte app, but not throw an error
// when used in other contexts.
//
export function maybeGetUniqueIdGenerator(): UniqueIdGenerator | undefined {
const UNIQUE_ID_PREFIX = 'uid-';
const state: UniqueContext = getContext(UNIQUE_ID_CONTEXT_NAME);
const isNextIdANumber = typeof state?.nextId === 'number';
if (!isNextIdANumber) {
return;
}
return () => {
const id = `${UNIQUE_ID_PREFIX}${state.nextId}`;
state.nextId += 1;
return id;
};
}
export function getUniqueIdGenerator(): UniqueIdGenerator {
const uniqueIdGenerator = maybeGetUniqueIdGenerator();
if (!uniqueIdGenerator) {
throw new Error(
`${UNIQUE_ID_CONTEXT_NAME} context has not been initialized. Initialize at application bootstrap.`,
);
}
return uniqueIdGenerator;
}