2025-04-01 16:15:20 +02:00
|
|
|
// Dependencies
|
|
|
|
import React from 'react'
|
|
|
|
import { defaultConfig } from '../config/index.mjs'
|
|
|
|
import { round, formatMm, randomLoadingMessage } from '@freesewing/utils'
|
2025-05-30 11:29:55 +02:00
|
|
|
import { formatDesignOptionValue, menuCoreSettingsStructure, fractionToDecimal } from './index.mjs'
|
2025-04-01 16:15:20 +02:00
|
|
|
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
|
|
|
|
}
|
2024-09-15 15:29:30 +02:00
|
|
|
/*
|
|
|
|
* This method drafts the pattern
|
|
|
|
*
|
|
|
|
* @param {function} Design - The Design constructor
|
|
|
|
* @param {object} settings - The settings for the pattern
|
2025-04-01 16:15:20 +02:00
|
|
|
* @param {array} plugins - Any (extra) plugins to load into the pattern
|
2024-09-15 15:29:30 +02:00
|
|
|
* @return {object} data - The drafted pattern, along with errors and failure data
|
|
|
|
*/
|
2025-04-13 08:59:27 +00:00
|
|
|
export function draft(Design, settings, plugins = [], pluginsHook = false) {
|
2025-04-01 16:15:20 +02:00
|
|
|
const pattern = new Design(settings)
|
|
|
|
for (const plugin of plugins) pattern.use(plugin)
|
2025-04-13 08:59:27 +00:00
|
|
|
if (pluginsHook) pluginsHook(pattern)
|
2024-09-15 15:29:30 +02:00
|
|
|
const data = {
|
|
|
|
// The pattern
|
2025-04-01 16:15:20 +02:00
|
|
|
pattern,
|
2024-09-15 15:29:30 +02:00
|
|
|
// 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
|
|
|
|
}
|
2025-04-01 16:15:20 +02:00
|
|
|
|
|
|
|
/*
|
|
|
|
* 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) {
|
2024-09-15 15:29:30 +02:00
|
|
|
const all = {}
|
2025-04-01 16:15:20 +02:00
|
|
|
for (const type of defaultConfig.flagTypes) {
|
2024-09-15 15:29:30 +02:00
|
|
|
let i = 0
|
|
|
|
if (flags[type]) {
|
|
|
|
for (const flag of Object.values(flags[type])) {
|
|
|
|
i++
|
|
|
|
all[`${type}-${i}`] = { ...flag, type }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return all
|
|
|
|
}
|
|
|
|
|
2025-04-01 16:15:20 +02:00
|
|
|
export function getUiPreferenceUndoStepData({ step }) {
|
2024-09-15 15:29:30 +02:00
|
|
|
/*
|
|
|
|
* We'll need these
|
|
|
|
*/
|
|
|
|
const field = step.name === 'ui' ? step.path[1] : step.path[2]
|
2025-04-01 16:15:20 +02:00
|
|
|
const structure = menuUiPreferencesStructure()[field]
|
|
|
|
|
|
|
|
if (!structure) return false
|
2024-09-15 15:29:30 +02:00
|
|
|
|
|
|
|
/*
|
|
|
|
* This we'll end up returning
|
|
|
|
*/
|
|
|
|
const data = {
|
2025-04-01 16:15:20 +02:00
|
|
|
icon: <UiIcon />,
|
2024-09-15 15:29:30 +02:00
|
|
|
field,
|
2025-04-01 16:15:20 +02:00
|
|
|
title: structure.title,
|
|
|
|
menu: 'UI Preferences',
|
|
|
|
structure: menuUiPreferencesStructure()[field],
|
2024-09-15 15:29:30 +02:00
|
|
|
}
|
|
|
|
const FieldIcon = data.structure.icon
|
|
|
|
data.fieldIcon = <FieldIcon />
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Add oldval and newVal if they exist, or fall back to default
|
|
|
|
*/
|
|
|
|
for (const key of ['old', 'new'])
|
2025-04-01 16:15:20 +02:00
|
|
|
data[key + 'Val'] = t(
|
2024-09-15 15:29:30 +02:00
|
|
|
structure.choiceTitles[
|
|
|
|
structure.choiceTitles[String(step[key])] ? String(step[key]) : String(structure.dflt)
|
2025-04-01 16:15:20 +02:00
|
|
|
]
|
2024-09-15 15:29:30 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
return data
|
|
|
|
}
|
|
|
|
|
2025-04-01 16:15:20 +02:00
|
|
|
export function getCoreSettingUndoStepData({ step, state, Design }) {
|
2024-09-15 15:29:30 +02:00
|
|
|
const field = step.path[1]
|
2025-04-28 07:06:35 +02:00
|
|
|
const { settings = {} } = state // Guard against undefined settings
|
2025-04-01 16:15:20 +02:00
|
|
|
const structure = menuCoreSettingsStructure({
|
2024-09-15 15:29:30 +02:00
|
|
|
language: state.language,
|
2025-04-28 07:06:35 +02:00
|
|
|
units: settings.units,
|
|
|
|
sabool: settings.sabool,
|
2024-09-15 15:29:30 +02:00
|
|
|
parts: Design.patternConfig.draftOrder,
|
|
|
|
})
|
|
|
|
|
|
|
|
const data = {
|
|
|
|
field,
|
2025-04-01 16:15:20 +02:00
|
|
|
menu: 'Core Settings',
|
|
|
|
title: structure?.[field] ? structure[field].title : '',
|
|
|
|
icon: <SettingsIcon />,
|
2024-09-15 15:29:30 +02:00
|
|
|
structure: structure[field],
|
|
|
|
}
|
|
|
|
if (!data.structure && field === 'sa') data.structure = structure.samm
|
2025-04-01 16:15:20 +02:00
|
|
|
const FieldIcon = data.structure?.icon || ErrorIcon
|
2024-09-15 15:29:30 +02:00
|
|
|
data.fieldIcon = <FieldIcon />
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Save us some typing
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
const cord = settingsValueCustomOrDefault
|
|
|
|
const Html = HtmlSpan
|
2024-09-15 15:29:30 +02:00
|
|
|
|
|
|
|
/*
|
|
|
|
* Need to allow HTML in some of these in case this is
|
|
|
|
* formated as imperial which uses <sub> and <sup>
|
|
|
|
*/
|
|
|
|
switch (data.field) {
|
|
|
|
case 'margin':
|
|
|
|
case 'sa':
|
|
|
|
case 'samm':
|
2025-04-01 16:15:20 +02:00
|
|
|
if (data.field !== 'margin') data.title = 'Seam Allowance'
|
2024-09-15 15:29:30 +02:00
|
|
|
data.oldVal = <Html html={formatMm(cord(step.old, data.structure.dflt))} />
|
|
|
|
data.newVal = <Html html={formatMm(cord(step.new, data.structure.dflt))} />
|
|
|
|
return data
|
|
|
|
case 'scale':
|
|
|
|
data.oldVal = cord(step.old, data.structure.dflt)
|
|
|
|
data.newVal = cord(step.new, data.structure.dflt)
|
|
|
|
return data
|
|
|
|
case 'units':
|
2025-04-01 16:15:20 +02:00
|
|
|
data.oldVal = t(step.new === 'imperial' ? 'Metric Units' : 'Imperial Units')
|
|
|
|
data.newVal = t(step.new === 'imperial' ? 'Imperial Units' : 'Metric Units')
|
2024-09-15 15:29:30 +02:00
|
|
|
return data
|
|
|
|
case 'only':
|
2025-04-01 16:15:20 +02:00
|
|
|
data.oldVal = cord(step.old, data.structure.dflt) || 'Include all parts'
|
|
|
|
data.newVal = cord(step.new, data.structure.dflt) || 'Include all parts'
|
2024-09-15 15:29:30 +02:00
|
|
|
return data
|
|
|
|
default:
|
2025-04-01 16:15:20 +02:00
|
|
|
data.oldVal = t(
|
|
|
|
data.structure.choiceTitles[String(step.old)]
|
2024-09-15 15:29:30 +02:00
|
|
|
? data.structure.choiceTitles[String(step.old)]
|
2025-04-01 16:15:20 +02:00
|
|
|
: data.structure.choiceTitles[String(data.structure.dflt)]
|
2024-09-15 15:29:30 +02:00
|
|
|
)
|
2025-04-01 16:15:20 +02:00
|
|
|
data.newVal = t(
|
|
|
|
data.structure.choiceTitles[String(step.new)]
|
2024-09-15 15:29:30 +02:00
|
|
|
? data.structure.choiceTitles[String(step.new)]
|
2025-04-01 16:15:20 +02:00
|
|
|
: data.structure.choiceTitles[String(data.structure.dflt)]
|
2024-09-15 15:29:30 +02:00
|
|
|
)
|
|
|
|
return data
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-04-01 16:15:20 +02:00
|
|
|
export function getDesignOptionUndoStepData({ step, state, Design }) {
|
2024-09-15 15:29:30 +02:00
|
|
|
const option = Design.patternConfig.options[step.path[2]]
|
|
|
|
const data = {
|
2025-04-01 16:15:20 +02:00
|
|
|
icon: <OptionsIcon />,
|
2024-09-15 15:29:30 +02:00
|
|
|
field: step.path[2],
|
2025-04-01 16:15:20 +02:00
|
|
|
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'),
|
2024-09-15 15:29:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return data
|
|
|
|
}
|
|
|
|
|
2025-04-01 16:15:20 +02:00
|
|
|
export function getUndoStepData(props) {
|
2024-09-15 15:29:30 +02:00
|
|
|
/*
|
|
|
|
* UI Preferences
|
|
|
|
*/
|
|
|
|
if ((props.step.name === 'settings' && props.step.path[1] === 'ui') || props.step.name === 'ui')
|
2025-04-01 16:15:20 +02:00
|
|
|
return getUiPreferenceUndoStepData(props)
|
2024-09-15 15:29:30 +02:00
|
|
|
|
|
|
|
/*
|
|
|
|
* Design options
|
|
|
|
*/
|
|
|
|
if (props.step.name === 'settings' && props.step.path[1] === 'options')
|
2025-04-01 16:15:20 +02:00
|
|
|
return getDesignOptionUndoStepData(props)
|
2024-09-15 15:29:30 +02:00
|
|
|
|
|
|
|
/*
|
|
|
|
* Core Settings
|
|
|
|
*/
|
|
|
|
if (
|
|
|
|
props.step.name === 'settings' &&
|
|
|
|
[
|
|
|
|
'sa',
|
|
|
|
'samm',
|
|
|
|
'margin',
|
|
|
|
'scale',
|
|
|
|
'only',
|
|
|
|
'complete',
|
|
|
|
'paperless',
|
|
|
|
'sabool',
|
|
|
|
'units',
|
|
|
|
'expand',
|
|
|
|
].includes(props.step.path[1])
|
|
|
|
)
|
2025-04-01 16:15:20 +02:00
|
|
|
return getCoreSettingUndoStepData(props)
|
2024-09-15 15:29:30 +02:00
|
|
|
|
|
|
|
/*
|
|
|
|
* Measurements
|
|
|
|
*/
|
|
|
|
if (props.step.name === 'settings' && props.step.path[1] === 'measurements') {
|
|
|
|
const data = {
|
2025-04-01 16:15:20 +02:00
|
|
|
icon: <MeasurementsIcon />,
|
2024-09-15 15:29:30 +02:00
|
|
|
field: 'measurements',
|
2025-04-01 16:15:20 +02:00
|
|
|
title: `measurements`,
|
|
|
|
menu: 'measurements',
|
2024-09-15 15:29:30 +02:00
|
|
|
}
|
|
|
|
/*
|
|
|
|
* Single measurements change?
|
|
|
|
*/
|
|
|
|
if (props.step.path[2])
|
|
|
|
return {
|
|
|
|
...data,
|
|
|
|
field: props.step.path[2],
|
2025-04-01 16:15:20 +02:00
|
|
|
oldVal: formatMm(props.step.old, props.imperial),
|
|
|
|
newVal: formatMm(props.step.new, props.imperial),
|
2024-09-15 15:29:30 +02:00
|
|
|
}
|
|
|
|
let count = 0
|
|
|
|
for (const m of Object.keys(props.step.new)) {
|
|
|
|
if (props.step.new[m] !== props.step.old?.[m]) count++
|
|
|
|
}
|
2025-04-01 16:15:20 +02:00
|
|
|
return { ...data, msg: `${count} measurements updated` }
|
2024-09-15 15:29:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* 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
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function initialEditorState(preload = {}, config) {
|
2024-09-15 15:29:30 +02:00
|
|
|
/*
|
|
|
|
* Create initial state object
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
const initial = { ...config.initialState, ...preload }
|
2024-09-15 15:29:30 +02:00
|
|
|
|
|
|
|
/*
|
|
|
|
* FIXME: Add preload support, from URL or other sources, rather than just passing in an object
|
|
|
|
*/
|
|
|
|
|
|
|
|
return initial
|
|
|
|
}
|
2025-04-01 16:15:20 +02:00
|
|
|
|
2025-05-10 15:47:00 +02:00
|
|
|
/*
|
2024-09-15 15:29:30 +02:00
|
|
|
* 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
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function menuRoundPct(num, factor) {
|
2024-09-15 15:29:30 +02:00
|
|
|
// 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
|
2025-04-01 16:15:20 +02:00
|
|
|
const useVal = allowFractions ? fractionToDecimal(parsedVal) : parsedVal
|
2024-09-15 15:29:30 +02:00
|
|
|
|
|
|
|
// 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?
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function menuValueWasChanged(current, config) {
|
2024-09-15 15:29:30 +02:00
|
|
|
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
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function objUpdate(obj = {}, path, val = '__UNSET__') {
|
2024-09-15 15:29:30 +02:00
|
|
|
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
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function undoableObjUpdate(name, obj = {}, path, val = '__UNSET__', setEphemeralState) {
|
2024-09-15 15:29:30 +02:00
|
|
|
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,
|
2025-04-01 16:15:20 +02:00
|
|
|
restore: cloneObject(obj),
|
2024-09-15 15:29:30 +02:00
|
|
|
},
|
|
|
|
...cur.undos,
|
|
|
|
],
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2025-04-01 16:15:20 +02:00
|
|
|
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)
|
2024-09-15 15:29:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* 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
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function addUndoStep(undo, restore, setEphemeralState) {
|
2024-09-15 15:29:30 +02:00
|
|
|
setEphemeralState((cur) => {
|
|
|
|
if (!Array.isArray(cur.undos)) cur.undos = []
|
|
|
|
return {
|
|
|
|
...cur,
|
2025-04-01 16:15:20 +02:00
|
|
|
undos: [{ time: Date.now(), ...undo, restore: cloneObject(restore) }, ...cur.undos],
|
2024-09-15 15:29:30 +02:00
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Helper method to clone an object
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function cloneObject(obj) {
|
2024-09-15 15:29:30 +02:00
|
|
|
return JSON.parse(JSON.stringify(obj))
|
|
|
|
}
|
2025-04-01 16:15:20 +02:00
|
|
|
|
2024-09-15 15:29:30 +02:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function statePrefixPath(prefix, path) {
|
2024-09-15 15:29:30 +02:00
|
|
|
if (Array.isArray(path)) return [prefix, ...path]
|
|
|
|
else return [prefix, ...path.split('.')]
|
|
|
|
}
|
2025-04-01 16:15:20 +02:00
|
|
|
|
2024-09-15 15:29:30 +02:00
|
|
|
/*
|
|
|
|
* This creates the helper object for state updates
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function stateUpdateFactory(setState, setEphemeralState, config) {
|
2024-09-15 15:29:30 +02:00
|
|
|
return {
|
|
|
|
/*
|
|
|
|
* This allows raw access to the entire state object
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
state: (path, val) => setState((cur) => objUpdate({ ...cur }, path, val)),
|
2024-09-15 15:29:30 +02:00
|
|
|
/*
|
|
|
|
* 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) =>
|
2025-04-01 16:15:20 +02:00
|
|
|
undoableObjUpdate(
|
2024-09-15 15:29:30 +02:00
|
|
|
'settings',
|
|
|
|
{ ...cur },
|
2025-04-01 16:15:20 +02:00
|
|
|
statePrefixPath('settings', path),
|
2024-09-15 15:29:30 +02:00
|
|
|
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)
|
2025-04-01 16:15:20 +02:00
|
|
|
const restore = cloneObject(cur)
|
2024-09-15 15:29:30 +02:00
|
|
|
// This requires 3 changes
|
|
|
|
const update = cur.settings.sabool
|
|
|
|
? [
|
|
|
|
['sabool', 0],
|
|
|
|
['sa', 0],
|
|
|
|
['samm', sa],
|
|
|
|
]
|
|
|
|
: [
|
|
|
|
['sabool', 1],
|
|
|
|
['sa', sa],
|
|
|
|
['samm', sa],
|
|
|
|
]
|
2025-04-01 16:15:20 +02:00
|
|
|
for (const [key, val] of update) objUpdate(cur, `settings.${key}`, val)
|
2024-09-15 15:29:30 +02:00
|
|
|
// Which we'll group as 1 undo action
|
2025-04-01 16:15:20 +02:00
|
|
|
addUndoStep(
|
2024-09-15 15:29:30 +02:00
|
|
|
{
|
|
|
|
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) =>
|
2025-04-01 16:15:20 +02:00
|
|
|
undoableObjUpdate('ui', { ...cur }, statePrefixPath('ui', path), val, setEphemeralState)
|
2024-09-15 15:29:30 +02:00
|
|
|
),
|
|
|
|
/*
|
|
|
|
* These only hold a string, so we only take a value
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
design: (val) => setState((cur) => objUpdate({ ...cur }, 'design', val)),
|
2024-09-15 15:29:30 +02:00
|
|
|
view: (val) => {
|
|
|
|
// Only take valid view names
|
2025-04-01 16:15:20 +02:00
|
|
|
if (!config.views.includes(val)) return console.log('not a valid view:', val)
|
2024-09-15 15:29:30 +02:00
|
|
|
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) => {
|
2025-04-01 16:15:20 +02:00
|
|
|
if (Array.isArray(eph.views) && config.views.includes(eph.views[1])) {
|
2024-09-15 15:29:30 +02:00
|
|
|
// 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
|
|
|
|
})
|
|
|
|
},
|
2025-04-01 16:15:20 +02:00
|
|
|
// Pattern ID (pid)
|
|
|
|
pid: (val) => setState((cur) => ({ ...cur, pid: val })),
|
|
|
|
ux: (val) => setState((cur) => objUpdate({ ...cur }, 'ux', val)),
|
2024-09-15 15:29:30 +02:00
|
|
|
clearPattern: () =>
|
|
|
|
setState((cur) => {
|
|
|
|
const newState = { ...cur }
|
2025-04-01 16:15:20 +02:00
|
|
|
objUpdate(newState, 'settings', {
|
2024-09-15 15:29:30 +02:00
|
|
|
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
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
objUpdate(newState, 'ui', { ...newState.ui, renderer: 'react' })
|
2024-09-15 15:29:30 +02:00
|
|
|
return newState
|
|
|
|
}),
|
2025-04-01 16:15:20 +02:00
|
|
|
clearAll: () => setState(config.initialState),
|
2024-09-15 15:29:30 +02:00
|
|
|
/*
|
|
|
|
* 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] = {
|
2025-04-01 16:15:20 +02:00
|
|
|
msg: randomLoadingMessage(),
|
|
|
|
icon: 'spinner',
|
2024-09-15 15:29:30 +02:00
|
|
|
...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()
|
2025-04-01 16:15:20 +02:00
|
|
|
newState.loading[id] = { ...conf, id, fadeTimer: config.notifyTimeout }
|
2024-09-15 15:29:30 +02:00
|
|
|
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,
|
2025-04-01 16:15:20 +02:00
|
|
|
fadeTimer: config.notifyTimeout,
|
2024-09-15 15:29:30 +02:00
|
|
|
}
|
|
|
|
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,
|
2025-04-01 16:15:20 +02:00
|
|
|
fadeTimer: config.notifyTimeout,
|
2024-09-15 15:29:30 +02:00
|
|
|
}
|
|
|
|
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
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function cloudImageUrl({ id = 'default-avatar', variant = 'public' }) {
|
2024-09-15 15:29:30 +02:00
|
|
|
/*
|
|
|
|
* Return something default so that people will actually change it
|
|
|
|
*/
|
2025-05-30 11:29:55 +02:00
|
|
|
if (!id || id === 'default-avatar') return defaultConfig.cloudImageDflt
|
2024-09-15 15:29:30 +02:00
|
|
|
|
|
|
|
/*
|
|
|
|
* If the variant is invalid, set it to the smallest thumbnail so
|
|
|
|
* people don't load enourmous images by accident
|
|
|
|
*/
|
2025-05-30 11:29:55 +02:00
|
|
|
if (!defaultConfig.cloudImageVariants.includes(variant)) variant = 'sq100'
|
2024-09-15 15:29:30 +02:00
|
|
|
|
2025-05-30 11:29:55 +02:00
|
|
|
return `${defaultConfig.cloudImageUrl}${id}/${variant}`
|
2024-09-15 15:29:30 +02:00
|
|
|
}
|
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
}
|
2025-04-01 16:15:20 +02:00
|
|
|
|
2024-09-15 15:29:30 +02:00
|
|
|
/*
|
|
|
|
* A translation fallback method in case none is passed in
|
|
|
|
*
|
|
|
|
* @param {string} key - The input
|
|
|
|
* @return {string} key - The input is returned
|
|
|
|
*/
|
2025-04-01 16:15:20 +02:00
|
|
|
export function t(key) {
|
2024-09-15 15:29:30 +02:00
|
|
|
return Array.isArray(key) ? key[0] : key
|
|
|
|
}
|
2025-04-01 16:15:20 +02:00
|
|
|
|
|
|
|
export function settingsValueIsCustom(val, dflt) {
|
2024-09-15 15:29:30 +02:00
|
|
|
return typeof val === 'undefined' || val === '__UNSET__' || val === dflt ? false : true
|
|
|
|
}
|
|
|
|
|
2025-04-01 16:15:20 +02:00
|
|
|
export function settingsValueCustomOrDefault(val, dflt) {
|
2024-09-15 15:29:30 +02:00
|
|
|
return typeof val === 'undefined' || val === '__UNSET__' || val === dflt ? dflt : val
|
|
|
|
}
|