[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:
parent
d22fbe78d9
commit
51dc1d9732
6626 changed files with 142053 additions and 150606 deletions
144
packages/utils/CHANGELOG.md
Normal file
144
packages/utils/CHANGELOG.md
Normal 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
143
packages/utils/README.md
Normal 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:
|
||||
>
|
||||
> [](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).
|
||||
|
47
packages/utils/package.json
Normal file
47
packages/utils/package.json
Normal 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"
|
||||
}
|
||||
}
|
691
packages/utils/src/index.mjs
Normal file
691
packages/utils/src/index.mjs
Normal 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} <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
|
||||
}
|
27
packages/utils/src/loading-messages.mjs
Normal file
27
packages/utils/src/loading-messages.mjs
Normal 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...',
|
||||
]
|
Loading…
Add table
Add a link
Reference in a new issue