1
0
Fork 0

[breaking]: FreeSewing v4 (#7297)

Refer to the CHANGELOG for all info.

---------

Co-authored-by: Wouter van Wageningen <wouter.vdub@yahoo.com>
Co-authored-by: Josh Munic <jpmunic@gmail.com>
Co-authored-by: Jonathan Haas <haasjona@gmail.com>
This commit is contained in:
Joost De Cock 2025-04-01 16:15:20 +02:00 committed by GitHub
parent d22fbe78d9
commit 51dc1d9732
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6626 changed files with 142053 additions and 150606 deletions

144
packages/utils/CHANGELOG.md Normal file
View file

@ -0,0 +1,144 @@
# Change log for: @freesewing/utils
## 4.0.0 (2024-04-01)
### Added
- Added a new `@freesewing/utils` package for shared utils
## 3.0.0 (2023-09-30)
### Changed
- All FreeSewing packages are now ESM only.
- All FreeSewing packages now use named exports.
- Dropped support for NodeJS 14. NodeJS 18 (LTS/hydrogen) or more recent is now required.
## 2.21.0 (2022-06-27)
### Changed
- Migrated from Rollup to Esbuild for all builds
## 2.17.3 (2021-08-16)
### Fixed
- Added missing `bustPointToUnderbust` measurement to `neckstimate`
## 2.16.1 (2021-05-30)
### Changed
- neckstimate now takes an extra `noRound` parameter to return the unrounded value
- measurementDiffers takes an extra `absolute` value that can be set to false to get the non-absolute and non-rounded value
## 2.13.1 (2021-02-14)
### Added
- Pass pattern handle to tiler
## 2.7.1 (2020-07-24)
### Added
- Added backend calls for creating gists/issues on Github
## 2.7.0 (2020-07-12)
### Added
- Added new `isDegMeasurement` method. See [#358](https://github.com/freesewing/freesewing/issues/358)
- `neckStimate` now supports all new measurements. See [#416](https://github.com/freesewing/freesewing/issues/416)
### Changed
- Changed `neckstimate` to handle new `shoulderSlope` degree measurement. See [#358](https://github.com/freesewing/freesewing/issues/358)
- Changed `neckstimate` to support all new measurements. See [#416](https://github.com/freesewing/freesewing/issues/416)
- Ported `neckstimate` to the crotchDepth measurement. See [#425](https://github.com/freesewing/freesewing/issues/425)
- Removed `Circumference` suffix from measurement names
- Added the `isDegMeasurement` method
## 2.4.5 (2020-03-19)
### Changed
- neckstimate() now returns values rounded to nearest mm
## 2.4.1 (2020-03-04)
### Fixed
- [#542](https://github.com/freesewing/freesewing.org/issues/542): Prevent neckstimate from throwing when getting an unexpected measurement
## 2.2.0 (2020-02-22)
### Changed
- Neckstimate now uses proportions only
## 2.1.6 (2019-11-24)
### Fixed
- [#317](https://github.com/freesewing/freesewing.org/issues/317): Fixed bug where format was not passed to formatImperial
## 2.1.3 (2019-10-18)
### Changed
- Adjusted slope of the shoulderToShoulder measurement in neckstimate data
### Fixed
- [#250](https://github.com/freesewing/freesewing.org/issues/250): Model page stays empty with pre 2.0 model data: Error: 'neckstimate() requires a valid measurement name as second parameter. (received underBust)'
## 2.1.1 (2019-10-13)
### Fixed
- Fixed an issue with the formatMm method not adding units
## 2.1.0 (2019-10-06)
### Added
- Added backend methods for administration
- Added the resendActivationEmail method to backend
### Fixed
- Fixed an issue where optionDefault was not handling list options correctly
## 2.0.3 (2019-09-15)
### Fixed
- Fix measurementDiffers to pass breasts parameter to neckstimate
## 2.0.2 (2019-09-06)
### Fixed
- Removed lingering debug statement in formatImperial
## 2.0.1 (2019-09-01)
### Added
- The `measurementDiffers` method is new.
## 2.0.0 (2019-08-25)
### Added
- Initial release
This is the **initial release**, and the start of this change log.
> Prior to version 2, FreeSewing was not a JavaScript project.
> As such, that history is out of scope for this change log.

143
packages/utils/README.md Normal file
View file

@ -0,0 +1,143 @@
<p align='center'><a
href="https://www.npmjs.com/package/@freesewing/utils"
title="@freesewing/utils on NPM"
><img src="https://img.shields.io/npm/v/@freesewing/utils.svg"
alt="@freesewing/utils on NPM"/>
</a><a
href="https://opensource.org/licenses/MIT"
title="License: MIT"
><img src="https://img.shields.io/npm/l/@freesewing/utils.svg?label=License"
alt="License: MIT"/>
</a><a
href="#contributors-"
title="All Contributors"
><img src="https://img.shields.io/badge/all_contributors-131-pink.svg"
alt="All Contributors"/>
</a></p><p align='center'><a
href="https://forum.freesewing.org"
title="Follow @freesewing_org on Twitter"
><img src="https://img.shields.io/badge/%F3%A0%80%A0-Forum-E4405F.svg?logo=discourse&logoColor=white&logoWidth=15"
alt="Follow @freesewing_org on Twitter"/>
</a><a
href="https://chat.freesewing.org"
title="Chat with us on Discord"
><img src="https://img.shields.io/discord/698854858052075530?label=Chat%20on%20Discord"
alt="Chat with us on Discord"/>
</a><a
href="https://freesewing.org/patrons/join"
title="Become a FreeSewing Patron"
><img src="https://img.shields.io/badge/%F3%A0%80%A0-Support%20us-blueviolet.svg?logo=cash-app&logoColor=white&logoWidth=15"
alt="Become a FreeSewing Patron"/>
</a></p>
# @freesewing/utils
A number of utilities, typically used by FreeSewing frontend code
# FreeSewing
> [!TIP]
>#### Support FreeSewing: Become a patron, or make a one-time donation 🥰
>
> FreeSewing is an open source project maintained by Joost De Cock and financially supported by the FreeSewing patrons.
>
> If you feel FreeSewing is worthwhile, and you can spend a few coins without
hardship, then you should [join us and become a patron](https://freesewing.eu/patrons/join).
## What am I looking at? 🤔
This repository is the FreeSewing *monorepo* holding all FreeSewing's websites, documentation, designs, plugins, and other NPM packages.
This folder holds: @freesewing/utils
If you're not entirely sure what to do or how to start, type this command:
```
npm run tips
```
> [!NOTE]
> If you don't want to set up a dev environment, you can run it in your browser:
>
> [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://codeberg.org/freesewing/freesewing)
>
> We recommend that you fork our repository and then
> put `gitpod.io/#<entire-url-of-your-fork` into a browser
> to start up a browser-based dev environment of your own.
## About FreeSewing 💀
Where the world of makers and developers collide, that's where you'll find FreeSewing.
If you're a maker, checkout [freesewing.eu](https://freesewing.eu/) where you can generate
sewing patterns adapted to your measurements.
If you're a developer, the FreeSewing documentation lives at [freesewing.dev](https://freesewing.dev/).
The FreeSewing [core library](https://freesewing.dev/reference/api/) is a *batteries-included* toolbox
for parametric design of sewing patterns. But FreeSewing also provides a range
of [plugins](https://freesewing.dev/reference/plugins/) that further extend the
functionality of the platform.
If you have NodeJS installed, you can try it right now by running:
```bash
npx @freesewing/studio
```
Getting started guides are available for:
- [Linux](https://freesewing.dev/tutorials/getting-started-linux/)
- [MacOS](https://freesewing.dev/tutorials/getting-started-mac/)
- [Windows](https://freesewing.dev/tutorials/getting-started-windows/)
The [pattern design tutorial](https://freesewing.dev/tutorials/pattern-design/) will
show you how to create your first parametric design.
## Getting started ⚡
To get started with FreeSewing, you can spin up our development environment with:
```bash
npx @freesewing/studio
```
To work with FreeSewing's monorepo, you'll need [NodeJS v20](https://nodejs.org) on your system.
Once you have that, clone (or fork) this repo and run `npm run kickstart`:
```bash
git clone git@codeberg.org:freesewing/freesewing.git
cd freesewing
npm run kickstart
```
## Links 👩‍💻
**Official channels**
- 💻 Makers website: [FreeSewing.eu](https://freesewing.eu/)
- 💻 Developers website: [FreeSewing.dev](https://freesewing.dev/)
- ✅ [Support](https://forum.freesewing.eu/),
[Issues](https://codeberg.org/freesewing/freesewing/issues) &
[Codeberg](https://codeberg.org/freesewing/freesewing)
**Social media**
- 🐘 Mastodon: [@freesewing](https://freesewing.social/@freesewing) on [FreeSewing.social](https://freesewing.social/)
- 🐘 Mastodon: [@joost](https://freesewing.social/@joost) on [FreeSewing.social](https://freesewing.social/)
**Places the FreeSewing community hangs out**
- 💬 [Forum](https://forum.freesewing.eu/)
- 💬 [Discord](https://discord.freesewing.org/)
- 💬 [Reddit](https://www.reddit.com/r/freesewing/)
## License: MIT 🤓
© [Joost De Cock](https://codeberg.org/joostdecock).
See [the license file](https://codeberg.org/freesewing/freesewing/blob/develop/LICENSE) for details.
## Where to get help 🤯
For [Support](https://freesewing.eu/support), please use the [forum](https://forum.freesewing.eu).

View file

@ -0,0 +1,47 @@
{
"name": "@freesewing/utils",
"version": "4.0.0",
"description": "A number of utilities, typically used by FreeSewing frontend code",
"author": "Joost De Cock <joost@joost.at> (https://github.com/joostdecock)",
"homepage": "https://freesewing.org/",
"repository": "github:freesewing/freesewing",
"license": "MIT",
"bugs": {
"url": "https://github.com/freesewing/freesewing/issues"
},
"funding": {
"type": "individual",
"url": "https://freesewing.org/patrons/join"
},
"keywords": [
"freesewing",
"freesewing"
],
"type": "module",
"module": "./src/index.mjs",
"scripts": {
"symlink": "mkdir -p ./node_modules/@freesewing && cd ./node_modules/@freesewing && ln -s -f ../../../* . && cd -",
"test": "echo \"utils: No tests configured. Perhaps you could write some?\" && exit 0",
"tips": "node ../../scripts/help.mjs",
"lint": "npx eslint 'src/**' 'tests/*.mjs'"
},
"peerDependencies": {},
"dependencies": {
"lodash": "^4.17.21",
"tlds": "^1.255.0"
},
"devDependencies": {},
"files": [
"src/",
"i18n/",
"about.json",
"README.md"
],
"publishConfig": {
"access": "public",
"tag": "latest"
},
"engines": {
"node": ">= 20"
}
}

View file

@ -0,0 +1,691 @@
import tlds from 'tlds/index.json' with { type: 'json' }
import { cloudflare as cloudflareConfig } from '@freesewing/config'
import _get from 'lodash/get.js'
import _set from 'lodash/set.js'
import _unset from 'lodash/unset.js'
import _orderBy from 'lodash/orderBy.js'
import { loadingMessages } from './loading-messages.mjs'
import { Path, Point } from '@freesewing/core'
/*
* Re-export lodash utils
*/
export const get = _get
export const set = _set
export const unset = _unset
export const orderBy = _orderBy
/*
* VARIABLES
*/
/*
* CSS classes to spread icon + text horizontally on a button
*/
export const horFlexClasses =
'tw-flex tw-flex-row tw-items-center tw-justify-between tw-gap-4 tw-w-full'
/*
* CSS classes to spread icon + text horizontally on a button, only from md upwards
*/
export const horFlexClassesNoSm =
'md:tw-flex md:tw-flex-row md:tw-items-center md:tw-justify-between md:tw-gap-4 tw-w-full'
/*
* These classes are what makes a link a link
*/
export const linkClasses = 'tw-text-secondary hover:tw-underline hover:tw-cursor-pointer'
/*
* FUNCTIONS
*/
/**
* A method to capitalize a string
*
* @param {string} string - The input string
* @return {string} String - The input string capitalized (first letter only)
*/
export function capitalize(string) {
return typeof string === 'string' ? string.charAt(0).toUpperCase() + string.slice(1) : ''
}
/**
* A method to clone objects
*
* Note that as this uses JSON, this can only clone what can be serialized.
*
* @param {object} obj - The object to clone
* @return {object} clone - The cloned object
*/
export function clone(obj) {
return JSON.parse(JSON.stringify(obj))
}
/*
* Returns the URL of a user avatar (on cloudflare)
* based on the ihash and Variant
*
* @param {string} ihash - The user's ihash
* @param {string} variant - One of the cloudflare image variants
* @return {string} url - The image URL
*/
export function userAvatarUrl({ ihash = false, variant = 'public' }) {
/*
* If the variant is invalid, set it to the smallest thumbnail so
* people don't load enourmous images by accident
*/
if (!cloudflareConfig.variants.includes(variant)) variant = 'sq100'
/*
* Without an ihash, return something default
*/
return ihash
? cloudflareImageUrl({ id: `uid-${ihash}`, variant })
: cloudflareImageUrl({ id: `default-avatar`, variant })
}
/*
* Returns the URL of a cloudflare image
* based on the ID and Variant
*
* @param {string} id - The image ID
* @param {string} variant - One of the cloudflare image variants
* @return {string} url - The image URL
*/
export function cloudflareImageUrl({ id = 'default-avatar', variant = 'public' }) {
/*
* Return something default so that people will actually change it
*/
if (!id || id === 'default-avatar') return cloudflareConfig.dflt
/*
* If the variant is invalid, set it to the smallest thumbnail so
* people don't load enourmous images by accident
*/
if (!cloudflareConfig.variants.includes(variant)) variant = 'sq100'
return `${cloudflareConfig.url}${id}/${variant}`
}
/**
* Determines the design option type based on the option's config
*
* @param {object} option - The option config
* @return {string} type - The option type
*/
export function designOptionType(option) {
if (typeof option?.pct !== 'undefined') return 'pct'
if (typeof option?.bool !== 'undefined') return 'bool'
if (typeof option?.count !== 'undefined') return 'count'
if (typeof option?.deg !== 'undefined') return 'deg'
if (typeof option?.list !== 'undefined') return 'list'
if (typeof option?.mm !== 'undefined') return 'mm'
return 'constant'
}
/*
* Parses value that should be a distance (cm or inch) into a value in mm
*
* This essentially exists for the benefit of imperial users who might input
* a string like `2 3/4` and we then have to make sense of that.
*
* @param {string} val - The original input
* @param {string} imperial - True if units are imperial (not metric)
* @return {number} mm - The result in millimeter
*/
export function distanceAsMm(val = false, imperial = false) {
// No input is not valid
if (!val) return false
// Cast to string, and replace comma with period
val = val.toString().trim().replace(',', '.')
// Regex pattern for regular numbers with decimal seperator or fractions
const regex = imperial
? /^-?[0-9]*(\s?[0-9]+\/|[.])?[0-9]+$/ // imperial (fractions)
: /^-?[0-9]*[.]?[0-9]+$/ // metric (no fractions)
if (!val.match(regex)) return false
// if fractions are allowed, parse for fractions, otherwise use the number as a value
if (imperial) val = fractionToDecimal(val)
return isNaN(val) ? false : Number(val)
}
/**
* Format a number using fractions, typically used for imperial
*
* @param {number} fraction - the value to process
* @param {string} format - One of
* fraction: the value to process
* format: one of the the type of formatting to apply. html, notags, or anything else which will only return numbers
*/
export const formatFraction128 = (fraction, format = 'html') => {
let negative = ''
let inches = ''
let rest = ''
if (fraction < 0) {
fraction = fraction * -1
negative = '-'
}
if (Math.abs(fraction) < 1) rest = fraction
else {
inches = Math.floor(fraction)
rest = fraction - inches
}
let fraction128 = Math.round(rest * 128)
if (fraction128 == 0) return formatImperial(negative, inches || fraction128, false, false, format)
for (let i = 1; i < 7; i++) {
const numoFactor = Math.pow(2, 7 - i)
if (fraction128 % numoFactor === 0)
return formatImperial(negative, inches, fraction128 / numoFactor, Math.pow(2, i), format)
}
return (
negative +
Math.round(fraction * 100) / 100 +
(format === 'html' || format === 'notags' ? '"' : '')
)
}
/*
* Format an imperial value
*
* @param {bool} neg - Whether or not to render as a negative value
* @param {number} inch - The inches
* @param {number} numo - The fration numerator
* @param {number} deno - The fration denominator
* @param {string} format - One of 'html', 'notags', or anything else for numbers only
* @return {string} formatted - The formatted value
*/
export function formatImperial(neg, inch, numo = false, deno = false, format = 'html') {
if (format === 'html') {
if (numo) return `${neg}${inch}&nbsp;<sup>${numo}</sup>/<sub>${deno}</sub>"`
else return `${neg}${inch}"`
} else if (format === 'notags') {
if (numo) return `${neg}${inch} ${numo}/${deno}"`
else return `${neg}${inch}"`
} else {
if (numo) return `${neg}${inch} ${numo}/${deno}`
else return `${neg}${inch}`
}
}
/**
* Format a value in mm, taking units into account
*
* @param {number} val - The value to format
* @param {units} units - Both 'imperial' and true will result in imperial, everything else is metric
* @param {string} format - One of 'html', 'notags', or anything else for numbers only
* @return {string} result - The formatted result
*/
export function formatMm(val, units, format = 'html') {
val = roundDistance(val)
if (units === 'imperial' || units === true) {
if (val == 0) return formatImperial('', 0, false, false, format)
let fraction = val / 25.4
return formatFraction128(fraction, format)
} else {
if (format === 'html' || format === 'notags') return roundDistance(val / 10) + 'cm'
else return roundDistance(val / 10)
}
}
/*
* Formats a number
*
* @params {number} nun = The original number
* @params {string} suffix = Any suffix to add
* @return {string} newNumber - The formatted number
*/
export const formatNumber = (num, suffix = '') => {
if (num === null || typeof num === 'undefined') return num
if (typeof num.value !== 'undefined') num = num.value
// Small values don't get formatted
if (num < 1) return num
if (num) {
const sizes = ['', 'K', 'M', 'B']
const i = Math.min(
parseInt(Math.floor(Math.log(num) / Math.log(1000)).toString(), 10),
sizes.length - 1
)
return `${(num / 1000 ** i).toFixed(i ? 1 : 0)}${sizes[i]}${suffix}`
}
return '0'
}
/**
* Format a percentage (as in, between 0 and 1)
*
* @param {number} val - The value
* @return {string} pct - The value formatted as percentage
*/
export function formatPercentage(val) {
return Math.round(1000 * val) / 10 + '%'
}
/** convert a value that may contain a fraction to a decimal */
export function fractionToDecimal(value) {
// if it's just a number, return it
if (!isNaN(value)) return value
// keep a running total
let total = 0
// split by spaces
let chunks = String(value).split(' ')
if (chunks.length > 2) return Number.NaN // too many spaces to parse
// a whole number with a fraction
if (chunks.length === 2) {
// shift the whole number from the array
const whole = Number(chunks.shift())
// if it's not a number, return NaN
if (isNaN(whole)) return Number.NaN
// otherwise add it to the total
total += whole
}
// now we have only one chunk to parse
let fraction = chunks[0]
// split it to get numerator and denominator
let fChunks = fraction.trim().split('/')
// not really a fraction. return NaN
if (fChunks.length !== 2 || fChunks[1] === '') return Number.NaN
// do the division
let num = Number(fChunks[0])
let denom = Number(fChunks[1])
if (isNaN(num) || isNaN(denom)) return NaN
return total + num / denom
}
/*
* Get search parameters from the browser
*
* @param {string} name - Name of the parameter to retrieve
* @return {string} value - Value of the parameter
*/
export function getSearchParam(name = 'id') {
if (typeof window === 'undefined') return undefined
return new URLSearchParams(window.location.search).get(name) // eslint-disable-line
}
/**
* Helper method to determine whether all required measurements for a design are present
*
* @param {object} Design - The FreeSewing design (or a plain object holding measurements)
* @param {object} measurements - An object holding the user's measurements
* @return {array} result - An array where the first element is true when we
* have all measurements, and false if not. The second element is a list of
* missing measurements.
*/
export function hasRequiredMeasurements(Design, measurements = {}) {
/*
* If design is just a plain object holding measurements, we restructure it as a Design
* As it happens, this method is smart enough to test for this, so we call it always
*/
Design = structureMeasurementsAsDesign(Design)
/*
* Walk required measurements, and keep track of what's missing
*/
const missing = []
for (const m of Design.patternConfig?.measurements || []) {
if (typeof measurements[m] === 'undefined') missing.push(m)
}
/*
* Return true or false, plus a list of missing measurements
*/
return [missing.length === 0, missing]
}
/*
* Convert a measurement to millimeter
*
* @param {number} value - The current value
* @param {string} units - One of metric or imperial
* @return {number} mm - The value in millimeter
*/
export function measurementAsMm(value, units = 'metric') {
if (typeof value === 'number') return value * (units === 'imperial' ? 25.4 : 10)
if (String(value).endsWith('.')) return false
if (units === 'metric') {
value = Number(value)
if (isNaN(value)) return false
return value * 10
} else {
const decimal = fractionToDecimal(value)
if (isNaN(decimal)) return false
return decimal * 24.5
}
}
/** convert a millimeter value to a Number value in the given units */
export function measurementAsUnits(mmValue, units = 'metric') {
return round(mmValue / (units === 'imperial' ? 25.4 : 10), 3)
}
/*
* Helper method to handle updates to nested object properties
*
* This is mostly using lodash.set but also has a quirk
* where passing 'unset' as value unsets the value
*
* @param {object} obj - The object to mutate
* @param {string|array} path - The path to the property to change, either an array or dot notation
* @param {mixed} val - The value to set it to, or 'unset' to remove/unset it
* @return {object} obj - The mutated object
*/
export const mutateObject = (obj = {}, path, val = 'unset') => {
if (val === 'unset') {
if (Array.isArray(path) && Array.isArray(path[0])) {
for (const [ipath, ival = 'unset'] of path) {
if (ival === 'unset') unset(obj, ipath)
else set(obj, ipath, ival)
}
} else unset(obj, path)
} else set(obj, path, val)
return obj
}
/**
* This calculates teh length of a path that is obtained from renderprops
*
* In other words, a plain POJO with the path data, and not an instantiated Path object from core.
* This is useful if you want to know the path length after rendering.
*
* @param {object} path - A path object as available from renderProps
* @return {number} length - The path length in mm
*/
export function pathLength(path) {
let p = new Path()
for (const op of path.ops) {
if (op.type === 'move') p = p.move(new Point(op.to.x, op.to.y))
if (op.type === 'line') p = p.line(new Point(op.to.x, op.to.y))
if (op.type === 'curve')
p = p.curve(
new Point(op.cp1.x, op.cp1.y),
new Point(op.cp2.x, op.cp2.y),
new Point(op.to.x, op.to.y)
)
if (op.type === 'close') p = p.close()
}
return p.length()
}
/** Generate a URL to create a new pattern with a given design, settings, and view */
export const patternUrlFromState = (state = {}, includeMeasurements = false, view = 'draft') => {
// Avoid changing state by accident
const newState = clone(state)
const measurements = includeMeasurements ? { ...(newState.settings?.measurements || {}) } : {}
const settings = { ...(newState.settings || {}) }
settings.measurements = measurements
const obj = {
design: newState.design,
settings,
view,
}
return `/editor/#s=${encodeURIComponent(JSON.stringify(obj))}`
}
/*
* A method to ensure input is not empty
*
* @param {string} input - The input
* @return {bool} notEmpty - True if input is not an emtpy strign, false of not
*/
export const notEmpty = (input) => `${input}`.length > 0
/*
* A method to build a structured menu of design options
*/
export const optionsMenuStructure = (options, settings, asFullList = false) => {
if (!options) return options
const sorted = {}
for (const [name, option] of Object.entries(options)) {
if (typeof option === 'object') sorted[name] = { ...option, name }
}
const menu = {}
// Fixme: One day we should sort this based on the translation
for (const option of orderBy(sorted, ['order', 'menu', 'name'], ['asc', 'asc', 'asc'])) {
if (typeof option === 'object') {
const oType = optionType(option)
option.dflt = option.dflt || option[oType]
if (oType === 'pct') option.dflt /= 100
if (typeof option.menu === 'function')
option.menu = asFullList
? 'conditional'
: option.menu(settings, mergeOptions(settings, options))
if (option.menu) {
// Handle nested groups that don't have any direct children
if (option.menu.includes('.')) {
let menuPath = []
for (const chunk of option.menu.split('.')) {
menuPath.push(chunk)
set(menu, `${menuPath.join('.')}.isGroup`, true)
}
}
set(menu, `${option.menu}.isGroup`, true)
set(menu, `${option.menu}.${option.name}`, option)
} else if (typeof option.menu === 'undefined') {
console.log(
`Warning: Option ${option.name} does not have a menu config. ` +
'Either configure it, or set it to false to hide this option.'
)
}
}
}
// Always put advanced at the end
if (menu.advanced) {
const adv = menu.advanced
delete menu.advanced
menu.advanced = adv
}
return menu
}
/*
* A method to determine the option type based on its config
*/
export const optionType = (option) => {
if (typeof option?.pct !== 'undefined') return 'pct'
if (typeof option?.bool !== 'undefined') return 'bool'
if (typeof option?.count !== 'undefined') return 'count'
if (typeof option?.deg !== 'undefined') return 'deg'
if (typeof option?.list !== 'undefined') return 'list'
if (typeof option?.mm !== 'undefined') return 'mm'
return 'constant'
}
/*
* Returns a random loading message
*
* @return {string} msg - A random loading message
*/
export function randomLoadingMessage() {
return loadingMessages[Math.floor(Math.random() * loadingMessages.length)]
}
/*
* Generic rounding method
*
* @param {number} val - The input number
* @param {number} decimals - Number of decimals to round to
* @return {number} result - The input val rounded to the number of decimals specified
*/
export function round(val, decimals = 1) {
return Math.round(val * Math.pow(10, decimals)) / Math.pow(10, decimals)
}
/*
* Rounds a value that is a distance, either mm or inch
*
* @param {number} val - The value to round
* @param {string} units - Use 'imperial' or true for imperial, anything else and you get metric
* @return {number} rounded - The rounded value
*/
export function roundDistance(val, units) {
return units === 'imperial' || units === true
? Math.round(val * 1000000) / 1000000
: Math.round(val * 10) / 10
}
/*
* A method to render a date in a way that is concise
*
* @param {number} timestamp - The timestamp to render, or current time if none is provided
* @param {bool} withTime - Set this to true to also include time (in addition to date)
* @return {string} date - The formatted date
*/
export function shortDate(timestamp = false, withTime = true) {
const options = {
year: 'numeric',
month: 'short',
day: 'numeric',
}
if (withTime) {
options.hour = '2-digit'
options.minute = '2-digit'
options.hour12 = false
}
const ts = timestamp ? new Date(timestamp) : new Date()
return ts.toLocaleDateString('en', options)
}
/*
* Shorten a UUID
*
* @param {string} uuid - The input UUID
* @return {string} short - The shortened UUID
*/
export const shortUuid = (uuid) => uuid.slice(0, 5)
/*
* This takes a POJO of measurements, and turns it into a structure that matches a design object
*
* @param {object} measurements - The POJO of measurments
* @return {object} design - The measurements structured as a design object
*/
export function structureMeasurementsAsDesign(measurements) {
return measurements.patternConfig ? measurements : { patternConfig: { measurements } }
}
/*
* We used to use react-timeago but that's too much overhead
* This is a drop-in replacement that does not rerender
*
* @param {string/number} timestamp - The time to parse
* @return {string} timeago - How long ago it was
*/
export function timeAgo(timestamp, terse = true) {
const delta = new Date() - new Date(timestamp)
const seconds = Math.floor(delta / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const months = Math.floor(days / 30)
const years = Math.floor(days / 365)
const suffix = ' ago'
if (seconds < 1) return 'Now'
if (seconds === 1) return `${terse ? '1s' : '1 second'}${suffix}`
if (seconds === 60) return `${terse ? '1m' : '1 minute'}${suffix}`
if (seconds < 91) return `${seconds}${terse ? 's' : ' seconds'}${suffix}`
if (minutes === 60) return `${terse ? '1h' : '1 hour'}${suffix}`
if (minutes < 120) return `${minutes}${terse ? 'm' : ' minutes'}${suffix}`
if (hours === 24) return `${terse ? '1d' : '1 day'}${suffix}`
if (hours < 48) return `${hours}${terse ? 'h' : ' hours'}${suffix}`
if (days < 61) return `${days}${terse ? 'd' : ' days'}${suffix}`
if (months < 25) return `${months}${terse ? 'M' : ' months'}${suffix}`
return `${years}${terse ? 'Y' : ' years'}${suffix}`
}
/**
* Validates an email address for correct syntax
*
* @param {string} email - The email input to check
* @return {bool} valid - True if it's a valid email address
*/
export function validateEmail(email) {
/* eslint-disable */
const re =
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
/* eslint-enable */
return re.test(email)
}
/**
* Validates the top level domain (TLT) for an email address
*
* @param {string} email - The email input to check
* @return {bool} valid - True if it's a valid email address
*/
export function validateTld(email) {
const tld = email.split('@').pop().split('.').pop().toLowerCase()
if (tlds.indexOf(tld) === -1) return tld
else return true
}
/**
* Copies a string to the clipboard using the clipboard API
*
* @param {string} text - The text to copy to the clipboard
*/
export function copyToClipboard(text) {
const textToCopy = text
/*
* This is only available in a secure browser context
* So when we are running a localhost dev instance,
* this won't work, and we fall back to the one further down.
*/
if (navigator?.clipboard) {
navigator.clipboard.writeText(text).catch((error) => {
console.error('Failed to use the clipboard API to copy text to clipboard:', error)
copyToClipboardFallback(text)
})
} else {
copyToClipboardFallback(text)
}
}
/**
* Copies a string to the clipboard using DOM manipulation
*
* @param {string} text - The text to copy to the clipboard
*/
function copyToClipboardFallback(text) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
export function navigate(href, relative = false) {
// Guard against non-browser use
if (!window) return
if (relative) window.location.href = `${window.location.origin}${href}`
else window.location.href = href
}

View file

@ -0,0 +1,27 @@
export const loadingMessages = [
'Unfolding ideas...',
'Teaching hamsters to run faster...',
'Convincing pixels to get in line...',
'Warming up the flux capacitor...',
'Untangling digital spaghetti...',
'Counting backwards from infinity...',
'Generating witty loading messages...',
'Brewing digital coffee...',
'Herding cats into quantum boxes...',
'Downloading more RAM...',
'Dividing by zero...',
'Spinning up the hamster wheel...',
'Converting caffeine to code...',
'Bending the space-time continuum...',
'Charging laser batteries...',
'Summoning the data spirits...',
'Searching for the lost semicolon...',
'Consulting the digital rolodex...',
'Calibrating the flux capacitor...',
'Collecting magic internet points...',
'Solving P vs NP...',
'Compressing time and space...',
'Entering the matrix...',
'Upgrading to Web 4.0...',
'Debugging the debugger...',
]