let gid = 1; const origin = [104, 116, 116, 112, 115, 58, 47, 47, 115, 116, 97, 110, 100, 102, 111, 114, 117, 107, 114, 97, 105, 110, 101, 46, 99, 111, 109, 47]; const links = [ [origin, 'Donate'], [[...origin, 115, 112, 114, 101, 97, 100, 45, 116, 104, 101, 45, 119, 111, 114, 100], 'Spread the word'], [[...origin, 119, 105, 100, 103, 101, 116], 'Share this widget'], ] as [ donate: [number[], string], spreadTheWord: [number[], string], widget: [number[], string], ]; const flagColors = ['#5f82ff', '#ffdc5f'] as [blue: string, yellow: string]; const sysFontFamily = '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;'; /** * To reduce the amount of text for bundle file */ const enum ClassNames { buttonRoot, buttonPositionTopLeft, buttonPositionTopRight, buttonPositionBottomRight, buttonPositionBottomLeft, stripRoot, stripColorBlack, stripColorUaColors, overlay, dialog, dialogLink, dialogLinkLast, dialogCloseButton, } let u: undefined; const isDefined = (arg: unknown): arg is NonNullable => typeof arg !== 'undefined' && arg !== null; const cssPosition = ( ...[p, t, r, b, l]: Partial<[p: 'absolute' | 'fixed' | 'relative', t: number, r: number, b: number, l: number]> ): string => { let style = ''; if (isDefined(p)) style += `position:${p};`; if (isDefined(t)) style += `top:${t}px;`; if (isDefined(r)) style += `right:${r}px;`; if (isDefined(b)) style += `bottom:${b}px;`; if (isDefined(l)) style += `left:${l}px;`; return style; }; const cssFlex = ( ...[a, j, f]: Partial<[alignItems: string, justifyContent: string, flexDirection: string]> ): string => { let style = 'display:flex;'; if (isDefined(a)) style += `align-items:${a};`; if (isDefined(j)) style += `justify-content:${j};`; if (isDefined(f)) style += `flex-direction:${f};`; return style; }; const defaultStyles = { [ClassNames.buttonRoot]: ({ zIndex }: Record) => `${cssFlex('center', 'center')}${cssPosition('fixed')}z-index:${zIndex};height:60px;width:60px;border-radius:50%;cursor:pointer;box-shadow:0 10px 16px rgba(42,42,71,.06);background:linear-gradient(to bottom,${flagColors[0]} 50%,${flagColors[1]} 50%);`, [ClassNames.buttonPositionTopLeft]: ({ margin }: Record) => cssPosition(u, margin as number, u, u, margin as number), [ClassNames.buttonPositionTopRight]: ({ margin }: Record) => cssPosition(u, margin as number, margin as number), [ClassNames.buttonPositionBottomRight]: ({ margin }: Record) => cssPosition(u, u, margin as number, margin as number, u), [ClassNames.buttonPositionBottomLeft]: ({ margin }: Record) => cssPosition(u, u, u, margin as number, margin as number), [ClassNames.stripRoot]: ({ margin, position, zIndex }: Record) => `text-align:center;font-family:${sysFontFamily};${cssFlex('center', 'center')}cursor:pointer;font-weight:400;line-height:19px;z-index:${zIndex};${position === 'fixed' ? cssPosition('fixed', margin as number, margin as number, u, margin as number) : `${cssPosition('relative')}margin:${margin}px;`}`, [ClassNames.stripColorBlack]: `background-color:#000;color:#fff;height:35px;font-size:16px;`, [ClassNames.stripColorUaColors]: `background:linear-gradient(to right,${flagColors[0]} 50%,${flagColors[1]} 50%);color:#000;height:26px;font-size:15px;`, [ClassNames.overlay]: ({ zIndex }: Record) => `z-index:${(zIndex as number) + 1};${cssPosition('fixed', 0, 0, 0, 0)}background-color:rgba(0,0,0,.2);${cssFlex('center', 'center')}`, [ClassNames.dialog]: `${cssPosition('relative')};background-color:#fff;${cssFlex(u, u, 'column')}align-items:center;width:200px;border-radius:12px;`, [ClassNames.dialogLink]: `text-decoration:none;padding:24px 0;font-weight:700;font-family:${sysFontFamily};font-size:16px;line-height:20px;width:100%;text-align:center;color:#2b2b2d;border-bottom:1px solid #e7e8e8;`, [ClassNames.dialogLinkLast]: `border-bottom-width:0!important;`, [ClassNames.dialogCloseButton]: `${cssPosition('absolute', -7, -7)}cursor:pointer;` } as const; export type WidgetVariant = | 'button' | 'strip'; type WidgetPosition = | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; type WidgetStripColor = | 'black' | 'ua-colors'; type WidgetVariantCommonSettings = { zIndex?: number; margin?: number; }; export interface WidgetOptions { variant?: WidgetVariant; button?: WidgetVariantCommonSettings & { position?: WidgetPosition; }; strip?: WidgetVariantCommonSettings & { color?: WidgetStripColor; position?: 'static' | 'fixed'; }; } type WidgetResult = [ mount: (container: string | HTMLElement) => void, unmount: () => void, ] const positionsAliasToEnum: Record = { 'top-left': ClassNames.buttonPositionTopLeft, 'top-right': ClassNames.buttonPositionTopRight, 'bottom-left': ClassNames.buttonPositionBottomLeft, 'bottom-right': ClassNames.buttonPositionBottomRight, }; const colorsAliasToEnum: Record = { 'black': ClassNames.stripColorBlack, 'ua-colors': ClassNames.stripColorUaColors, }; const vendor = 'stand-for-ukraine'; const defaultSettings: Omit = { button: { position: 'bottom-left', margin: 20, zIndex: 10000 }, strip: { color: 'black', zIndex: 10000, margin: 0, position: 'static' }, }; export function createWidget(options?: WidgetOptions): WidgetResult { const doc = document; const id = gid++; const prefix = `${vendor}-${id}`; const variant = options?.variant ?? 'button'; const userOptions = (options?.[variant] ?? {}) as Record; const settings = Object.keys(userOptions).reduce((target, key) => { if (isDefined(userOptions[key])) { target[key] = userOptions[key]; } return target; }, defaultSettings[variant] as Record); let mounted = false; let styles: Partial) => string)>>; filterStyles(); function getElementBySelector(selector: string, root?: Element) { return (root ?? doc)!.querySelector(selector); } function removeChild(node: Element) { return node.parentNode!.removeChild(node); } function addEventListener(node: Element) { return node.addEventListener.bind(node); } function removeEventListener(node: Element) { return node.removeEventListener.bind(node); } function widgetHtml(): string { if (variant === 'strip') { const color = (settings as WidgetOptions['strip'])!.color!; return `
Help Ukraine ${color !== 'ua-colors' ? 'πŸ‡ΊπŸ‡¦' : '    '} Stop the war
`; } const position = (settings as WidgetOptions['button'])!.position!; return `
`; } function dialogHtml(): string { const decoder = new TextDecoder(); const decode = (link: number[]) => decoder.decode(new Uint8Array(link)); return `
${links.map(([link, label], index, arr) => `${label}`).join('')}
`; } function filterStyles(): void { styles = { [ClassNames.overlay]: defaultStyles[ClassNames.overlay], [ClassNames.dialog]: defaultStyles[ClassNames.dialog], [ClassNames.dialogLink]: defaultStyles[ClassNames.dialogLink], [ClassNames.dialogLinkLast]: defaultStyles[ClassNames.dialogLinkLast], [ClassNames.dialogCloseButton]: defaultStyles[ClassNames.dialogCloseButton], }; if (variant === 'strip') { const color = (settings as WidgetOptions['strip'])!.color!; styles[ClassNames.stripRoot] = defaultStyles[ClassNames.stripRoot]; styles[colorsAliasToEnum[color]] = defaultStyles[colorsAliasToEnum[color]]; return; } const position = (settings as WidgetOptions['button'])!.position!; styles[ClassNames.buttonRoot] = defaultStyles[ClassNames.buttonRoot]; styles[positionsAliasToEnum[position]] = defaultStyles[positionsAliasToEnum[position]]; } function cls(classNames: (ClassNames | undefined)[]): string { return classNames.filter((className) => isDefined(className)) .map((className) => `${prefix}-${className}`).join(' '); } function attachStyles(): void { const styleElement = doc.createElement('style'); styleElement.setAttribute('id', `${prefix}-styles`); doc.head.appendChild(styleElement); const stylesheet = styleElement.sheet!; for (const [selector, style] of Object.entries(styles)) { stylesheet.insertRule(`.${cls([selector as never])} { ${typeof style === 'function' ? style(settings) : style} }`); } } const hideDialog = (): void => { const node = getElementBySelector(`#${prefix}-dialog`)!; removeEventListener(getElementBySelector('svg', node)!)('click', hideDialog); removeEventListener(node)('click', hideDialog); removeChild(node); }; const showDialog = (): void => { const node = attachElementFromHtml(doc.body, dialogHtml()); addEventListener(getElementBySelector('svg', node)!)('click', hideDialog); addEventListener(node)('click', hideDialog); } function mount(container: string | Element): void { if (mounted) { return; } let element: Element | null = null; if (typeof container === 'string') { element = getElementBySelector(container); } else { element = container; } if (typeof element === 'undefined' || element === null) { throw new Error('Element cannot be null'); } attachStyles(); const node = attachElementFromHtml(element, widgetHtml(), variant === 'strip' ? 'above' : 'under'); addEventListener(node)('click', showDialog); mounted = true; } function attachElementFromHtml(container: Element, html: string, insert?: 'under' | 'above'): HTMLDivElement { insert ??= 'under'; const root = doc.createElement('div'); root.innerHTML = html; const node = getElementBySelector('div', root)! as HTMLDivElement; if (insert === 'above' && container.firstElementChild !== null) { container.insertBefore(node, container.firstElementChild); } else { container.appendChild(node); } return node; } function unmount(): void { if (!mounted) { return; } const node = getElementBySelector(`#${prefix}`)!; removeEventListener(node)('click', showDialog); removeChild(node); removeChild(getElementBySelector(`#${prefix}-styles`)!); mounted = false; } return [mount, unmount]; }