@naturalcycles/js-lib 14.255.0 → 14.257.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/cfg/frontend/tsconfig.json +67 -0
  2. package/dist/bot.d.ts +60 -0
  3. package/dist/bot.js +129 -0
  4. package/dist/browser/adminService.d.ts +69 -0
  5. package/dist/browser/adminService.js +98 -0
  6. package/dist/browser/analytics.util.d.ts +12 -0
  7. package/dist/browser/analytics.util.js +59 -0
  8. package/dist/browser/i18n/fetchTranslationLoader.d.ts +13 -0
  9. package/dist/browser/i18n/fetchTranslationLoader.js +17 -0
  10. package/dist/browser/i18n/translation.service.d.ts +53 -0
  11. package/dist/browser/i18n/translation.service.js +61 -0
  12. package/dist/browser/imageFitter.d.ts +60 -0
  13. package/dist/browser/imageFitter.js +69 -0
  14. package/dist/browser/script.util.d.ts +14 -0
  15. package/dist/browser/script.util.js +50 -0
  16. package/dist/browser/topbar.d.ts +23 -0
  17. package/dist/browser/topbar.js +137 -0
  18. package/dist/decorators/memo.util.d.ts +2 -1
  19. package/dist/decorators/memo.util.js +8 -6
  20. package/dist/decorators/swarmSafe.decorator.d.ts +9 -0
  21. package/dist/decorators/swarmSafe.decorator.js +42 -0
  22. package/dist/error/assert.d.ts +2 -1
  23. package/dist/error/assert.js +15 -13
  24. package/dist/error/error.util.js +9 -6
  25. package/dist/index.d.ts +8 -0
  26. package/dist/index.js +8 -0
  27. package/dist/zod/zod.util.d.ts +1 -1
  28. package/dist-esm/bot.js +125 -0
  29. package/dist-esm/browser/adminService.js +94 -0
  30. package/dist-esm/browser/analytics.util.js +54 -0
  31. package/dist-esm/browser/i18n/fetchTranslationLoader.js +13 -0
  32. package/dist-esm/browser/i18n/translation.service.js +56 -0
  33. package/dist-esm/browser/imageFitter.js +65 -0
  34. package/dist-esm/browser/script.util.js +46 -0
  35. package/dist-esm/browser/topbar.js +134 -0
  36. package/dist-esm/decorators/memo.util.js +3 -1
  37. package/dist-esm/decorators/swarmSafe.decorator.js +38 -0
  38. package/dist-esm/error/assert.js +3 -1
  39. package/dist-esm/error/error.util.js +4 -1
  40. package/dist-esm/index.js +8 -0
  41. package/package.json +2 -1
  42. package/src/bot.ts +155 -0
  43. package/src/browser/adminService.ts +157 -0
  44. package/src/browser/analytics.util.ts +68 -0
  45. package/src/browser/i18n/fetchTranslationLoader.ts +16 -0
  46. package/src/browser/i18n/translation.service.ts +102 -0
  47. package/src/browser/imageFitter.ts +128 -0
  48. package/src/browser/script.util.ts +52 -0
  49. package/src/browser/topbar.ts +147 -0
  50. package/src/datetime/localDate.ts +16 -0
  51. package/src/datetime/localTime.ts +39 -0
  52. package/src/decorators/debounce.ts +1 -0
  53. package/src/decorators/memo.util.ts +4 -1
  54. package/src/decorators/swarmSafe.decorator.ts +47 -0
  55. package/src/error/assert.ts +5 -11
  56. package/src/error/error.util.ts +4 -1
  57. package/src/index.ts +8 -0
  58. package/src/json-schema/jsonSchemaBuilder.ts +20 -0
  59. package/src/semver.ts +2 -0
  60. package/src/zod/zod.util.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
- "version": "14.255.0",
3
+ "version": "14.257.0",
4
4
  "scripts": {
5
5
  "prepare": "husky",
6
6
  "build": "dev-lib build-esm-cjs",
@@ -36,6 +36,7 @@
36
36
  "files": [
37
37
  "dist",
38
38
  "dist-esm",
39
+ "cfg",
39
40
  "src",
40
41
  "!src/test",
41
42
  "!src/**/*.test.*",
package/src/bot.ts ADDED
@@ -0,0 +1,155 @@
1
+ // Relevant material:
2
+ // https://deviceandbrowserinfo.com/learning_zone/articles/detecting-headless-chrome-puppeteer-2024
3
+
4
+ import { isServerSide } from './env'
5
+
6
+ export interface BotDetectionServiceCfg {
7
+ /**
8
+ * Defaults to false.
9
+ * If true - the instance will memoize (remember) the results of the detection
10
+ * and won't re-run it.
11
+ */
12
+ memoizeResults?: boolean
13
+
14
+ /**
15
+ * Defaults to false.
16
+ * If set to true: `getBotReason()` would return BotReason.CDP if CDP is detected.
17
+ * Otherwise - `getBotReason()` will not perform the CDP check.
18
+ */
19
+ treatCDPAsBotReason?: boolean
20
+ }
21
+
22
+ /**
23
+ * Service to detect bots and CDP (Chrome DevTools Protocol).
24
+ *
25
+ * @experimental
26
+ */
27
+ export class BotDetectionService {
28
+ constructor(public cfg: BotDetectionServiceCfg = {}) {}
29
+
30
+ // memoized results
31
+ private botReason: BotReason | null | undefined
32
+ private cdp: boolean | undefined
33
+
34
+ isBotOrCDP(): boolean {
35
+ return !!this.getBotReason() || this.isCDP()
36
+ }
37
+
38
+ isBot(): boolean {
39
+ return !!this.getBotReason()
40
+ }
41
+
42
+ /**
43
+ * Returns null if it's not a Bot,
44
+ * otherwise a truthy BotReason.
45
+ */
46
+ getBotReason(): BotReason | null {
47
+ if (this.cfg.memoizeResults && this.botReason !== undefined) {
48
+ return this.botReason
49
+ }
50
+
51
+ this.botReason = this.detectBotReason()
52
+ return this.botReason
53
+ }
54
+
55
+ private detectBotReason(): BotReason | null {
56
+ // SSR - not a bot
57
+ if (isServerSide()) return null
58
+ const { navigator } = globalThis
59
+ if (!navigator) return BotReason.NoNavigator
60
+ const { userAgent } = navigator
61
+ if (!userAgent) return BotReason.NoUserAgent
62
+
63
+ if (/bot|headless|electron|phantom|slimer/i.test(userAgent)) {
64
+ return BotReason.UserAgent
65
+ }
66
+
67
+ if (navigator.webdriver) {
68
+ return BotReason.WebDriver
69
+ }
70
+
71
+ // Kirill: commented out, as it's no longer seems reliable,
72
+ // e.g generates false positives with latest Android clients (e.g. Chrome 129)
73
+ // if (navigator.plugins?.length === 0) {
74
+ // return BotReason.ZeroPlugins // Headless Chrome
75
+ // }
76
+
77
+ if ((navigator.languages as any) === '') {
78
+ return BotReason.EmptyLanguages // Headless Chrome
79
+ }
80
+
81
+ // isChrome is true if the browser is Chrome, Chromium or Opera
82
+ // this is "the chrome test" from https://intoli.com/blog/not-possible-to-block-chrome-headless/
83
+ // this property is for some reason not present by default in headless chrome
84
+ // Kirill: criterium removed due to false positives with Android
85
+ // if (userAgent.includes('Chrome') && !(globalThis as any).chrome) {
86
+ // return BotReason.ChromeWithoutChrome // Headless Chrome
87
+ // }
88
+
89
+ if (this.cfg.treatCDPAsBotReason && this.detectCDP()) {
90
+ return BotReason.CDP
91
+ }
92
+
93
+ return null
94
+ }
95
+
96
+ /**
97
+ * CDP stands for Chrome DevTools Protocol.
98
+ * This function tests if the current environment is a CDP environment.
99
+ * If it's true - it's one of:
100
+ *
101
+ * 1. Bot, automated with CDP, e.g Puppeteer, Playwright or such.
102
+ * 2. Developer with Chrome DevTools open.
103
+ *
104
+ * 2 is certainly not a bot, but unfortunately we can't distinguish between the two.
105
+ * That's why this function is not part of `isBot()`, because it can give "false positive" with DevTools.
106
+ *
107
+ * Based on: https://deviceandbrowserinfo.com/learning_zone/articles/detecting-headless-chrome-puppeteer-2024
108
+ */
109
+ isCDP(): boolean {
110
+ if (this.cfg.memoizeResults && this.cdp !== undefined) {
111
+ return this.cdp
112
+ }
113
+
114
+ this.cdp = this.detectCDP()
115
+ return this.cdp
116
+ }
117
+
118
+ private detectCDP(): boolean {
119
+ if (isServerSide()) return false
120
+ let cdpCheck1 = false
121
+ try {
122
+ /* eslint-disable */
123
+ // biome-ignore lint/suspicious/useErrorMessage: ok
124
+ const e = new window.Error()
125
+ window.Object.defineProperty(e, 'stack', {
126
+ configurable: false,
127
+ enumerable: false,
128
+ // biome-ignore lint/complexity/useArrowFunction: ok
129
+ get: function () {
130
+ cdpCheck1 = true
131
+ return ''
132
+ },
133
+ })
134
+ // This is part of the detection and shouldn't be deleted
135
+ window.console.debug(e)
136
+ /* eslint-enable */
137
+ } catch {}
138
+ return cdpCheck1
139
+ }
140
+ }
141
+
142
+ export enum BotReason {
143
+ NoNavigator = 1,
144
+ NoUserAgent = 2,
145
+ UserAgent = 3,
146
+ WebDriver = 4,
147
+ // ZeroPlugins = 5,
148
+ EmptyLanguages = 6,
149
+ // ChromeWithoutChrome = 7,
150
+ /**
151
+ * This is when CDP is considered to be a reason to be a Bot.
152
+ * By default it's not.
153
+ */
154
+ CDP = 8,
155
+ }
@@ -0,0 +1,157 @@
1
+ import { _Memo } from '../decorators/memo.decorator'
2
+ import { isServerSide } from '../env'
3
+ import { _stringify } from '../string/stringify'
4
+ import { Promisable } from '../typeFest'
5
+
6
+ export interface AdminModeCfg {
7
+ /**
8
+ * Function (predicate) to detect if needed keys are pressed.
9
+ *
10
+ * @example
11
+ * predicate: e => e.ctrlKey && e.key === 'L'
12
+ *
13
+ * @default
14
+ * Detects Ctrl+Shift+L
15
+ */
16
+ predicate?: (e: KeyboardEvent) => boolean
17
+
18
+ /**
19
+ * Called when RedDot is clicked. Implies that AdminMode is enabled.
20
+ */
21
+ onRedDotClick?: () => any
22
+
23
+ /**
24
+ * Called when AdminMode was changed.
25
+ */
26
+ onChange?: (adminMode: boolean) => any
27
+
28
+ /**
29
+ * Called BEFORE entering AdminMode.
30
+ * Serves as a predicate that can cancel entering AdminMode if false is returned.
31
+ * Return true to allow.
32
+ * Function is awaited before proceeding.
33
+ */
34
+ beforeEnter?: () => Promisable<boolean>
35
+
36
+ /**
37
+ * Called BEFORE exiting AdminMode.
38
+ * Serves as a predicate that can cancel exiting AdminMode if false is returned.
39
+ * Return true to allow.
40
+ * Function is awaited before proceeding.
41
+ */
42
+ beforeExit?: () => Promisable<boolean>
43
+
44
+ /**
45
+ * @default true
46
+ * If true - it will "persist" the adminMode state in LocalStorage
47
+ */
48
+ persistToLocalStorage?: boolean
49
+
50
+ /**
51
+ * The key for LocalStorage persistence.
52
+ *
53
+ * @default '__adminMode__'
54
+ */
55
+ localStorageKey?: string
56
+ }
57
+
58
+ const RED_DOT_ID = '__red-dot__'
59
+ const NOOP = (): void => {}
60
+
61
+ /**
62
+ * @experimental
63
+ *
64
+ * Allows to listen for AdminMode keypress combination (Ctrl+Shift+L by default) to toggle AdminMode,
65
+ * indicated by RedDot DOM element.
66
+ *
67
+ * todo: help with Authentication
68
+ */
69
+ export class AdminService {
70
+ constructor(cfg?: AdminModeCfg) {
71
+ this.cfg = {
72
+ predicate: e => e.ctrlKey && e.key === 'L',
73
+ persistToLocalStorage: true,
74
+ localStorageKey: '__adminMode__',
75
+ onRedDotClick: NOOP,
76
+ onChange: NOOP,
77
+ beforeEnter: () => true,
78
+ beforeExit: () => true,
79
+ ...cfg,
80
+ }
81
+ }
82
+
83
+ cfg: Required<AdminModeCfg>
84
+
85
+ adminMode = false
86
+
87
+ private listening = false
88
+
89
+ /**
90
+ * Start listening to keyboard events to toggle AdminMode when detected.
91
+ */
92
+ startListening(): void {
93
+ if (this.listening || isServerSide()) return
94
+
95
+ this.adminMode = !!localStorage.getItem(this.cfg.localStorageKey)
96
+
97
+ if (this.adminMode) this.toggleRedDotVisibility()
98
+
99
+ document.addEventListener('keydown', this.keydownListener.bind(this), { passive: true })
100
+
101
+ this.listening = true
102
+ }
103
+
104
+ stopListening(): void {
105
+ if (isServerSide()) return
106
+ document.removeEventListener('keydown', this.keydownListener)
107
+ this.listening = false
108
+ }
109
+
110
+ private async keydownListener(e: KeyboardEvent): Promise<void> {
111
+ // console.log(e)
112
+ if (!this.cfg.predicate(e)) return
113
+ await this.toggleRedDot()
114
+ }
115
+
116
+ async toggleRedDot(): Promise<void> {
117
+ try {
118
+ const allow = await this.cfg[this.adminMode ? 'beforeExit' : 'beforeEnter']()
119
+ if (!allow) return // no change
120
+ } catch (err) {
121
+ console.error(err)
122
+ // ok to show alert to Admins, it's not user-facing
123
+ alert(_stringify(err))
124
+ return // treat as "not allowed"
125
+ }
126
+
127
+ this.adminMode = !this.adminMode
128
+
129
+ this.toggleRedDotVisibility()
130
+
131
+ if (this.cfg.persistToLocalStorage) {
132
+ const { localStorageKey } = this.cfg
133
+ if (this.adminMode) {
134
+ localStorage.setItem(localStorageKey, '1')
135
+ } else {
136
+ localStorage.removeItem(localStorageKey)
137
+ }
138
+ }
139
+
140
+ this.cfg.onChange(this.adminMode)
141
+ }
142
+
143
+ private toggleRedDotVisibility(): void {
144
+ this.getRedDotElement().style.display = this.adminMode ? 'block' : 'none'
145
+ }
146
+
147
+ @_Memo()
148
+ private getRedDotElement(): HTMLElement {
149
+ const el = document.createElement('div')
150
+ el.id = RED_DOT_ID
151
+ el.style.cssText =
152
+ 'position:fixed;width:24px;height:24px;margin-top:-12px;background-color:red;opacity:0.5;top:50%;left:0;z-index:9999999;cursor:pointer;border-radius:0 3px 3px 0'
153
+ el.addEventListener('click', () => this.cfg.onRedDotClick())
154
+ document.body.append(el)
155
+ return el
156
+ }
157
+ }
@@ -0,0 +1,68 @@
1
+ import { isServerSide } from '@naturalcycles/js-lib'
2
+ import { loadScript } from './script.util'
3
+
4
+ declare global {
5
+ interface Window {
6
+ dataLayer: any[]
7
+ gtag: (...args: any[]) => void
8
+ }
9
+ }
10
+
11
+ /* eslint-disable unicorn/prefer-global-this */
12
+
13
+ /**
14
+ * Pass enabled = false to only init window.gtag, but not load actual gtag script (e.g in dev mode).
15
+ */
16
+ export async function loadGTag(gtagId: string, enabled = true): Promise<void> {
17
+ if (isServerSide()) return
18
+
19
+ window.dataLayer ||= []
20
+ window.gtag ||= function gtag() {
21
+ // biome-ignore lint/complexity/useArrowFunction: ok
22
+ // biome-ignore lint/style/noArguments: ok
23
+ window.dataLayer.push(arguments)
24
+ }
25
+ window.gtag('js', new Date())
26
+ window.gtag('config', gtagId)
27
+
28
+ if (!enabled) return
29
+
30
+ await loadScript(`https://www.googletagmanager.com/gtag/js?id=${gtagId}`)
31
+ }
32
+
33
+ export async function loadGTM(gtmId: string, enabled = true): Promise<void> {
34
+ if (isServerSide()) return
35
+
36
+ window.dataLayer ||= []
37
+ window.dataLayer.push({
38
+ 'gtm.start': Date.now(),
39
+ event: 'gtm.js',
40
+ })
41
+
42
+ if (!enabled) return
43
+
44
+ await loadScript(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`)
45
+ }
46
+
47
+ export function loadHotjar(hjid: number): void {
48
+ if (isServerSide()) return
49
+
50
+ /* eslint-disable */
51
+ // prettier-ignore
52
+ ;
53
+ ;((h: any, o, t, j, a?: any, r?: any) => {
54
+ h.hj =
55
+ h.hj ||
56
+ function hj() {
57
+ // biome-ignore lint/style/noArguments: ok
58
+ ;(h.hj.q = h.hj.q || []).push(arguments)
59
+ }
60
+ h._hjSettings = { hjid, hjsv: 6 }
61
+ a = o.querySelectorAll('head')[0]
62
+ r = o.createElement('script')
63
+ r.async = 1
64
+ r.src = t + h._hjSettings.hjid + j + h._hjSettings.hjsv
65
+ a.append(r)
66
+ })(window, document, 'https://static.hotjar.com/c/hotjar-', '.js?sv=')
67
+ /* eslint-enable */
68
+ }
@@ -0,0 +1,16 @@
1
+ import { Fetcher } from '../../http/fetcher'
2
+ import { StringMap } from '../../types'
3
+ import { TranslationLoader } from './translation.service'
4
+
5
+ /**
6
+ * Use `baseUrl` to prefix your language files.
7
+ * Example URL structure:
8
+ * ${baseUrl}/${locale}.json
9
+ */
10
+ export class FetchTranslationLoader implements TranslationLoader {
11
+ constructor(public fetcher: Fetcher) {}
12
+
13
+ async load(locale: string): Promise<StringMap> {
14
+ return await this.fetcher.get(`${locale}.json`)
15
+ }
16
+ }
@@ -0,0 +1,102 @@
1
+ import { pMap } from '../../promise/pMap'
2
+ import { StringMap } from '../../types'
3
+
4
+ export type MissingTranslationHandler = (key: string, params?: StringMap<any>) => string
5
+
6
+ export const defaultMissingTranslationHandler: MissingTranslationHandler = key => {
7
+ console.warn(`[tr] missing: ${key}`)
8
+ return `[${key}]`
9
+ }
10
+
11
+ export interface TranslationServiceCfg {
12
+ defaultLocale: string
13
+ supportedLocales: string[]
14
+
15
+ /**
16
+ * It is allowed to set it later. Will default to `defaultLocale` in that case.
17
+ */
18
+ currentLocale?: string
19
+
20
+ translationLoader: TranslationLoader
21
+
22
+ /**
23
+ * Defaults to `defaultMissingTranslationHandler` that returns `[${key}]` and emits console warning.
24
+ */
25
+ missingTranslationHandler?: MissingTranslationHandler
26
+ }
27
+
28
+ export interface TranslationServiceCfgComplete extends TranslationServiceCfg {
29
+ missingTranslationHandler: MissingTranslationHandler // non-optional
30
+ }
31
+
32
+ export interface TranslationLoader {
33
+ load: (locale: string) => Promise<StringMap>
34
+ }
35
+
36
+ export class TranslationService {
37
+ constructor(cfg: TranslationServiceCfg, preloadedLocales: StringMap<StringMap> = {}) {
38
+ this.cfg = {
39
+ ...cfg,
40
+ missingTranslationHandler: defaultMissingTranslationHandler,
41
+ }
42
+
43
+ this.locales = {
44
+ ...preloadedLocales,
45
+ }
46
+
47
+ this.currentLocale = cfg.currentLocale || cfg.defaultLocale
48
+ }
49
+
50
+ cfg: TranslationServiceCfgComplete
51
+
52
+ /**
53
+ * Cache of loaded locales
54
+ */
55
+ locales: StringMap<StringMap>
56
+
57
+ currentLocale: string
58
+
59
+ /**
60
+ * Manually set locale data, bypassing the TranslationLoader.
61
+ */
62
+ setLocale(localeName: string, locale: StringMap): void {
63
+ this.locales[localeName] = locale
64
+ }
65
+
66
+ getLocale(locale: string): StringMap | undefined {
67
+ return this.locales[locale]
68
+ }
69
+
70
+ /**
71
+ * Loads locale(s) (if not already cached) via configured TranslationLoader.
72
+ * Resolves promise when done (ready to be used).
73
+ */
74
+ async loadLocale(locale: string | string[]): Promise<void> {
75
+ const locales = Array.isArray(locale) ? locale : [locale]
76
+
77
+ await pMap(locales, async locale => {
78
+ if (this.locales[locale]) return // already loaded
79
+ this.locales[locale] = await this.cfg.translationLoader.load(locale)
80
+ // console.log(`[tr] locale loaded: ${locale}`)
81
+ })
82
+ }
83
+
84
+ /**
85
+ * Will invoke `missingTranslationHandler` on missing tranlation.
86
+ *
87
+ * Does NOT do any locale loading. The locale needs to be loaded beforehand:
88
+ * either pre-loaded and passed to the constructor,
89
+ * or `await loadLocale(locale)`.
90
+ */
91
+ translate(key: string, params?: StringMap): string {
92
+ return this.translateIfExists(key, params) || this.cfg.missingTranslationHandler(key, params)
93
+ }
94
+
95
+ /**
96
+ * Does NOT invoke `missingTranslationHandler`, returns `undefined` instead.
97
+ */
98
+ translateIfExists(key: string, _params?: StringMap): string | undefined {
99
+ // todo: support params
100
+ return this.locales[this.currentLocale]?.[key] || this.locales[this.cfg.defaultLocale]?.[key]
101
+ }
102
+ }
@@ -0,0 +1,128 @@
1
+ export interface FitImagesCfg {
2
+ /**
3
+ * Container of the images
4
+ */
5
+ containerElement: HTMLElement
6
+
7
+ /**
8
+ * Array of image metadatas (most notably: aspectRatio).
9
+ */
10
+ images: FitImage[]
11
+
12
+ /**
13
+ * Will be called on each layout change.
14
+ * Should be listened to to update the width/height of the images in your DOM.
15
+ */
16
+ onChange: (images: FitImage[]) => any
17
+
18
+ /**
19
+ * Max image height in pixels.
20
+ *
21
+ * @default 300
22
+ */
23
+ maxHeight?: number
24
+
25
+ /**
26
+ * Margin between images.
27
+ *
28
+ * @default 8
29
+ */
30
+ margin?: number
31
+ }
32
+
33
+ export interface FitImage {
34
+ src: string
35
+
36
+ /**
37
+ * width divided by height
38
+ */
39
+ aspectRatio: number
40
+
41
+ /**
42
+ * Calculated image width to fit the layout.
43
+ */
44
+ fitWidth?: number
45
+ /**
46
+ * Calculated image height to fit the layout.
47
+ */
48
+ fitHeight?: number
49
+ }
50
+
51
+ /**
52
+ * Calculates the width/height of the images to fit in the layout.
53
+ *
54
+ * Currently does not mutate the cfg.images array, but DOES mutate individual images with .fitWidth, .fitHeight properties.
55
+ *
56
+ * @experimental
57
+ */
58
+ export class ImageFitter {
59
+ constructor(cfg: FitImagesCfg) {
60
+ this.cfg = {
61
+ maxHeight: 300,
62
+ margin: 8,
63
+ ...cfg,
64
+ }
65
+ this.resizeObserver = new ResizeObserver(entries => this.update(entries))
66
+ this.resizeObserver.observe(cfg.containerElement)
67
+ }
68
+
69
+ cfg!: Required<FitImagesCfg>
70
+ resizeObserver: ResizeObserver
71
+ containerWidth = -1
72
+
73
+ stop(): void {
74
+ this.resizeObserver.disconnect()
75
+ }
76
+
77
+ private update(entries: ResizeObserverEntry[]): void {
78
+ const width = Math.floor(entries[0]!.contentRect.width)
79
+ if (width === this.containerWidth) return // we're only interested in width changes
80
+ this.containerWidth = width
81
+
82
+ console.log(`resize ${width}`)
83
+ this.doLayout(this.cfg.images)
84
+ this.cfg.onChange(this.cfg.images)
85
+ }
86
+
87
+ private doLayout(imgs: readonly FitImage[]): void {
88
+ if (imgs.length === 0) return // nothing to do
89
+ const { maxHeight } = this.cfg
90
+
91
+ let imgNodes = imgs.slice(0)
92
+
93
+ w: while (imgNodes.length > 0) {
94
+ let slice: FitImage[]
95
+ let h: number
96
+
97
+ for (let i = 1; i <= imgNodes.length; i++) {
98
+ slice = imgNodes.slice(0, i)
99
+ h = this.getHeigth(slice)
100
+
101
+ if (h < maxHeight) {
102
+ this.setHeight(slice, h)
103
+ imgNodes = imgNodes.slice(i)
104
+ continue w
105
+ }
106
+ }
107
+
108
+ this.setHeight(slice!, Math.min(maxHeight, h!))
109
+ break
110
+ }
111
+ }
112
+
113
+ private getHeigth(images: readonly FitImage[]): number {
114
+ const width = this.containerWidth - images.length * this.cfg.margin
115
+ let r = 0
116
+ images.forEach(img => (r += img.aspectRatio))
117
+
118
+ return width / r // have to round down because Firefox will automatically roundup value with number of decimals > 3
119
+ }
120
+
121
+ // mutates/sets images' fitWidth, fitHeight properties
122
+ private setHeight(images: readonly FitImage[], height: number): void {
123
+ images.forEach(img => {
124
+ img.fitWidth = Math.floor(height * img.aspectRatio)
125
+ img.fitHeight = Math.floor(height)
126
+ })
127
+ }
128
+ }
@@ -0,0 +1,52 @@
1
+ import { isServerSide } from '../env'
2
+ import { _objectAssign } from '../types'
3
+
4
+ export type LoadScriptOptions = Partial<HTMLScriptElement>
5
+ export type LoadCSSOptions = Partial<HTMLLinkElement>
6
+
7
+ /**
8
+ * opt.async defaults to `true`.
9
+ * No other options are set by default.
10
+ */
11
+ export async function loadScript(src: string, opt?: LoadScriptOptions): Promise<void> {
12
+ if (isServerSide()) return
13
+
14
+ return await new Promise<void>((resolve, reject) => {
15
+ const s = _objectAssign(document.createElement('script'), {
16
+ src,
17
+ async: true,
18
+ ...opt,
19
+ onload: resolve as any,
20
+ onerror: (_event, _source, _lineno, _colno, err) => {
21
+ reject(err || new Error(`loadScript failed: ${src}`))
22
+ },
23
+ })
24
+ document.head.append(s)
25
+ })
26
+ }
27
+
28
+ /**
29
+ * Default options:
30
+ * rel: 'stylesheet'
31
+ *
32
+ * No other options are set by default.
33
+ */
34
+ export async function loadCSS(href: string, opt?: LoadCSSOptions): Promise<void> {
35
+ if (isServerSide()) return
36
+
37
+ return await new Promise<void>((resolve, reject) => {
38
+ const link = _objectAssign(document.createElement('link'), {
39
+ href,
40
+ rel: 'stylesheet',
41
+ // type seems to be unnecessary: https://stackoverflow.com/a/5409146/4919972
42
+ // type: 'text/css',
43
+ ...opt,
44
+ onload: resolve as any,
45
+ onerror: (_event, _source, _lineno, _colno, err) => {
46
+ reject(err || new Error(`loadCSS failed: ${href}`))
47
+ },
48
+ })
49
+
50
+ document.head.append(link)
51
+ })
52
+ }