// Dependencies
import React from 'react'
import { defaultConfig } from '../config/index.mjs'
import { round, formatMm, randomLoadingMessage } from '@freesewing/utils'
import { formatDesignOptionValue, menuCoreSettingsStructure, fractionToDecimal } from './index.mjs'
import { menuUiPreferencesStructure } from './ui-preferences.mjs'
import { i18n } from '@freesewing/collection'
import { i18n as pluginI18n } from '@freesewing/core-plugins'
import { flags as flagTranslations } from '@freesewing/i18n'
// Components
import {
ErrorIcon,
MeasurementsIcon,
OptionsIcon,
SettingsIcon,
UiIcon,
} from '@freesewing/react/components/Icon'
import { HtmlSpan } from '../components/HtmlSpan.mjs'
/*
* i18n makes everything complicated
*/
const flagTranslationsWithNamespace = {}
for (const [key, val] of Object.entries(flagTranslations || {}))
flagTranslationsWithNamespace[`flag:${key}`] = val
/*
* This method bundles pattern translations in a object we can pass to the Pattern component
*
* @param {string} design - The name of the design
* @return {object} strings - An object of key/value pairs for translation
*/
export const bundlePatternTranslations = (design) => {
const strings = {}
for (const [key, val] of Object.entries(flagTranslationsWithNamespace)) strings[key] = val
if (i18n[design]?.en) {
const en = i18n[design].en
// Parts have no prefix
for (const [key, val] of Object.entries(en.p || {})) strings[key] = val
// Strings do
for (const [key, val] of Object.entries(en.s || {})) strings[`${design}:${key}`] = val
}
for (const [key, val] of Object.entries(pluginI18n.en)) strings[key] = val
return strings
}
/*
* This method drafts the pattern
*
* @param {function} Design - The Design constructor
* @param {object} settings - The settings for the pattern
* @param {array} plugins - Any (extra) plugins to load into the pattern
* @return {object} data - The drafted pattern, along with errors and failure data
*/
export function draft(Design, settings, plugins = [], pluginsHook = false) {
const pattern = new Design(settings)
for (const plugin of plugins) pattern.use(plugin)
if (pluginsHook) pluginsHook(pattern)
const data = {
// The pattern
pattern,
// Any errors logged by the pattern
errors: [],
// If the pattern fails to draft, this will hold the error
failure: false,
}
// Draft the pattern or die trying
try {
data.pattern.draft()
data.errors.push(...data.pattern.store.logs.error)
for (const store of data.pattern.setStores) data.errors.push(...store.logs.error)
} catch (error) {
data.failure = error
}
return data
}
/*
* This method samples a pattern option
*
* @param {function} Design - The Design constructor
* @param {object} settings - The settings for the pattern
* @param {array} plugins - Any (extra) plugins to load into the pattern
* @return {object} data - The drafted pattern, along with errors and failure data
*/
export function sample(Design, settings, plugins = []) {
const pattern = new Design(settings)
for (const plugin of plugins) pattern.use(plugin)
const data = {
// The pattern
pattern,
// Any errors logged by the pattern
errors: [],
// If the pattern fails to draft, this will hold the error
failure: false,
}
// Draft the pattern or die trying
try {
data.pattern.sample()
data.errors.push(...data.pattern.store.logs.error)
for (const store of data.pattern.setStores) data.errors.push(...store.logs.error)
} catch (error) {
data.failure = error
}
return data
}
export function flattenFlags(flags) {
const all = {}
for (const type of defaultConfig.flagTypes) {
let i = 0
if (flags[type]) {
for (const flag of Object.values(flags[type])) {
i++
all[`${type}-${i}`] = { ...flag, type }
}
}
}
return all
}
export function getUiPreferenceUndoStepData({ step }) {
/*
* We'll need these
*/
const field = step.name === 'ui' ? step.path[1] : step.path[2]
const structure = menuUiPreferencesStructure()[field]
if (!structure) return false
/*
* This we'll end up returning
*/
const data = {
icon: ,
field,
title: structure.title,
menu: 'UI Preferences',
structure: menuUiPreferencesStructure()[field],
}
const FieldIcon = data.structure.icon
data.fieldIcon =
/*
* Add oldval and newVal if they exist, or fall back to default
*/
for (const key of ['old', 'new'])
data[key + 'Val'] = t(
structure.choiceTitles[
structure.choiceTitles[String(step[key])] ? String(step[key]) : String(structure.dflt)
]
)
return data
}
export function getCoreSettingUndoStepData({ step, state, Design }) {
const field = step.path[1]
const { settings = {} } = state // Guard against undefined settings
const structure = menuCoreSettingsStructure({
language: state.language,
units: settings.units,
sabool: settings.sabool,
parts: Design.patternConfig.draftOrder,
})
const data = {
field,
menu: 'Core Settings',
title: structure?.[field] ? structure[field].title : '',
icon: ,
structure: structure[field],
}
if (!data.structure && field === 'sa') data.structure = structure.samm
const FieldIcon = data.structure?.icon || ErrorIcon
data.fieldIcon =
/*
* Save us some typing
*/
const cord = settingsValueCustomOrDefault
const Html = HtmlSpan
/*
* Need to allow HTML in some of these in case this is
* formated as imperial which uses and
*/
switch (data.field) {
case 'margin':
case 'sa':
case 'samm':
if (data.field !== 'margin') data.title = 'Seam Allowance'
data.oldVal =
data.newVal =
return data
case 'scale':
data.oldVal = cord(step.old, data.structure.dflt)
data.newVal = cord(step.new, data.structure.dflt)
return data
case 'units':
data.oldVal = t(step.new === 'imperial' ? 'Metric Units' : 'Imperial Units')
data.newVal = t(step.new === 'imperial' ? 'Imperial Units' : 'Metric Units')
return data
case 'only':
data.oldVal = cord(step.old, data.structure.dflt) || 'Include all parts'
data.newVal = cord(step.new, data.structure.dflt) || 'Include all parts'
return data
default:
data.oldVal = t(
data.structure.choiceTitles[String(step.old)]
? data.structure.choiceTitles[String(step.old)]
: data.structure.choiceTitles[String(data.structure.dflt)]
)
data.newVal = t(
data.structure.choiceTitles[String(step.new)]
? data.structure.choiceTitles[String(step.new)]
: data.structure.choiceTitles[String(data.structure.dflt)]
)
return data
}
}
export function getDesignOptionUndoStepData({ step, state, Design }) {
const option = Design.patternConfig.options[step.path[2]]
const data = {
icon: ,
field: step.path[2],
title: `${state.design}:${step.path[2]}`,
menu: `Design Options`,
oldVal: formatDesignOptionValue(option, step.old, state.units === 'imperial'),
newVal: formatDesignOptionValue(option, step.new, state.units === 'imperial'),
}
return data
}
export function getUndoStepData(props) {
/*
* UI Preferences
*/
if ((props.step.name === 'settings' && props.step.path[1] === 'ui') || props.step.name === 'ui')
return getUiPreferenceUndoStepData(props)
/*
* Design options
*/
if (props.step.name === 'settings' && props.step.path[1] === 'options')
return getDesignOptionUndoStepData(props)
/*
* Core Settings
*/
if (
props.step.name === 'settings' &&
[
'sa',
'samm',
'margin',
'scale',
'only',
'complete',
'paperless',
'sabool',
'units',
'expand',
].includes(props.step.path[1])
)
return getCoreSettingUndoStepData(props)
/*
* Measurements
*/
if (props.step.name === 'settings' && props.step.path[1] === 'measurements') {
const data = {
icon: ,
field: 'measurements',
title: `measurements`,
menu: 'measurements',
}
/*
* Single measurements change?
*/
if (props.step.path[2])
return {
...data,
field: props.step.path[2],
oldVal: formatMm(props.step.old, props.imperial),
newVal: formatMm(props.step.new, props.imperial),
}
let count = 0
for (const m of Object.keys(props.step.new)) {
if (props.step.new[m] !== props.step.old?.[m]) count++
}
return { ...data, msg: `${count} measurements updated` }
}
/*
* Bail out of the step fell throug
*/
return false
}
/*
* This helper method constructs the initial state object.
*
* If they are not present, it will fall back to the relevant defaults
*/
export function initialEditorState(preload = {}, config) {
/*
* Create initial state object
*/
const initial = { ...config.initialState, ...preload }
/*
* FIXME: Add preload support, from URL or other sources, rather than just passing in an object
*/
return initial
}
/*
* round a value to the correct number of decimal places to display all supplied digits after multiplication
* this is a workaround for floating point errors
* examples:
* roundPct(0.72, 100) === 72
* roundPct(7.5, 0.01) === 0.075
* roundPct(7.50, 0.01) === 0.0750
* @param {Number} num the number to be operated on
* @param {Number} factor the number to multiply by
* @return {Number} the given num multiplied by the factor, rounded appropriately
*/
export function menuRoundPct(num, factor) {
// stringify
const str = '' + num
// get the index of the decimal point in the number
const decimalIndex = str.indexOf('.')
// get the number of places the factor moves the decimal point
const factorPlaces = factor > 0 ? Math.ceil(Math.log10(factor)) : Math.floor(Math.log10(factor))
// the number of places needed is the number of digits that exist after the decimal minus the number of places the decimal point is being moved
const numPlaces = Math.max(0, str.length - (decimalIndex + factorPlaces))
return round(num * factor, numPlaces)
}
const menuNumericInputMatcher = /^-?[0-9]*[.,eE]?[0-9]+$/ // match a single decimal separator
const menuFractionInputMatcher = /^-?[0-9]*(\s?[0-9]+\/|[.,eE])?[0-9]+$/ // match a single decimal separator or fraction
/**
* Validate and parse a value that should be a number
* @param {any} val the value to validate
* @param {Boolean} allowFractions should fractions be considered valid input?
* @param {Number} min the minimum allowable value
* @param {Number} max the maximum allowable value
* @return {null|false|Number} null if the value is empty,
* false if the value is invalid,
* or the value parsed to a number if it is valid
*/
export function menuValidateNumericValue(
val,
allowFractions = true,
min = -Infinity,
max = Infinity
) {
// if it's empty, we're neutral
if (typeof val === 'undefined' || val === '') return null
// make sure it's a string
val = ('' + val).trim()
// get the appropriate match pattern and check for a match
const matchPattern = allowFractions ? menuFractionInputMatcher : menuNumericInputMatcher
if (!val.match(matchPattern)) return false
// replace comma with period
const parsedVal = val.replace(',', '.')
// if fractions are allowed, parse for fractions, otherwise use the number as a value
const useVal = allowFractions ? fractionToDecimal(parsedVal) : parsedVal
// check that it's a number and it's in the range
if (isNaN(useVal) || useVal > max || useVal < min) return false
// all checks passed. return the parsed value
return useVal
}
/**
* Check to see if a value is different from its default
* @param {Number|String|Boolean} current the current value
* @param {Object} config configuration containing a dflt key
* @return {Boolean} was the value changed?
*/
export function menuValueWasChanged(current, config) {
if (typeof current === 'undefined') return false
if (current == config.dflt) return false
return true
}
import get from 'lodash.get'
import set from 'lodash.set'
import unset from 'lodash.unset'
const UNSET = '__UNSET__'
/*
* Helper method to handle object updates
*
* @param {object} obj - The object to update
* @param {string|array} path - The path to the key to update, either as array or dot notation
* @param {mixed} val - The new value to set or 'unset' to unset the value
* @return {object} result - The updated object
*/
export function objUpdate(obj = {}, path, val = '__UNSET__') {
if (val === UNSET) unset(obj, path)
else set(obj, path, val)
return obj
}
/*
* Helper method to handle object updates that also updates the undo history in ephemeral state
*
* @param {object} obj - The object to update
* @param {string|array} path - The path to the key to update, either as array or dot notation
* @param {mixed} val - The new value to set or 'unset' to unset the value
* @param {function} setEphemeralState - The ephemeral state setter
* @return {object} result - The updated object
*/
export function undoableObjUpdate(name, obj = {}, path, val = '__UNSET__', setEphemeralState) {
const current = get(obj, path)
setEphemeralState((cur) => {
if (!Array.isArray(cur.undos)) cur.undos = []
return {
...cur,
undos: [
{
name,
time: Date.now(),
path,
old: current,
new: val,
restore: cloneObject(obj),
},
...cur.undos,
],
}
})
return objUpdate(obj, path, val)
}
/*
* Helper method to strip a namespace: prefix from a string
*
* @param {string} key
* @return {string} keyWithoutNamespace
*/
export function stripNamespace(key) {
const pos = `${key}`.indexOf(':')
return pos === -1 ? key : key.slice(pos + 1)
}
/*
* Helper method to add an undo step for which state updates are handles in another way
*
* This is typically used for SA changes as it requires changing 3 fields:
* - sabool: Is sa on or off?
* - sa: sa value for core
* - samm: Holds the sa value in mm even when sa is off
*
* @param {object} undo - The undo step to add
* @param {object} restore - The state to restore for this step
* @param {function} setEphemeralState - The ephemeral state setter
*/
export function addUndoStep(undo, restore, setEphemeralState) {
setEphemeralState((cur) => {
if (!Array.isArray(cur.undos)) cur.undos = []
return {
...cur,
undos: [{ time: Date.now(), ...undo, restore: cloneObject(restore) }, ...cur.undos],
}
})
}
/*
* Helper method to clone an object
*/
export function cloneObject(obj) {
return JSON.parse(JSON.stringify(obj))
}
/**
* Helper method to push a prefix to a set path
*
* By 'set path' we mean a path to be passed to the
* objUpdate method, which uses lodash's set under the hood.
*
* @param {string} prefix - The prefix path to add
* @param {string|array} path - The path to prefix either as array or a string in dot notation
* @return {array} newPath - The prefixed path
*/
export function statePrefixPath(prefix, path) {
if (Array.isArray(path)) return [prefix, ...path]
else return [prefix, ...path.split('.')]
}
/*
* This creates the helper object for state updates
*/
export function stateUpdateFactory(setState, setEphemeralState, config) {
return {
/*
* This allows raw access to the entire state object
*/
state: (path, val) => setState((cur) => objUpdate({ ...cur }, path, val)),
/*
* These hold an object, so we take a path
*/
settings: (path = null, val = null) => {
/*
* This check can be removed once all code is migrated to the new editor
*/
if (Array.isArray(path) && Array.isArray(path[0]) && val === null) {
throw new Error(
'Update.settings was called with an array of operations. This is no longer supported.'
)
}
return setState((cur) =>
undoableObjUpdate(
'settings',
{ ...cur },
statePrefixPath('settings', path),
val,
setEphemeralState
)
)
},
/*
* Helper to restore from undo state
* Takes the index of the undo step in the array in ephemeral state
*/
restore: async (i, ephemeralState) => {
setState(ephemeralState.undos[i].restore)
setEphemeralState((cur) => {
cur.undos = cur.undos.slice(i + 1)
return cur
})
},
/*
* Helper to toggle SA on or off as that requires managing sa, samm, and sabool
*/
toggleSa: () =>
setState((cur) => {
const sa = cur.settings?.samm || (cur.settings?.units === 'imperial' ? 15.3125 : 10)
const restore = cloneObject(cur)
// This requires 3 changes
const update = cur.settings.sabool
? [
['sabool', 0],
['sa', 0],
['samm', sa],
]
: [
['sabool', 1],
['sa', sa],
['samm', sa],
]
for (const [key, val] of update) objUpdate(cur, `settings.${key}`, val)
// Which we'll group as 1 undo action
addUndoStep(
{
name: 'settings',
path: ['settings', 'sa'],
new: cur.settings.sabool ? 0 : sa,
old: cur.settings.sabool ? sa : 0,
},
restore,
setEphemeralState
)
return cur
}),
ui: (path, val) =>
setState((cur) =>
undoableObjUpdate('ui', { ...cur }, statePrefixPath('ui', path), val, setEphemeralState)
),
/*
* These only hold a string, so we only take a value
*/
design: (val) => setState((cur) => objUpdate({ ...cur }, 'design', val)),
view: (val) => {
// Only take valid view names
if (!config.views.includes(val)) return console.log('not a valid view:', val)
setState((cur) => ({ ...cur, view: val }))
// Also add it onto the views (history)
setEphemeralState((cur) => {
if (!Array.isArray(cur.views)) cur.views = []
return { ...cur, views: [val, ...cur.views] }
})
},
viewBack: () => {
setEphemeralState((eph) => {
if (Array.isArray(eph.views) && config.views.includes(eph.views[1])) {
// Load view at the 1 position of the history
setState((cur) => ({ ...cur, view: eph.views[1] }))
return { ...eph, views: eph.views.slice(1) }
}
return eph
})
},
// Pattern ID (pid)
pid: (val) => setState((cur) => ({ ...cur, pid: val })),
ux: (val) => setState((cur) => objUpdate({ ...cur }, 'ux', val)),
clearPattern: () =>
setState((cur) => {
const newState = { ...cur }
objUpdate(newState, 'settings', {
measurements: cur.settings.measurements,
})
/*
* Let's also reset the renderer to React as that feels a bit like a pattern setting even though it's UI
*/
objUpdate(newState, 'ui', { ...newState.ui, renderer: 'react' })
return newState
}),
clearAll: () => setState(config.initialState),
/*
* These are setters for the ephemeral state which is passed down as part of the
* state object, but is not managed in the state backend because it's ephemeral
*/
startLoading: (id, conf = {}) =>
setEphemeralState((cur) => {
const newState = { ...cur }
if (typeof newState.loading !== 'object') newState.loading = {}
if (typeof conf.color === 'undefined') conf.color = 'info'
newState.loading[id] = {
msg: randomLoadingMessage(),
icon: 'spinner',
...conf,
}
return newState
}),
stopLoading: (id) =>
setEphemeralState((cur) => {
const newState = { ...cur }
if (typeof newState.loading[id] !== 'undefined') delete newState.loading[id]
return newState
}),
clearLoading: () => setEphemeralState((cur) => ({ ...cur, loading: {} })),
notify: (conf, id = false) =>
setEphemeralState((cur) => {
const newState = { ...cur }
/*
* Passing in an id allows making sure the same notification is not repeated
* So if the id is set, and we have a loading state with that id, we just return
*/
if (id && cur.loading?.[id]) return newState
if (typeof newState.loading !== 'object') newState.loading = {}
if (id === false) id = Date.now()
newState.loading[id] = { ...conf, id, fadeTimer: config.notifyTimeout }
return newState
}),
notifySuccess: (msg, id = false) =>
setEphemeralState((cur) => {
const newState = { ...cur }
/*
* Passing in an id allows making sure the same notification is not repeated
* So if the id is set, and we have a loading state with that id, we just return
*/
if (id && cur.loading?.[id]) return newState
if (typeof newState.loading !== 'object') newState.loading = {}
if (id === false) id = Date.now()
newState.loading[id] = {
msg,
icon: 'success',
color: 'success',
id,
fadeTimer: config.notifyTimeout,
}
return newState
}),
notifyFailure: (msg, id = false) =>
setEphemeralState((cur) => {
const newState = { ...cur }
/*
* Passing in an id allows making sure the same notification is not repeated
* So if the id is set, and we have a loading state with that id, we just return
*/
if (id && cur.loading?.[id]) return newState
if (typeof newState.loading !== 'object') newState.loading = {}
if (id === false) id = Date.now()
newState.loading[id] = {
msg,
icon: 'failure',
color: 'error',
id,
fadeTimer: config.notifyTimeout,
}
return newState
}),
fadeNotify: (id) =>
setEphemeralState((cur) => {
const newState = { ...cur }
newState.loading[id] = { ...newState.loading[id], clearTimer: 600, id, fading: true }
delete newState.loading[id].fadeTimer
return newState
}),
clearNotify: (id) =>
setEphemeralState((cur) => {
const newState = { ...cur }
if (typeof newState.loading[id] !== 'undefined') delete newState.loading[id]
return newState
}),
}
}
/*
* Returns the URL of a cloud-hosted image (cloudflare in this case) based on the ID and Variant
*/
export function cloudImageUrl({ id = 'default-avatar', variant = 'public' }) {
/*
* Return something default so that people will actually change it
*/
if (!id || id === 'default-avatar') return defaultConfig.cloudImageDflt
/*
* If the variant is invalid, set it to the smallest thumbnail so
* people don't load enourmous images by accident
*/
if (!defaultConfig.cloudImageVariants.includes(variant)) variant = 'sq100'
return `${defaultConfig.cloudImageUrl}${id}/${variant}`
}
/**
* This method does nothing. It is used to disable certain methods
* that need to be passed it to work
*
* @return {null} null - null
*/
export function noop() {
return null
}
/*
* A method that check that a value is not empty
*/
export function notEmpty(value) {
return String(value).length > 0
}
/*
* A translation fallback method in case none is passed in
*
* @param {string} key - The input
* @return {string} key - The input is returned
*/
export function t(key) {
return Array.isArray(key) ? key[0] : key
}
export function settingsValueIsCustom(val, dflt) {
return typeof val === 'undefined' || val === '__UNSET__' || val === dflt ? false : true
}
export function settingsValueCustomOrDefault(val, dflt) {
return typeof val === 'undefined' || val === '__UNSET__' || val === dflt ? dflt : val
}