1
0
Fork 0

feat: New pattern editor (#7080)

This is not exactly finished or ready for prime-time, but I do feel that leaving it in a feature branch longer will only cause the spread between the `develop` branch and this to grow.

Given that I've taken care to not break the current site, I plan to merge this and then keep polishing things.

Some views are not implemented yet, and overall there's a need to polish to limit the amount of re-renders and improve performance.
This commit is contained in:
Joost De Cock 2024-09-15 15:29:30 +02:00 committed by GitHub
parent ea0746313e
commit adefbe7d18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 9223 additions and 76 deletions

View file

@ -23,7 +23,12 @@
".": {
"internal": "./src/index.mjs",
"default": "./dist/index.mjs"
}
},
"./pattern": "./src/pattern/index.mjs",
"./xray": "./src/pattern-xray/index.mjs",
"./editor": "./src/editor/index.mjs",
"./icons": "./src/editor/swizzle/components/icons.mjs"
},
"scripts": {
"build": "node build.mjs",
@ -40,10 +45,15 @@
"wbuild:all": "yarn wbuild"
},
"peerDependencies": {
"react": ">=14"
"react": "^18.2.0"
},
"dependencies": {
"html-react-parser": "^5.0.7"
"axios": "1.6.8",
"html-react-parser": "^5.0.7",
"nuqs": "^1.17.6",
"react-markdown": "^9.0.1",
"use-local-storage-state": "19.1.0",
"use-session-storage-state": "^19.0.0"
},
"devDependencies": {},
"files": [

View file

@ -0,0 +1,137 @@
// Hooks
import { useState } from 'react'
/**
* The editor view wrapper component
*
* Figures out what view to load initially,
* and handles state for the pattern, inclding the view
*
* @param (object) props - All the props
* @param {object} props.designs - An object holding all designs
* @param {object} props.preload - Object holding state to preload
* @param {object} props.locale - The locale (language) code
* @param {object} props.design - A design name to force the editor to use this design
* @param {object} props.Swizzled - An object holding swizzled code
*/
export const ViewWrapper = ({ designs = {}, preload = {}, design = false, Swizzled }) => {
/*
* Ephemeral state will not be stored in the state backend
* It is used for things like loading state and so on
*/
const [ephemeralState, setEphemeralState] = useState({})
// Editor state
const allState = Swizzled.hooks.useEditorState(
Swizzled.methods.initialEditorState(preload),
setEphemeralState
)
const state = allState[0]
const update = allState[2]
// Don't bother before state is initialized
if (!state) return <Swizzled.components.TemporaryLoader />
// Figure out what view to load
const [View, extraProps] = viewfinder({ design, designs, preload, state, Swizzled })
/*
* Pass this down to allow disabling features that require measurements
*/
const { missingMeasurements = [] } = extraProps
/*
* Almost all editor state has a default settings, and when that is selected
* we just unset that value in the state. This way, state holds only what is
* customized, and it makes it a lot easier to see how a pattern was edited.
* The big exception is the 'ui.ux' setting. If it is unset, a bunch of
* components will not function properly. We could guard against this by passing
* the default to all of these components, but instead, we just check that state
* is undefined, and if so pass down the default ux value here.
* This way, should more of these exceptions get added over time, we can use
* the same centralized solution.
*/
const passDownState =
state.ui.ux === undefined
? {
...state,
ui: { ...state.ui, ux: Swizzled.config.defaultUx },
_: { ...ephemeralState, missingMeasurements },
}
: { ...state, _: { ...ephemeralState, missingMeasurements } }
return (
<div className="flex flex-row items-top">
{Swizzled.config.withAside ? (
<Swizzled.components.AsideViewMenu update={update} state={passDownState} />
) : null}
<div
className={
state.ui.kiosk
? 'md:z-30 md:w-screen md:h-screen md:fixed md:top-0 md:left-0 md:bg-base-100'
: 'grow w-full'
}
>
<Swizzled.components.LoadingStatus state={passDownState} update={update} />
<View {...extraProps} {...{ update, designs }} state={passDownState} />
</div>
</div>
)
}
/**
* Helper method to figure out what view to load
* based on the props passed in, and destructure
* the props we need for it.
*
* @param (object) props - All the props
* @param {object} props.design - The (name of the) current design
* @param {object} props.designs - An object holding all designs
* @param (object) props.state - React state passed down from the wrapper view
*/
const viewfinder = ({ design, designs, state, Swizzled }) => {
/*
* Grab Design from props or state and make them extra props
*/
if (!design && state?.design) design = state.design
const Design = designs[design] || false
const extraProps = { design, Design }
/*
* If no design is set, return the designs view
*/
if (!designs[design]) return [getViewComponent('designs', Swizzled), extraProps]
/*
* If we have a design, do we have the measurements?
*/
const [measurementsOk, missingMeasurements] = Swizzled.methods.hasRequiredMeasurements(
designs[design],
state.settings?.measurements
)
if (missingMeasurements) extraProps.missingMeasurements = missingMeasurements
/*
* Allow all views that do not require measurements before
* we force the user to the measurements view
*/
if (state.view && Swizzled.config.measurementsFreeViews.includes(state.view)) {
const view = getViewComponent(state.view, Swizzled)
if (view) return [view, extraProps]
}
if (!measurementsOk) return [getViewComponent('measurements', Swizzled), extraProps]
/*
* If a view is set, return that
*/
const view = getViewComponent(state.view, Swizzled)
if (view) return [view, extraProps]
/*
* If no obvious view was found, return the view picker
*/
return [getViewComponent('picker', Swizzled), extraProps]
}
const getViewComponent = (view = false, Swizzled) =>
view ? Swizzled.components[Swizzled.config.viewComponents[view]] : false

View file

@ -0,0 +1,290 @@
# Popout component
comment: Comment
note: Note
tip: Tip
warning: Warning
fixme: FIXME
link: Link
related: Related
# Designs view
chooseADesign: Choose a Design
# Mesurements view
measurements: Measurements
measurementsAreOk: We have all required measurements to draft this pattern.
editMeasurements: Edit Measurements
editMeasurementsDesc: You can manually set or override measurements below.
requiredMeasurements: Required Measurements
optionalMeasurements: Optional Measurements
missingMeasurements: Missing Measurements
missingMeasurementsInfo: To generate this pattern, we need the following additional measurements
missingMeasurementsNotify: To generate this pattern, we need some additional measurements
# Measurements sets
noOwnSets: You do not have any of your own measurements sets (yet)
pleaseMtm: Because our patterns are bespoke, we strongly suggest you take accurate measurements.
noOwnSetsMsg: You can store your measurements as a measurements set, after which you can generate as many patterns as you want for them.
chooseFromOwnSets: Choose one of your own measurements sets
chooseFromOwnSetsDesc: Pick any of your own measurements sets that have all required measurements to generate this pattern.
newSet: Create a new measurements set
someSetsLacking: Some of your measurements sets lack the measurements required to generate this pattern
chooseFromBookmarkedSets: Choose one of the measurements sets you've bookmarked
chooseFromBookmarkedSetsDesc: If you've bookmarked any measurements sets, you can select from those too.
chooseFromCuratedSets: Choose one of FreeSewing's curated measurements sets
chooseFromCuratedSetsDesc: If you're just looking to try out our platform, you can select from our list of curated measurements sets.
# View wrapper
requiredPropsMissing.t: Required props are missing
requiredPropsMissing.d: The FreeSewing pattern editor needs to be initialized properly. It currently lacks some props to be able to bootstrap.
# View picker
chooseAnActivity: Choose an activity
chooseAnotherActivity: Choose a different activity
view.draft.t: Draft Pattern
view.draft.d: Choose this if you are not certain what to pick
view.measurements.t: Pattern Measurements
view.measurements.d: Update or load measurements to generate a pattern for
view.test.t: Test Design
view.test.d: See how different options or changes in measurements influence the design
view.timing.t: Time Design
view.timing.d: Shows detailed timing of the pattern being drafted, allowing you to find bottlenecks in performance
view.printLayout.t: Print Layout
view.printLayout.d: Organize your pattern parts to minimize paper use
view.save.t: Save pattern as...
view.save.d: Save the changes to this pattern in your account, or save it as a new pattern
view.export.t: Export Pattern
view.export.d: Export this pattern into a variety of formats
view.editSettings.t: Edit settings by hand
view.editSettings.d: Throw caution to the wind, and hand-edit the pattern's settings
view.logs.t: Pattern Logs
view.logs.d: Show the logs generated by the pattern, useful to troubleshoot problems
view.inspect.t: Pattern inspector
view.inspect.d: Load the pattern in the inspector, giving you in-depth info about a pattern's components
view.docs.t: Documentation
view.docs.d: More information and links to documentation
view.designs.t: Choose a different Design
view.designs.d: "Current design: {- design }"
view.picker.t: Choose a different view
view.undos.t: Undo History
view.undos.d: Time-travel through your recent pattern changes
showAdvancedOptions: Show advanced options
hideAdvancedOptions: Hide advanced options
views.t: Views
views.d: Choose between the main views of the pattern editor
measurementsFreeViewsOnly.t: We are only showing activities that do not require measurements
measurementsFreeViewsOnly.d: Once we have all measurements required to generate a pattern, you will have more choices here.
# menus
youAreUsingTheDefaultValue: You are using the default value
youAreUsingACustomValue: You are using a custom value
designOptions.t: Design Options
designOptions.d: These options are specific to this design. You can use them to customize your pattern in a variety of ways.
fit.t: Fit
style.t: Style
advanced.t: Advanced
coreSettings.t: Core Settings
coreSettings.d: These settings are not specific to the design, but instead allow you to customize various parameters of the FreeSewing core library, which generates the design for you.
paperless.t: Paperless
paperless.d: Trees are awesome, and taping together sewing patterns is not much fun. Try our paperless mode to avoid the need to print out your pattern altogether.
samm.t: Seam Allowance Size
samm.d: Controls the amount of seam allowance used in your pattern
sabool.t: Include Seam Allowance
sabool.d: Controls whether or not to include seam allowance in your pattern
complete.t: Details
complete.d: Controls how detailed the pattern is; Either a complete pattern with all details, or a basic outline of the pattern parts
expand.t: Expand
expand.d: Controls efforts to save paper. Disable this to expand all pattern parts at the cost of using more space.
only.t: Included Parts
only.d: Use this to control exactly which pattern parts will be included in your pattern
units.t: Units
units.d: This setting determines how unit are displayed on your pattern
margin.t: Margin
margin.d: Controls the margin around pattern parts
scale.t: Scale
scale.d: Controls the overall line width, font size, and other elements that do not scale with the pattern's measurements
yes: Yes
no: No
completeYes.t: Generate a complete pattern
completeYes.d: This will generate a complete pattern with all notations, lines, markings. Use this if you are not certain what to choose.
completeNo.t: Generate a pattern outline
completeNo.d: Only generate the outline of the pattern parts. Use this if you are looking to use a laser cutter or have other specific needs.
expandYes.t: Expand all pattern parts
expandYes.d: This will generate a pattern where all pattern parts are drawn to their full size, even if they are simple rectangles.
expandNo.t: Keep patterns parts compact where possible
expandNo.d: This will draw a more dense representation of the pattern which includes all info without using up too much space & paper.
saNo.t: Do not include seam allowance
saNo.d: This generates a pattern which does not include any seam allowance. The size of the seam allowance does not matter as no seam allowance will be included.
saYes.t: Include seam allowance
saYes.d: This generates a pattern that will include seam allowance. The size of the seam allowance is set individually.
paperlessNo.t: Generate a regular pattern
paperlessNo.d: This will generate a regular pattern, which you can then print out.
paperlessYes.t: Generate a paperless pattern
paperlessYes.d: This generates a pattern with dimensions and a grid, which allows you to transfer it on fabric or another medium without the need to print out the pattern.
metric: Metric
uiPreferences.t: UI Preferences
uiPreferences.d: These preferences control the UI (User Interface) of the pattern editor
renderer.t: Render Engine
renderer.d: Controls how the pattern is rendered (drawn) on the screen
renderWithReact.t: Render with FreeSewing's React components
renderWithReact.d: Render as SVG through our React components. Allows interactivity and is optimized for screen. Use this if you are not sure what to pick.
renderWithCore.t: Render with FreeSewing's Core library
renderWithCore.d: Render directly to SVG from Core. Allows no interactivity and is optimized for print. Use this if you want to know what it will look like when exported.
kiosk.t: Kiosk Mode
kiosk.d: Controls how the pattern editor is embedded in the web page.
aside.t: Aside Menu
aside.d: Controls whether or not to show menus on the side of larger screens
withAside.t: Also show menus on the side
withAside.d: Shows menus both on the side of the screen, as well as the drop-downs in the header (this only applies to larger screens)
noAside.t: Only show menus in the header
noAside.d: Only shows the drop-down variant of the menus, making more room for your pattern
rotate.t: Rotate pattern
rotate.d: Allows you to rotate your pattern 90 degrees to better fit your screen
rotateNo.t: Do not rotate pattern
rotateNo.d: Show the pattern as it is
rotateYes.t: Rotate pattern 90 degrees
rotateYes.d: Rotate the pattern 90 degrees counter clockwise
websiteMode.t: Use inline mode
websiteMode.d: Embeds the pattern editor in the natural flow of the web page.
kioskMode.t: Use kiosk mode
kioskMode.d: Breaks out the pattern editor to fill the entire page.
ux.t: User Experience
ux.d: Which user experience do you prefer? From keep it simple, to give me all the powers.
inspect.t: Inspect
inspect.d: Enabling this will allow you to drill down into the pattern, and pull up information about its various parts, paths, and points.
inspectNo.t: Disable the inspector
inspectNo.d: This is the default, the pattern inspector is disabled and the pattern is displayed as usual.
inspectYes.t: Enable the inspector
inspectYes.d: With the pattern inspector enabled and the React rendering engine selected, we will add interactivity to the pattern to allow you to inspect the various elements that make up the pattern.
draft: Draft
test: Test
print: Print layout
cut: Cut Layout
save: Save
export: Export
edit: Edit
draft.t: Draft your pattern
draft.d: Launches FreeSewing flagship pattern editor, where you can tweak your pattern to your heart's desire
test.t: Test your pattern
test.d: See how your pattern adapts to changes in options, or measurements
print.t: Print Layout
print.d: Allows you to arrange your pattern pieces so you can printing your pattern on as little pages as possible
cut.t: Cutting layout
cut.d: Allows you to arrange your pattern pieces so you can determine exactly how much fabric you need to make it.
save.t: Save your pattern
save.d: Save the current pattern to your FreeSewing account
export.t: Export your pattern
export.d: Allows you to export this pattern to a variety of formats
logs.t: Pattern logs
enterCustomValue: Enter a custom value
# ux
ux1.t: Keep it as simple as possible
ux1.d: Hides all but the most essential features.
ux2.t: Keep it simple, but not too simple
ux2.d: Hides the majority of features.
ux3.t: Balance simplicity with power
ux3.d: Reveals the majority of features, but not all.
ux4.t: Give me all powers, but keep me safe
ux4.d: Reveals all features, keeps handrails and safety checks.
ux5.t: Get out of my way
ux5.d: Reveals all features, removes all handrails and safety checks.
# Tooltips
tt.changeEditorView: Change to a different view
tt.toggleSa: Turns Seam Allowance on or off (see Core Settings)
tt.togglePaperless: Turns Paperless on or off (see Core Settings)
tt.toggleComplete: Turns Details on or off (see Core Settings)
tt.toggleExpand: Turns Expand on or off (see Core Settings)
tt.toggleUnits: Switches Units between metric and imperial (see Core Settings)
tt.changeUx: Changes your UX setting (see UI Preferences)
tt.toggleAside: Turn the Aside Menu on or off (see UI Preferences)
tt.toggleKiosk: Turns Kiosk Mode on or off (see UI Preferences)
tt.toggleRotate: Turns Rotate Pattern on or off (see UI Preferences)
tt.toggleRenderer: Switches the Render Engine between React and SVG (see UI Preferences)
tt.exportPattern: Export pattern
tt.savePattern: Save pattern
tt.savePatternAs: Save pattern as...
tt.undo: Undo most recent change
tt.undoAll: Undo all changes since the last save point
tt.resetDesign: Reset all settings, but keep the design and measurements
tt.resetAll: Reset the editor completely
# flags
apply: Apply
decrease: Decrease
disable: Disable
dismiss: Dismiss
expandIsOff.t: This design saves space (and trees) because expand is disabled
expandIsOff.d: "Because the **expand** core setting is currently disabled, some parts are not fully drawn or not shown at all. Typically, these are simple rectangles that only take up space, or things that can be cut on the fold. \n\nTo expand all pattern parts to their full size, enable the expand setting."
expandIsOn.t: This design can save space (and trees)
expandIsOn.d: "Because the **expand** core setting is currently enabled, all parts are fully drawn. You can display this design in a more compact way by disabling the **expand** setting. \n\nDoing so will mean that some parts are not fully drawn or not shown at all. Typically, these are simple rectangles that only take up space, or things that can be cut on the fold."
enable: Enable
flags: Flags
flagMenu.t: Flags
flagMenuOne.d: A specific issue about your current pattern needs your attention.
flagMenuMany.d: Some issues about your current pattern need your attention.
hide: Hide
increase: Increase
show: Show
saIncluded: (This includes seam allowance)
saExcluded: (This does not include seam allowance)
saUnused: (This part does not require any seam allowance)
partHiddenByExpand: This part is not shown because the **expand** core setting is currently disabled. Enable it to show this pattern part.
# Auth
authRequired: Authentication required
membersOnly: This functionality requires a FreeSewing account.
signUp: Sign Up
signIn: Sign In
statusUnknown: Account status warning
statusUnknownMsg: Your account status prohibits us from processing your data. Please contact support.
consentLacking: Consent lacking
consentLackingMsg: We do not have your consent for processing your data. Without consent, we have no legal basis to process your data.
accountProhibited: Your account has been disabled
accountProhibitedMsg: Your account has been administratively disabled.
accountDisabled: Account disabled
accountDisabledMsg: You cannot re-enable a disabled account. You need to contact support to address this.
accountInactive: Your account is inactive
accountInactiveMsg: You must activate your account via the signup link we sent you.
signupAgain: If you cannot find the link, you can receive a new one by signing up again.
cannotUse: A disabled account cannot be used.
contactSupport: Contact support
reviewConsent: Review your consent
roleLacking: You lack the required role to access this content
roleLackingMsg: This content requires the <b>{ requiredRole }</b> role. Your role is <b>{ role }</b> which does not grant you access to this content.
# save pattern
bookmarkPattern: Bookmark pattern
savePattern: Save pattern
saveAsNewPattern: Save as a New Pattern
savePatternAs: Save pattern as...
savePatternAsHellip: Save pattern as...
patternBookmarkCreated: Pattern bookmark created
see: See
addNotes: Add notes
addSettingsToNotes: Add settings to notes
exporting: Exporting
exportAsData: Export as data
exportForEditing: Export for editing
exportForPrinting: Export for printing
exportPattern-txt: Export a PDF suitable for your printer, or download this pattern in a variety of formats
exportPattern: Export pattern
settings: Settings
patternTitle: Pattern Title
patternNotes: Pattern Notes
toAccessPatternsGoTo: To access your patterns, go to
genericLoadingMessage: Hang tight, we're working on it...
patternSavedAs: Pattern saved as
cancel: Cancel
# undo history
secondsAgo: seconds ago
minutesAgo: minutes ago
hoursAgo: hours ago
undos.unknown.t: Unknown Change
defaultRestored: Cleared (default restored)
includeAllParts: Include all parts
allFirstLetter: A
undo: Undo
xMeasurementsChanged: "{ count } Measurements changed"

View file

@ -0,0 +1,89 @@
import { useState, useEffect } from 'react'
import { swizzleConfig } from './swizzle/config.mjs'
import { swizzleComponents } from './swizzle/components/index.mjs'
import { swizzleHooks } from './swizzle/hooks/index.mjs'
import { swizzleMethods } from './swizzle/methods/index.mjs'
import { ViewWrapper } from './components/view-wrapper.mjs'
// This is an exception as we need to show something before Swizzled components are ready
import { TemporaryLoader as UnswizzledTemporaryLoader } from './swizzle/components/loaders.mjs'
/*
* Namespaces used by the pattern editor
*/
export const ns = ['pe', 'measurements']
/**
* PatternEditor is the high-level FreeSewing component
* that provides the entire pattern editing environment
*
* @param {object} props.design = The name of the design we are editing
* @param {object} props.designs = An object holding the designs code
* @param {object} props.components = An object holding components to swizzle
* @param {object} props.hooks = An object holding hooks to swizzle
* @param {object} props.methods = An object holding methods to swizzle
* @param {object} props.config = An object holding the editor config to swizzle
* @param {object} props.locale = The locale (language) code
* @param {object} props.preload = Any state to preload
*
*/
export const PatternEditor = (props) => {
const [swizzled, setSwizzled] = useState(false)
useEffect(() => {
if (!swizzled) {
const merged = {
config: swizzleConfig(props.config),
}
merged.methods = swizzleMethods(props.methods, merged)
merged.components = swizzleComponents(props.components, merged)
merged.hooks = swizzleHooks(props.hooks, merged)
setSwizzled(merged)
}
}, [swizzled, props.components, props.config, props.hooks, props.methods])
if (!swizzled?.hooks) return <UnswizzledTemporaryLoader />
/*
* First of all, make sure we have all the required props
*/
const lackingProps = lackingPropsCheck(props)
if (lackingProps !== false) return <LackingPropsError error={lackingProps} />
/*
* Extract props we care about
*/
const { designs = {}, locale = 'en', preload } = props
/*
* Now return the view wrapper and pass it the relevant props and the swizzled props
*/
return <ViewWrapper {...{ designs, locale, preload }} Swizzled={swizzled} />
}
/**
* Helper function to verify that all props that are required to
* run the editor are present.
*
* Note that these errors are not translation, because they are
* not intended for end-users, but rather for developers.
*
* @param {object} props - The props passed to the PatternEditor component
* @return {bool} result - Either true or false depending on required props being present
*/
const lackingPropsCheck = (props) => {
if (typeof props.designs !== 'object')
return "Please pass a 'designs' prop with the designs supported by this editor"
if (Object.keys(props.designs).length < 1) return "The 'designs' prop does not hold any designs"
return false
}
/**
* A component to inform the user that the editor cannot be started
* because there are missing required props
*/
const LackingPropsError = ({ error }) => (
<div className="w-full p-0 text-center py-24">
<h2>Unable to initialize pattern editor</h2>
<p>{error}</p>
</div>
)

View file

@ -0,0 +1,23 @@
# Popout component
comment: Opmerking
note: Notitie
tip: Tip
warning: Waarschuwing
fixme: FIXME
link: Link
related: Gerelateerd
# Mesurements view
measurements: Maten
measurementsAreOk: We hebben alle benodigde maten om dit patroon te tekenen.
editMeasurements: Maten Aanpassen
editMeasurementsDesc: Hier kan je manueel de maten aanpassen.
requiredMeasurements: Vereiste Maten
optionalMeasurements: Optionele Maten
# Designs view
pickADesign: Kies een ontwerp
# View wrapper
requiredPropsMissing.t: Vereiste props ontbreken
requiredPropsMissing.d: De FreeSewing patroon editor moet correct geinitialiseerd worden. Momenteel ontbreken een aantal props die noodzakelijk zijn om de editor te starten.

View file

@ -0,0 +1,26 @@
# List of props that can be passed to the pattern editor
| Prop | Default | Description |
| ---- | ------- | ----------- |
| `design` | `undefined` | Name of the current design (key in the `objects` prop).<br>Note that this will set the initial state, but it can be changed by the user. |
| `designs` | `{}` |Object holding all designs that are available. |
| `locale` | `en` | Language code |
| `imperial`| `false` | Whether to use imperial units as the default, or not |
| `components` | `{}` | Object holding swizzled components |
| `hooks` | `{}` | Object holding swizzled hooks |
| `methods` | `{}` | Object holding swizzled methods |
## Defaults object
```mjs
{
locale: 'en',
imperial: 'false',
ui: {
renderer: 'react',
kiosk: false,
}
}
```

View file

@ -0,0 +1,64 @@
import { useState } from 'react'
/*
* DaisyUI's accordion seems rather unreliable.
* So instead, we handle this in React state
*/
const getProps = (isActive) => ({
className: `p-2 px-4 rounded-lg bg-transparent shadow
w-full mt-2 py-4 h-auto content-start text-left bg-opacity-20
${isActive ? 'hover:bg-transparent' : 'hover:bg-secondary hover:bg-opacity-10'}`,
})
const getSubProps = (isActive) => ({
className: ` p-2 px-4 rounded bg-transparent w-full mt-2 py-4 h-auto
content-start bg-secondary text-left bg-opacity-20
${
isActive
? 'bg-opacity-100 hover:bg-transparent shadow'
: 'hover:bg-opacity-10 hover:bg-secondary '
}`,
})
const components = {
button: (props) => <button {...props}>{props.children}</button>,
div: (props) => <div {...props}>{props.children}</div>,
}
export const BaseAccordion = ({
items, // Items in the accordion
act, // Allows one to preset the active (opened) entry
propsGetter = getProps, // Method to get the relevant props
component = 'button',
}) => {
const [active, setActive] = useState(act)
const Component = components[component]
return (
<nav>
{items
.filter((item) => item[0])
.map((item, i) =>
active === item[2] ? (
<div key={i} {...propsGetter(true)}>
<Component onClick={setActive} className="w-full hover:cursor-pointer">
{item[0]}
</Component>
{item[1]}
</div>
) : (
<Component
key={i}
{...propsGetter(active === item[2])}
onClick={() => setActive(item[2])}
>
{item[0]}
</Component>
)
)}
</nav>
)
}
export const SubAccordion = (props) => <BaseAccordion {...props} propsGetter={getSubProps} />
export const Accordion = (props) => <BaseAccordion {...props} propsGetter={getProps} />

View file

@ -0,0 +1,122 @@
import { useState } from 'react'
export const AsideViewMenuButton = ({
href,
label,
children,
onClick = false,
active = false,
extraClasses = 'lg:hover:bg-secondary lg:hover:text-secondary-content',
Swizzled,
}) => {
const className = `w-full flex flex-row items-center px-4 py-2 ${extraClasses} ${
active
? 'font-bold lg:font-normal bg-secondary bg-opacity-10 lg:bg-secondary lg:text-secondary-content lg:bg-opacity-50'
: 'lg:bg-neutral lg:text-neutral-content'
}`
const span = <span className="block grow text-left">{label}</span>
return onClick ? (
<button {...{ onClick, className }} title={label}>
{span}
{children}
</button>
) : (
<Swizzled.components.Link {...{ href, className }} title={label}>
{span}
{children}
</Swizzled.components.Link>
)
}
export const ViewTypeIcon = ({ Swizzled, view, className = 'h-6 w-6 grow-0' }) => {
const Icon = Swizzled.components[`View${Swizzled.methods.capitalize(view)}Icon`]
if (!Icon) return <Swizzled.components.OptionsIcon />
return <Icon className={className} />
}
export const AsideViewMenuSpacer = () => (
<hr className="my-1 w-full opacity-20 font-bold border-t-2" />
)
export const AsideViewMenuIcons = ({ state, update, setDense, dense, Swizzled }) => {
const { t } = Swizzled.methods
const iconSize = 'h-6 w-6 grow-0'
const output = [
<Swizzled.components.AsideViewMenuButton
key={1}
onClick={() => setDense(!dense)}
label={
dense ? (
''
) : (
<b>
<em className="pl-4 opacity-60">Editor Views</em>
</b>
)
}
extraClasses="hidden lg:flex text-accent bg-neutral hover:bg-accent hover:text-neutral-content"
>
{dense ? (
<Swizzled.components.RightIcon
className={`${iconSize} group-hover:animate-[bounceright_1s_infinite] animate-[bounceright_1s_5]`}
stroke={4}
/>
) : (
<Swizzled.components.LeftIcon className={`${iconSize} animate-bounce-right`} stroke={4} />
)}
</Swizzled.components.AsideViewMenuButton>,
]
let i = 1
for (const view of [
'spacer',
...Swizzled.config.mainViews,
'spacer',
...Swizzled.config.extraViews,
'spacerOver3',
...Swizzled.config.devViews,
'spacer',
'picker',
]) {
if (view === 'spacer') output.push(<Swizzled.components.AsideViewMenuSpacer key={i} />)
else if (view === 'spacerOver3')
output.push(state.ui.ux > 3 ? <Swizzled.components.AsideViewMenuSpacer key={i} /> : null)
else if (state.ui.ux >= Swizzled.config.uxLevels.views[view])
output.push(
<Swizzled.components.AsideViewMenuButton
key={view}
onClick={() => update.view(view)}
label={t(`pe:view.${view}.t`)}
active={state.view === view}
>
<Swizzled.components.ViewTypeIcon view={view} />
</Swizzled.components.AsideViewMenuButton>
)
i++
}
return output
}
export const AsideViewMenu = ({ Swizzled, update, state }) => {
const [dense, setDense] = useState(true)
return (
<div
className={`w-64 min-h-screen pt-4
bg-neutral
shrink-0 grow-0 self-stretch
transition-all
drop-shadow-xl
${dense ? '-ml-52' : 'ml-0'}`}
>
<aside className="sticky top-4 group">
<div className="flex flex-col items-center w-full">
<Swizzled.components.AsideViewMenuIcons {...{ update, state, setDense, dense }} />
</div>
</aside>
</div>
)
}

View file

@ -0,0 +1,210 @@
import { useState, useEffect } from 'react'
export const AuthMessageWrapper = ({ children }) => (
<div className="m-auto max-w-xl text-center mt-8 p-8">{children}</div>
)
export const ContactSupport = ({ t, Swizzled }) => (
<div className="flex flex-row items-center justify-center gap-4 mt-8">
<Swizzled.components.Link href="/support" className="btn btn-success w-full">
{t('contactSupport')}
</Swizzled.components.Link>
</div>
)
export const AuthRequired = ({ t, banner, Swizzled }) => (
<Swizzled.components.AuthMessageWrapper>
{banner}
<h2>{Swizzled.methods.t('pe:authRequired')}</h2>
<p>{t('pe:membersOnly')}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mt-8">
<Swizzled.components.Link
href="/signup"
className={`${Swizzled.config.classes.horFlex} btn btn-secondary w-full`}
>
<Swizzled.components.PlusIcon />
{t('pe:signUp')}
</Swizzled.components.Link>
<Swizzled.components.Link
href="/signin"
className={`${Swizzled.config.classes.horFlex} btn btn-secondary btn-outline w-full`}
>
<Swizzled.components.LockIcon />
{t('signIn')}
</Swizzled.components.Link>
</div>
</Swizzled.components.AuthMessageWrapper>
)
export const AccountInactive = ({ t, banner, Swizzled }) => (
<Swizzled.components.AuthMessageWrapper>
{banner}
<h1>{t('accountInactive')}</h1>
<p>{t('accountInactiveMsg')}</p>
<p>{t('signupAgain')}</p>
<div className="flex flex-row items-center justify-center gap-4 mt-8">
<Swizzled.components.Link href="/signup" className="btn btn-primary w-full">
{t('signUp')}
</Swizzled.components.Link>
</div>
</Swizzled.components.AuthMessageWrapper>
)
export const AccountDisabled = ({ t, banner, Swizzled }) => (
<Swizzled.components.AuthMessageWrapper>
{banner}
<h1>{t('accountDisabled')}</h1>
<p>{t('accountDisabledMsg')}</p>
<ContactSupport t={t} />
</Swizzled.components.AuthMessageWrapper>
)
export const AccountProhibited = ({ t, banner, Swizzled }) => (
<Swizzled.components.AuthMessageWrapper>
{banner}
<h1>{t('accountProhibited')}</h1>
<p>{t('accountProhibitedMsg')}</p>
<Swizzled.components.ContactSupport t={t} />
</Swizzled.components.AuthMessageWrapper>
)
export const AccountStatusUnknown = ({ t, banner, Swizzled }) => (
<Swizzled.components.AuthMessageWrapper>
{banner}
<h1>{t('statusUnknown')}</h1>
<p>{t('statusUnknownMsg')}</p>
<Swizzled.components.ContactSupport t={t} />
</Swizzled.components.AuthMessageWrapper>
)
export const RoleLacking = ({ t, requiredRole, role, banner, Swizzled }) => (
<Swizzled.components.AuthMessageWrapper>
{banner}
<h1>{t('roleLacking')}</h1>
<p dangerouslySetInnerHTML={{ __html: t('roleLackingMsg', { requiredRole, role }) }} />
<Swizzled.components.ContactSupport t={t} />
</Swizzled.components.AuthMessageWrapper>
)
export const ConsentLacking = ({ banner, Swizzled }) => {
//const { setAccount, setToken, setSeenUser } = Swizzled.hooks.useAccount()
//const backend = Swizzled.hooks.useBackend()
//const updateConsent = async ({ consent1, consent2 }) => {
// let consent = 0
// if (consent1) consent = 1
// if (consent1 && consent2) consent = 2
// if (consent > 0) {
// const result = await backend.updateConsent(consent)
// if (result.success) {
// setToken(result.data.token)
// setAccount(result.data.account)
// setSeenUser(result.data.account.username)
// refresh()
// } else {
// console.log('something went wrong', result)
// refresh()
// }
// }
//}
return (
<Swizzled.components.AuthMessageWrapper>
<div className="text-left">
{banner}
<p>FIXME: Handle content form</p>
{/*<ConsentForm submit={updateConsent} />*/}
</div>
</Swizzled.components.AuthMessageWrapper>
)
}
export const AuthWrapper = ({ children, requiredRole = 'user', Swizzled }) => {
const { t } = Swizzled.methods
const { useAccount, useBackend } = Swizzled.hooks
const { account, setAccount, token, admin, stopImpersonating, signOut } = useAccount()
const backend = useBackend()
const [ready, setReady] = useState(false)
const [impersonating, setImpersonating] = useState(false)
const [error, setError] = useState(false)
const [refreshCount, setRefreshCount] = useState(0)
/*
* Avoid hydration errors
*/
useEffect(() => {
const verifyAdmin = async () => {
const result = await backend.adminPing(admin.token)
if (result.success && result.data.account.role === 'admin') {
setImpersonating({
admin: result.data.account.username,
user: account.username,
})
}
setReady(true)
}
const verifyUser = async () => {
const result = await backend.ping()
if (result.success) {
// Refresh account in local storage
setAccount({
...account,
...result.data.account,
})
} else {
if (result.data?.error?.error) setError(result.data.error.error)
else signOut()
}
setReady(true)
}
if (admin && admin.token) verifyAdmin()
if (token) verifyUser()
else setReady(true)
}, [admin, token, refreshCount])
const refresh = () => {
setRefreshCount(refreshCount + 1)
setError(false)
}
if (!ready) return <Swizzled.components.Loading />
const banner = impersonating ? (
<div className="bg-warning rounded-lg shadow py-4 px-6 flex flex-row items-center gap-4 justify-between">
<span className="text-base-100 text-left">
Hi <b>{impersonating.admin}</b>, you are currently impersonating <b>{impersonating.user}</b>
</span>
<button className="btn btn-neutral" onClick={stopImpersonating}>
Stop Impersonating
</button>
</div>
) : null
const childProps = { t, banner, Swizzled }
if (!token || !account.username) return <Swizzled.components.AuthRequired {...childProps} />
if (error) {
if (error === 'accountInactive') return <Swizzled.components.AccountInactive {...childProps} />
if (error === 'accountDisabled') return <Swizzled.components.AccountDisabled {...childProps} />
if (error === 'accountBlocked') return <Swizzled.components.AccountProhibited {...childProps} />
if (error === 'consentLacking')
return <Swizzled.components.ConsentLacking {...childProps} refresh={refresh} />
return <Swizzled.components.AccountStatusUnknown {...childProps} />
}
if (
!Swizzled.config.roles.levels[account.role] ||
Swizzled.config.roles.levels[account.role] < Swizzled.config.roles.levels[requiredRole]
) {
return (
<Swizzled.components.RoleLacking
{...childProps}
role={account.role}
requiredRole={requiredRole}
/>
)
}
return children
}

View file

@ -0,0 +1,37 @@
export const CuratedMeasurementsSetLineup = ({ sets = [], locale, clickHandler, Swizzled }) => (
<div
className={`w-full flex flex-row ${
sets.length > 1 ? 'justify-start px-8' : 'justify-center'
} overflow-x-scroll`}
style={{
backgroundImage: `url(/img/lineup-backdrop.svg)`,
width: 'auto',
backgroundSize: 'auto 100%',
backgroundRepeat: 'repeat-x',
}}
>
{sets.map((set) => {
const props = {
className: 'aspect-[1/3] w-auto h-96',
style: {
backgroundImage: `url(${Swizzled.methods.cloudImageUrl({
id: `cset-${set.id}`,
type: 'lineup',
})})`,
width: 'auto',
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
},
onClick: () => clickHandler(set),
}
return (
<div className="flex flex-col items-center" key={set.id}>
<button {...props} key={set.id}></button>
<b>{set[`name${Swizzled.methods.capitalize(locale)}`]}</b>
</div>
)
})}
</div>
)

View file

@ -0,0 +1,28 @@
/**
* The design view is loaded if and only if not design is passed to the editor
*
* @param {Object} props - All the props
* @param {Object} designs - Object holding all designs
* @param {object} props.Swizzled - An object holding swizzled code
* @param {Object} update - ViewWrapper state update object
*/
export const DesignsView = ({ designs = {}, Swizzled, update }) => (
<div className="text-center mt-8 mb-24">
<h2>{Swizzled.methods.t('pe:chooseADesign')}</h2>
<ul className="flex flex-row flex-wrap gap-2 items-center justify-center max-w-2xl px-8 mx-auto">
{Object.keys(designs).map((name) => (
<li key={name}>
<button
className={`btn btn-primary btn-outline btn-sm capitalize font-bold `}
onClick={() => {
update.design(name)
update.view('draft')
}}
>
{name}
</button>
</li>
))}
</ul>
</div>
)

View file

@ -0,0 +1,67 @@
/**
* The draft view allows users to tweak their pattern
*
* @param (object) props - All the props
* @param {function} props.Design - The design constructor
* @param {array} props.missingMeasurements - List of missing measurements for the current design
* @param {object} props.state - The ViewWrapper state object
* @param {object} props.state.settings - The current settings
* @param {object} props.update - Helper object for updating the ViewWrapper state
* @param {object} props.Swizzled - An object holding swizzled code
* @return {function} DraftView - React component
*/
export const DraftView = ({ Design, state, update, Swizzled }) => {
/*
* Don't trust that we have all measurements
*
* We do not need to change the view here. That is done in the central
* ViewWrapper componenet. However, checking the measurements against
* the design takes a brief moment, so this component will typically
* render before that happens, and if measurments are missing it will
* throw and error.
*
* So when measurements are missing, we just return here and the view
* will switch on the next render loop.
*/
if (Swizzled.methods.missingMeasurements(state)) return null
/*
* First, attempt to draft
*/
const { pattern } = Swizzled.methods.draft(Design, state.settings)
let output = null
let renderProps = false
if (state.ui?.renderer === 'svg') {
try {
const __html = pattern.render()
output = (
<Swizzled.components.ZoomablePattern>
<div className="w-full h-full" dangerouslySetInnerHTML={{ __html }} />
</Swizzled.components.ZoomablePattern>
)
} catch (err) {
console.log(err)
}
} else {
renderProps = pattern.getRenderProps()
output = (
<Swizzled.components.ZoomablePattern
renderProps={renderProps}
patternLocale={state.locale || 'en'}
rotate={state.ui.rotate}
/>
)
}
return (
<Swizzled.components.PatternLayout
{...{ update, Design, output, state, pattern }}
menu={
state.ui?.aside ? (
<Swizzled.components.DraftMenu {...{ Design, pattern, update, state }} />
) : null
}
/>
)
}

View file

@ -0,0 +1,12 @@
/*
* Loading MDX dynamically is non-trivial an depends on the
* environment in which the component is loaded.
* By default, we use this component, which disabled loading
* inline docs. If you want this to work, you need to pass in
* your own DynamicMdx component into the editor:
*
* <PatternEditor components={{ DynamicMdx: MyComponent }} />
*/
export const DynamicMdx = ({ Swizzled }) => (
<Swizzled.components.Popout node>Not implemented</Swizzled.components.Popout>
)

View file

@ -0,0 +1,23 @@
/**
* The error view is loaded if and only an error occurs that we can't handle
*
* @param {object} props - The component's props
* @param {object} props.Swizzled - Swizzled code
* @param {function} props.Design - The design constructor
* @param {object} props.state - The ViewWrapper state object
* @param {object} props.state.settings - The current settings
* @param {object} props.update - Helper object for updating the ViewWrapper state
* @param {array} props.missingMeasurements - List of missing measurements for the current design
* @param {object} props.components - The possibly swizzled components
* @param {object} props.methods - The possibly swizzled methods
* @param {function} props.methods.t - The translation method
* @param {object} props.config - The possibly swizzled pattern editor configuration
* @return {function} MeasurementsView - React component
*/
export const ErrorView = ({ Swizzled, state }) => (
<div className="text-center mt-8">
<h2>{Swizzled.methods.t('oops')}</h2>
<p>FIXME: Something went wrong</p>
<pre>{JSON.stringify(state, null, 2)}</pre>
</div>
)

View file

@ -0,0 +1,103 @@
import mustache from 'mustache'
export const FlagTypeIcon = ({ Swizzled, type, className = 'w-6 h-6' }) => {
const Icon = Swizzled.components[`Flag${Swizzled.methods.capitalize(type)}Icon`]
return Icon ? <Icon className={className} /> : null
}
export const Flag = ({ Swizzled, data, handleUpdate }) => {
const btnIcon = data.suggest?.icon ? (
<Swizzled.components.FlagTypeIcon type={data.suggest.icon} className="w-5 h-6 sm:w-6 h-6" />
) : null
const { t } = Swizzled.methods
const button =
data.suggest?.text && data.suggest?.update ? (
<button
className={`btn btn-secondary btn-outline flex flex-row items-center ${
btnIcon ? 'gap-6' : ''
}`}
onClick={() => handleUpdate(data.suggest.update)}
>
{btnIcon}
{t(data.suggest.text)}
</button>
) : null
const desc = data.replace ? mustache.render(t(data.desc), data.replace) : t(data.desc)
const notes = data.notes
? Array.isArray(data.notes)
? '\n\n' +
data.notes
.map((note) => (data.replace ? mustache.render(t(note), data.replace) : t(note)))
.join('\n\n')
: '\n\n' + (data.replace ? mustache.render(t(data.notes), data.replace) : t(data.notes))
: null
return (
<div className="flex flex-col gap-2 items-start">
<div className="first:mt-0 grow md flag">
<pre>{desc}</pre>
<pre>{notes}</pre>
</div>
{button ? <div className="mt-2 w-full flex flex-row justify-end">{button}</div> : null}
</div>
)
}
//<Mdx md={notes ? desc + notes : desc} />
export const FlagsAccordionTitle = ({ flags, Swizzled }) => {
const { t } = Swizzled.methods
const flagList = Swizzled.methods.flattenFlags(flags)
if (Object.keys(flagList).length < 1) return null
return (
<>
<h5 className="flex flex-row gap-2 items-center justify-between w-full">
<span className="text-left">
{t('pe:flagMenu.t')} ({Object.keys(flagList).length})
</span>
<Swizzled.components.FlagTypeIcon className="w-8 h-8" />
</h5>
<p className="text-left">
{Object.keys(flagList).length > 1 ? t('pe:flagMenuMany.d') : t('pe:flagMenuOne.d')}
</p>
</>
)
}
export const FlagsAccordionEntries = ({ flags, update, Swizzled }) => {
const flagList = Swizzled.methods.flattenFlags(flags)
const { t } = Swizzled.methods
if (Object.keys(flagList).length < 1) return null
const handleUpdate = (config) => {
if (config.settings) update.settings(...config.settings)
if (config.ui) update.ui(...config.settings)
}
return (
<Swizzled.components.SubAccordion
items={Object.entries(flagList).map(([key, flag], i) => {
const title = flag.replace ? mustache.render(t(flag.title), flag.replace) : t(flag.title)
return [
<div className="w-full flex flex-row gap2 justify-between" key={i}>
<div className="flex flex-row items-center gap-2">
<div className="no-shrink">
<Swizzled.components.FlagIcon type={flag.type} />
</div>
<span className="font-medium text-left">{title}</span>
</div>
<span className="uppercase font-bold">{flag.type}</span>
</div>,
<Swizzled.components.Flag key={key} t={t} data={flag} handleUpdate={handleUpdate} />,
key,
]
})}
/>
)
}

View file

@ -0,0 +1,525 @@
import { useState } from 'react'
export const HeaderMenu = ({ state, Swizzled, update, Design, pattern }) => {
const [open, setOpen] = useState()
/*
* Guard views that require measurements agains missing measurements
* and make sure there's a view-specific header menu
*/
const ViewMenu =
!Swizzled.methods.missingMeasurements(state) &&
Swizzled.components[`HeaderMenu${Swizzled.config.viewComponents[state.view]}`]
? Swizzled.components[`HeaderMenu${Swizzled.config.viewComponents[state.view]}`]
: Swizzled.components.Null
return (
<div
className={`flex sticky top-0 ${
state.ui.kiosk ? 'z-50' : 'z-20'
} transition-[top] duration-300 ease-in-out`}
>
<div
className={`flex flex-row flex-wrap gap-1 md:gap-4 w-full items-start justify-center border-b border-base-300 py-1 md:py-1.5`}
>
<Swizzled.components.HeaderMenuAllViews {...{ state, Swizzled, update, open, setOpen }} />
<ViewMenu {...{ state, Swizzled, update, Design, pattern, open, setOpen }} />
</div>
</div>
)
}
export const HeaderMenuAllViews = ({ state, Swizzled, update, open, setOpen }) => (
<Swizzled.components.HeaderMenuViewMenu {...{ state, Swizzled, update, open, setOpen }} />
)
export const HeaderMenuDraftView = (props) => {
const { Swizzled } = props
const flags = props.pattern?.setStores?.[0]?.plugins?.['plugin-annotations']?.flags
return (
<>
<div className="flex flex-row gap-1">
<Swizzled.components.HeaderMenuDraftViewDesignOptions {...props} />
<Swizzled.components.HeaderMenuDraftViewCoreSettings {...props} />
<Swizzled.components.HeaderMenuDraftViewUiPreferences {...props} />
{flags ? <Swizzled.components.HeaderMenuDraftViewFlags {...props} flags={flags} /> : null}
</div>
<Swizzled.components.HeaderMenuDraftViewIcons {...props} />
<Swizzled.components.HeaderMenuUndoIcons {...props} />
<Swizzled.components.HeaderMenuSaveIcons {...props} />
</>
)
}
export const HeaderMenuDropdown = (props) => {
const { Swizzled, tooltip, toggle, open, setOpen, id } = props
/*
* We need to use both !fixed and md:!absolute here to override DaisyUI's
* classes on dropdown-content to force the dropdown to use the available
* screen space on mobile, rather than be positioned under its toggle button
*/
return props.disabled ? (
<Swizzled.components.Tooltip tip={tooltip}>
<button
disabled
tabIndex={0}
role="button"
className="btn btn-ghost hover:bg-secondary hover:bg-opacity-20 hover:border-solid hover:boder-2 hover:border-secondary border border-secondary border-2 border-dotted btn-sm px-2 z-20 relative"
>
{toggle}
</button>
</Swizzled.components.Tooltip>
) : (
<Swizzled.components.Tooltip tip={tooltip}>
<div className={`dropdown ${open === id ? 'dropdown-open z-20' : ''}`}>
<div
tabIndex={0}
role="button"
className="btn btn-ghost hover:bg-secondary hover:bg-opacity-20 hover:border-solid hover:boder-2 hover:border-secondary border border-secondary border-2 border-dotted btn-sm px-2 z-20 relative"
onClick={() => setOpen(open === id ? false : id)}
>
{toggle}
</div>
<div
tabIndex={0}
className="dropdown-content bg-base-100 bg-opacity-90 z-20 shadow left-0 !fixed md:!absolute top-12 w-screen md:w-96"
>
{props.children}
</div>
{open === id && (
<div
className="w-screen h-screen absolute top-10 left-0 opacity-0"
style={{ width: '200vw', transform: 'translateX(-100vw)' }}
onClick={() => setOpen(false)}
></div>
)}
</div>
</Swizzled.components.Tooltip>
)
}
export const HeaderMenuDraftViewDesignOptions = (props) => {
const { Swizzled } = props
return (
<Swizzled.components.HeaderMenuDropdown
{...props}
id="designOptions"
tooltip={Swizzled.methods.t('pe:designOptions.d')}
toggle={
<>
<Swizzled.components.HeaderMenuIcon name="options" extraClasses="text-secondary" />
<span className="hidden lg:inline">{Swizzled.methods.t('pe:designOptions.t')}</span>
</>
}
>
<Swizzled.components.DesignOptionsMenu {...props} />
</Swizzled.components.HeaderMenuDropdown>
)
}
export const HeaderMenuDraftViewCoreSettings = (props) => {
const { Swizzled } = props
return (
<Swizzled.components.HeaderMenuDropdown
{...props}
tooltip={Swizzled.methods.t('pe:coreSettings.d')}
id="coreSettings"
toggle={
<>
<Swizzled.components.HeaderMenuIcon name="settings" extraClasses="text-secondary" />
<span className="hidden lg:inline">{Swizzled.methods.t('pe:coreSettings.t')}</span>
</>
}
>
<Swizzled.components.CoreSettingsMenu {...props} />
</Swizzled.components.HeaderMenuDropdown>
)
}
export const HeaderMenuDraftViewUiPreferences = (props) => {
const { Swizzled } = props
return (
<Swizzled.components.HeaderMenuDropdown
{...props}
tooltip={Swizzled.methods.t('pe:uiPreferences.d')}
id="uiPreferences"
toggle={
<>
<Swizzled.components.HeaderMenuIcon name="ui" extraClasses="text-secondary" />
<span className="hidden lg:inline">{Swizzled.methods.t('pe:uiPreferences.t')}</span>
</>
}
>
<Swizzled.components.UiPreferencesMenu {...props} />
</Swizzled.components.HeaderMenuDropdown>
)
}
export const HeaderMenuDraftViewFlags = (props) => {
const { Swizzled } = props
const count = Object.keys(Swizzled.methods.flattenFlags(props.flags)).length
return (
<Swizzled.components.HeaderMenuDropdown
{...props}
tooltip={Swizzled.methods.t('pe:flagMenuMany.d')}
id="flags"
toggle={
<>
<Swizzled.components.HeaderMenuIcon name="flag" extraClasses="text-secondary" />
<span className="hidden lg:inline">
{Swizzled.methods.t('pe:flags')}
<span>({count})</span>
</span>
</>
}
>
<Swizzled.components.FlagsAccordionEntries {...props} />
</Swizzled.components.HeaderMenuDropdown>
)
}
export const HeaderMenuDraftViewIcons = (props) => {
const { Swizzled, update } = props
const Button = Swizzled.components.HeaderMenuButton
const size = 'w-5 h-5'
const muted = 'text-current opacity-50'
const ux = props.state.ui.ux
const levels = {
...props.Swizzled.config.uxLevels.core,
...props.Swizzled.config.uxLevels.ui,
}
return (
<div className="flex flex-row flex-wrap items-center justify-center px-2">
{ux >= levels.sa ? (
<Button updateHandler={update.toggleSa} tooltip={Swizzled.methods.t('pe:tt.toggleSa')}>
<Swizzled.components.SaIcon
className={`${size} ${props.state.settings.sabool ? 'text-secondary' : muted}`}
/>
</Button>
) : null}
{ux >= levels.paperless ? (
<Button
updateHandler={() => update.settings('paperless', props.state.settings.paperless ? 0 : 1)}
tooltip={Swizzled.methods.t('pe:tt.togglePaperless')}
>
<Swizzled.components.PaperlessIcon
className={`${size} ${props.state.settings.paperless ? 'text-secondary' : muted}`}
/>
</Button>
) : null}
{ux >= levels.complete ? (
<Button
updateHandler={() => update.settings('complete', props.state.settings.complete ? 0 : 1)}
tooltip={Swizzled.methods.t('pe:tt.toggleComplete')}
>
<Swizzled.components.DetailIcon
className={`${size} ${!props.state.settings.complete ? 'text-secondary' : muted}`}
/>
</Button>
) : null}
{ux >= levels.expand ? (
<Button
updateHandler={() => update.settings('expand', props.state.settings.expand ? 0 : 1)}
tooltip={Swizzled.methods.t('pe:tt.toggleExpand')}
>
<Swizzled.components.ExpandIcon
className={`${size} ${props.state.settings.expand ? 'text-secondary' : muted}`}
/>
</Button>
) : null}
{ux >= levels.units ? (
<Button
updateHandler={() =>
update.settings(
'units',
props.state.settings.units === 'imperial' ? 'metric' : 'imperial'
)
}
tooltip={Swizzled.methods.t('pe:tt.toggleUnits')}
>
<Swizzled.components.UnitsIcon
className={`${size} ${
props.state.settings.units === 'imperial' ? 'text-secondary' : muted
}`}
/>
</Button>
) : null}
<Swizzled.components.HeaderMenuIconSpacer />
{ux >= levels.ux ? (
<div className="flex flex-row px-1">
<Swizzled.components.Tooltip tip={Swizzled.methods.t('pe:tt.changeUx')}>
{[0, 1, 2, 3, 4].map((i) => (
<button
key={i}
className="btn btn-ghost btn-sm px-0 -mx-0.5"
onClick={() => update.ui('ux', i + 1)}
>
<Swizzled.components.CircleIcon
key={i}
fill={i < props.state.ui.ux ? true : false}
className={`${size} ${
i < props.state.ui.ux ? 'stroke-secondary fill-secondary' : 'stroke-current'
}`}
fillOpacity={0.3}
/>
</button>
))}
</Swizzled.components.Tooltip>
</div>
) : null}
{ux >= levels.aside ? (
<Button
updateHandler={() => update.ui('aside', props.state.ui.aside ? 0 : 1)}
tooltip={Swizzled.methods.t('pe:tt.toggleAside')}
>
<Swizzled.components.MenuIcon
className={`${size} ${!props.state.ui.aside ? 'text-secondary' : muted}`}
/>
</Button>
) : null}
{ux >= levels.kiosk ? (
<Button
updateHandler={() => update.ui('kiosk', props.state.ui.kiosk ? 0 : 1)}
tooltip={Swizzled.methods.t('pe:tt.toggleKiosk')}
>
<Swizzled.components.KioskIcon
className={`${size} ${props.state.ui.kiosk ? 'text-secondary' : muted}`}
/>
</Button>
) : null}
{ux >= levels.rotate ? (
<Button
updateHandler={() => update.ui('rotate', props.state.ui.rotate ? 0 : 1)}
tooltip={Swizzled.methods.t('pe:tt.toggleRotate')}
>
<Swizzled.components.RotateIcon
className={`${size} ${props.state.ui.rotate ? 'text-secondary' : muted}`}
/>
</Button>
) : null}
{ux >= levels.renderer ? (
<Button
updateHandler={() =>
update.ui('renderer', props.state.ui.renderer === 'react' ? 'svg' : 'react')
}
tooltip={Swizzled.methods.t('pe:tt.toggleRenderer')}
>
<Swizzled.components.RocketIcon
className={`${size} ${props.state.ui.renderer === 'svg' ? 'text-secondary' : muted}`}
/>
</Button>
) : null}
</div>
)
}
export const HeaderMenuUndoIcons = (props) => {
const { Swizzled, update, state, Design } = props
const Button = Swizzled.components.HeaderMenuButton
const size = 'w-5 h-5'
const undos = props.state._?.undos && props.state._.undos.length > 0 ? props.state._.undos : false
return (
<div className="flex flex-row flex-wrap items-center justify-center px-2">
<Button
updateHandler={() => update.restore(0, state._)}
tooltip={Swizzled.methods.t('pe:tt.undo')}
disabled={undos ? false : true}
>
<Swizzled.components.UndoIcon
className={`${size} ${undos ? 'text-secondary' : ''}`}
text="1"
/>
</Button>
<Button
updateHandler={() => update.restore(undos.length - 1, state._)}
tooltip={Swizzled.methods.t('pe:tt.undoAll')}
disabled={undos ? false : true}
>
<Swizzled.components.UndoIcon
className={`${size} ${undos ? 'text-secondary' : ''}`}
text={Swizzled.methods.t('pe:allFirstLetter')}
/>
</Button>
<Swizzled.components.HeaderMenuDropdown
{...props}
tooltip={Swizzled.methods.t('pe:view.undos.t')}
id="undos"
disabled={undos ? false : true}
toggle={
<>
<Swizzled.components.UndoIcon className="w-4 h-4" stroke={3} />
<span className="hidden lg:inline">{Swizzled.methods.t('pe:undo')}</span>
</>
}
>
{undos ? (
<ul className="dropdown-content bg-base-100 bg-opacity-90 z-20 shadow left-0 !fixed md:!absolute w-screen md:w-96 px-4 md:p-2 md:pt-0">
{undos.slice(0, 9).map((step, index) => (
<li key={index}>
<Swizzled.components.UndoStep {...{ step, update, state, Design, index }} compact />
</li>
))}
<li key="view">
<Swizzled.components.ButtonFrame
dense
onClick={() => {
return null /*update.state(index, state._) */
}}
>
<div className="flex flex-row items-center align-center justify-between gap-2 w-full">
<div className="flex flex-row items-center align-start gap-2 grow">
<Swizzled.components.UndoIcon className="w-5 h-5 text-secondary" />
{Swizzled.methods.t(`pe:view.undos.t`)}...
</div>
{undos.length}
</div>
</Swizzled.components.ButtonFrame>
</li>
</ul>
) : null}
</Swizzled.components.HeaderMenuDropdown>
<Button updateHandler={update.clearAll} tooltip={Swizzled.methods.t('pe:tt.resetDesign')}>
<Swizzled.components.TrashIcon className={`${size} text-secondary`} />
</Button>
<Button updateHandler={update.clearAll} tooltip={Swizzled.methods.t('pe:tt.resetAll')}>
<Swizzled.components.ResetAllIcon className={`${size} text-secondary`} />
</Button>
</div>
)
}
export const HeaderMenuSaveIcons = (props) => {
const { Swizzled, update } = props
const Button = Swizzled.components.HeaderMenuButton
const size = 'w-5 h-5'
const saveable = props.state._?.undos && props.state._.undos.length > 0
return (
<div className="flex flex-row flex-wrap items-center justify-center px-2">
<Button
updateHandler={update.clearPattern}
tooltip={Swizzled.methods.t('pe:tt.savePattern')}
disabled={saveable ? false : true}
>
<Swizzled.components.SaveIcon className={`${size} ${saveable ? 'text-success' : ''}`} />
</Button>
<Button
updateHandler={() => update.view('save')}
tooltip={Swizzled.methods.t('pe:tt.savePatternAs')}
>
<Swizzled.components.SaveAsIcon className={`${size} text-secondary`} />
</Button>
<Button
updateHandler={update.clearPattern}
tooltip={Swizzled.methods.t('pe:tt.exportPattern')}
>
<Swizzled.components.ExportIcon className={`${size} text-secondary`} />
</Button>
</div>
)
}
export const HeaderMenuIcon = (props) => {
const { Swizzled, name, extraClasses = '' } = props
const Icon =
Swizzled.components[`${Swizzled.methods.capitalize(name)}Icon`] || Swizzled.components.Noop
return <Icon {...props} className={`h-5 w-5 ${extraClasses}`} />
}
export const HeaderMenuIconSpacer = () => <span className="px-1 font-bold opacity-30">|</span>
export const HeaderMenuButton = ({
Swizzled,
updateHandler,
children,
tooltip,
disabled = false,
}) => (
<Swizzled.components.Tooltip tip={tooltip}>
<button
className="btn btn-ghost btn-sm px-1 disabled:bg-transparent"
onClick={updateHandler}
disabled={disabled}
>
{children}
</button>
</Swizzled.components.Tooltip>
)
export const HeaderMenuViewMenu = (props) => {
const { Swizzled, update, state } = props
const output = []
let i = 1
for (const viewName of [
'spacer',
...Swizzled.config.mainViews,
'spacer',
...Swizzled.config.extraViews,
'spacerOver3',
...Swizzled.config.devViews,
'spacer',
'picker',
]) {
if (viewName === 'spacer') output.push(<Swizzled.components.AsideViewMenuSpacer key={i} />)
else if (viewName === 'spacerOver3')
output.push(state.ui.ux > 3 ? <Swizzled.components.AsideViewMenuSpacer key={i} /> : null)
else if (
state.ui.ux >= Swizzled.config.uxLevels.views[viewName] &&
(Swizzled.config.measurementsFreeViews.includes(viewName) ||
state._.missingMeasurements.length < 1)
)
output.push(
<li key={i} className="mb-1 flex flex-row items-center justify-between w-full">
<a
className={`w-full rounded-lg border-2 border-secondary text-base-content
flex flex-row items-center gap-2 md:gap-4 p-2
hover:cursor-pointer
hover:bg-secondary hover:bg-opacity-20 hover:border-solid ${
viewName === state.view ? 'bg-secondary border-solid bg-opacity-20' : 'border-dotted'
}`}
onClick={() => update.view(viewName)}
>
<Swizzled.components.ViewTypeIcon
view={viewName}
className="w-6 h-6 grow-0"
Swizzled={Swizzled}
/>
<b className="text-left grow">{Swizzled.methods.t(`pe:view.${viewName}.t`)}</b>
</a>
</li>
)
i++
}
return (
<Swizzled.components.HeaderMenuDropdown
{...props}
tooltip={Swizzled.methods.t('pe:views.d')}
id="views"
toggle={
<>
<Swizzled.components.HeaderMenuIcon
name="right"
stroke={3}
extraClasses="text-secondary rotate-90"
/>
<span className="hidden lg:inline">{Swizzled.methods.t('pe:views.t')}</span>
</>
}
>
<ul
tabIndex={i}
className="dropdown-content bg-base-100 bg-opacity-90 z-20 shadow left-0 !fixed md:!absolute w-screen md:w-96 px-4 md:p-2 md:pt-0"
>
{output}
</ul>
</Swizzled.components.HeaderMenuDropdown>
)
}

View file

@ -0,0 +1 @@
export const HtmlSpan = ({ html }) => <span dangerouslySetInnerHTML={{ __html: html }} />

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,453 @@
/*************************************************************************
* *
* FreeSewing's pattern editor allows swizzling components *
* *
* To 'swizzle' means to replace the default implementation of a *
* component with a custom one. It allows one to customize *
* the pattern editor. *
* *
* This file holds the 'swizzleComponents' method that will return *
* the various components that can be swizzled, or their default *
* implementation. *
* *
* To use a custom version, simply pas it as a prop into the editor *
* under the 'components' key. So to pass a custom 'TemporaryLoader' *
* component, you do: *
* *
* <PatternEditor compnents={{ TemporaryLoader: MyTemporaryLoader }} /> *
* *
*************************************************************************/
/*
* Import of components that can be swizzled
*/
import { Link, AnchorLink, PageLink, WebLink, CardLink } from './link.mjs'
// Accordion
import { BaseAccordion, SubAccordion, Accordion } from './accordion.mjs'
// Auth wrapper
import {
AuthWrapper,
AuthMessageWrapper,
ContactSupport,
AuthRequired,
AccountInactive,
AccountDisabled,
AccountProhibited,
AccountStatusUnknown,
RoleLacking,
ConsentLacking,
} from './auth-wrapper.mjs'
// Ux
import { Ux } from './ux.mjs'
// Popout
import { Popout } from './popout.mjs'
// Loader
import { TemporaryLoader } from './loaders.mjs'
// Measurements Sets
import { UserSetPicker, BookmarkedSetPicker, CuratedSetPicker } from './sets.mjs'
// Curated Measurements Sets
import { CuratedMeasurementsSetLineup } from './curated-sets.mjs'
import { MeasurementsSetCard } from './measurements-set-card.mjs'
// Icons
import {
ApplyIcon,
BackIcon,
BeakerIcon,
BookmarkIcon,
BoolNoIcon,
BoolYesIcon,
CircleIcon,
CloseIcon,
CuratedMeasurementsSetIcon,
DesignIcon,
DetailIcon,
DocsIcon,
DownIcon,
EditIcon,
ExpandIcon,
ExportIcon,
FailureIcon,
FlagIcon,
FlagNoteIcon,
FlagInfoIcon,
FlagTipIcon,
FlagWarningIcon,
FlagErrorIcon,
FlagFixmeIcon,
FlagExpandIcon,
FlagOtionsIcon,
GaugeIcon,
GroupIcon,
IncludeIcon,
HelpIcon,
KioskIcon,
LeftIcon,
ListIcon,
LockIcon,
MarginIcon,
MeasurementsIcon,
MeasurementsSetIcon,
MenuIcon,
NoIcon,
OkIcon,
OptionsIcon,
PaperlessIcon,
PlusIcon,
PrintIcon,
ResetAllIcon,
ResetIcon,
RightIcon,
RocketIcon,
RotateIcon,
SaIcon,
SaveIcon,
SaveAsIcon,
ScaleIcon,
SettingsIcon,
SpinnerIcon,
SuccessIcon,
TipIcon,
TrashIcon,
UiIcon,
UndoIcon,
UnitsIcon,
UpIcon,
UploadIcon,
UxIcon,
XrayIcon,
ViewDraftIcon,
ViewMeasurementsIcon,
ViewTestIcon,
ViewTimingIcon,
ViewPrintLayoutIcon,
ViewSaveIcon,
ViewExportIcon,
ViewEditSettingsIcon,
ViewLogsIcon,
ViewInspectIcon,
ViewDocsIcon,
ViewDesignsIcon,
ViewViewPickerIcon,
ViewUndosIcon,
} from './icons.mjs'
// Measurements Editor
import { MeasurementsEditor } from './measurements-editor.mjs'
// Zoomable pattern
import { ZoomablePattern, ZoomContextProvider } from './zoomable-pattern.mjs'
import { PatternLayout } from './pattern-layout.mjs'
// inputs
import {
FormControl,
ButtonFrame,
NumberInput,
StringInput,
ListInput,
MarkdownInput,
MeasurementInput,
ToggleInput,
} from './inputs.mjs'
// Views
import { DesignsView } from './designs-view.mjs'
import { DraftView } from './draft-view.mjs'
import { ErrorView } from './error-view.mjs'
import { MeasurementsView } from './measurements-view.mjs'
import { SaveView } from './save-view.mjs'
import { ViewPicker } from './view-picker.mjs'
import { UndoStep, UndoStepTimeAgo, UndosView } from './undos-view.mjs'
// Pattern
import { Pattern } from '@freesewing/react-components/pattern'
// Menus
import { DraftMenu } from './menus/draft-menu.mjs'
import { CoreSettingsMenu, CoreSetting } from './menus/core-settings-menu.mjs'
import { DesignOptionsMenu, DesignOption } from './menus/design-options-menu.mjs'
import { UiPreferencesMenu, UiPreference } from './menus/ui-preferences-menu.mjs'
import { MenuItem, MenuItemGroup, MenuItemTitle } from './menus/containers.mjs'
import {
MenuBoolInput,
MenuConstantInput,
MenuDegInput,
MenuEditOption,
MenuListInput,
MenuListToggle,
MenuMmInput,
//MenuNumberInput,
MenuUxSettingInput,
MenuOnlySettingInput,
MenuPctInput,
MenuSliderInput,
} from './menus/shared-inputs.mjs'
import {
MenuBoolValue,
MenuConstantOptionValue,
MenuCountOptionValue,
MenuDegOptionValue,
MenuHighlightValue,
MenuListOptionValue,
MenuListValue,
MenuMmOptionValue,
MenuMmValue,
MenuOnlySettingValue,
MenuPctOptionValue,
MenuScaleSettingValue,
MenuShowValue,
} from './menus/shared-values.mjs'
import {
HeaderMenu,
HeaderMenuAllViews,
HeaderMenuDraftView,
HeaderMenuButton,
HeaderMenuDropdown,
HeaderMenuDraftViewDesignOptions,
HeaderMenuDraftViewCoreSettings,
HeaderMenuDraftViewUiPreferences,
HeaderMenuDraftViewFlags,
HeaderMenuDraftViewIcons,
HeaderMenuIcon,
HeaderMenuIconSpacer,
HeaderMenuSaveIcons,
HeaderMenuUndoIcons,
HeaderMenuViewMenu,
} from './header-menu.mjs'
// Flags
import { Flag, FlagTypeIcon, FlagsAccordionTitle, FlagsAccordionEntries } from './flags.mjs'
// View Menu
import {
AsideViewMenu,
AsideViewMenuIcons,
AsideViewMenuButton,
AsideViewMenuSpacer,
ViewTypeIcon,
} from './aside-view-menu.mjs'
import { Null } from './null.mjs'
import { LargeScreenOnly } from './large-screen-only.mjs'
import { Tooltip } from './tooltip.mjs'
import { LoadingStatus } from './loading-status.mjs'
import { Spinner, Loading } from './spinner.mjs'
import { Tab, Tabs } from './tabs.mjs'
import { Markdown } from './markdown.mjs'
import { HtmlSpan } from './html-span.mjs'
/**
* This object holds all components that can be swizzled
*/
const defaultComponents = {
Accordion,
AuthWrapper,
AuthMessageWrapper,
BackIcon,
ContactSupport,
AuthRequired,
AccountInactive,
AccountDisabled,
AccountProhibited,
AccountStatusUnknown,
AnchorLink,
AsideViewMenu,
AsideViewMenuIcons,
AsideViewMenuButton,
AsideViewMenuSpacer,
RoleLacking,
ConsentLacking,
BaseAccordion,
BookmarkedSetPicker,
ButtonFrame,
CardLink,
CircleIcon,
CoreSetting,
CoreSettingsMenu,
CuratedMeasurementsSetIcon,
CuratedMeasurementsSetLineup,
CuratedSetPicker,
DesignOption,
DesignOptionsMenu,
DesignsView,
DraftMenu,
DraftView,
ErrorView,
SaveView,
Flag,
FlagsAccordionTitle,
FlagsAccordionEntries,
FlagTypeIcon,
FormControl,
HeaderMenu,
HeaderMenuAllViews,
HeaderMenuDraftView,
HeaderMenuDraftViewDesignOptions,
HeaderMenuDraftViewCoreSettings,
HeaderMenuDraftViewUiPreferences,
HeaderMenuDraftViewFlags,
HeaderMenuDraftViewIcons,
HeaderMenuButton,
HeaderMenuDropdown,
HeaderMenuIcon,
HeaderMenuIconSpacer,
HeaderMenuSaveIcons,
HeaderMenuUndoIcons,
HtmlSpan,
LargeScreenOnly,
Link,
ListInput,
Loading,
LoadingStatus,
Markdown,
MarkdownInput,
MeasurementInput,
MeasurementsSetCard,
MeasurementsView,
MeasurementsEditor,
MenuIcon,
NumberInput,
Null,
PageLink,
Pattern,
PatternLayout,
Popout,
StringInput,
SubAccordion,
Spinner,
SpinnerIcon,
Tab,
Tabs,
TemporaryLoader,
ToggleInput,
Tooltip,
UiPreferencesMenu,
UiPreference,
UndoStep,
UndoStepTimeAgo,
UndosView,
UserSetPicker,
Ux,
HeaderMenuViewMenu,
ViewPicker,
ViewTypeIcon,
WebLink,
ZoomablePattern,
ZoomContextProvider,
// icons
ApplyIcon,
BeakerIcon,
BookmarkIcon,
BoolNoIcon,
BoolYesIcon,
CloseIcon,
DesignIcon,
DetailIcon,
DocsIcon,
DownIcon,
EditIcon,
ExpandIcon,
ExportIcon,
FailureIcon,
FlagIcon,
FlagNoteIcon,
FlagInfoIcon,
FlagTipIcon,
FlagWarningIcon,
FlagErrorIcon,
FlagFixmeIcon,
FlagExpandIcon,
FlagOtionsIcon,
GaugeIcon,
GroupIcon,
HelpIcon,
IncludeIcon,
KioskIcon,
LeftIcon,
ListIcon,
LockIcon,
MarginIcon,
MeasurementsIcon,
MeasurementsSetIcon,
NoIcon,
OkIcon,
OptionsIcon,
PaperlessIcon,
PlusIcon,
PrintIcon,
ResetAllIcon,
ResetIcon,
RightIcon,
RocketIcon,
RotateIcon,
SaIcon,
SaveIcon,
SaveAsIcon,
ScaleIcon,
SettingsIcon,
SuccessIcon,
TipIcon,
TrashIcon,
UiIcon,
UndoIcon,
UnitsIcon,
UpIcon,
UploadIcon,
UxIcon,
XrayIcon,
ViewDraftIcon,
ViewMeasurementsIcon,
ViewTestIcon,
ViewTimingIcon,
ViewPrintLayoutIcon,
ViewSaveIcon,
ViewExportIcon,
ViewEditSettingsIcon,
ViewLogsIcon,
ViewInspectIcon,
ViewDocsIcon,
ViewDesignsIcon,
ViewViewPickerIcon,
ViewUndosIcon,
// menus
MenuItem,
MenuItemGroup,
MenuItemTitle,
MenuBoolInput,
MenuConstantInput,
MenuDegInput,
MenuEditOption,
MenuListInput,
MenuListToggle,
MenuMmInput,
//MenuNumberInput,
MenuUxSettingInput,
MenuOnlySettingInput,
MenuPctInput,
MenuSliderInput,
MenuBoolValue,
MenuConstantOptionValue,
MenuCountOptionValue,
MenuDegOptionValue,
MenuHighlightValue,
MenuListOptionValue,
MenuListValue,
MenuMmOptionValue,
MenuMmValue,
MenuOnlySettingValue,
MenuPctOptionValue,
MenuScaleSettingValue,
MenuShowValue,
}
/*
* This method returns a component that can be swizzled
* So either the passed-in component, or the default one
*/
export const swizzleComponents = (components = {}, Swizzled) => {
/*
* We need to return all resulting components, swizzled or not
* So we create this object so we can pass that down
*/
const all = {}
for (let [name, Component] of Object.entries(defaultComponents)) {
all[name] = components[name]
? (props) => components[name]({ Swizzled, ...props })
: (props) => <Component {...props} Swizzled={Swizzled} />
}
/*
* Return all components
*/
return all
}

View file

@ -0,0 +1,349 @@
// Hooks
import { useState } from 'react'
/*
* Helper component to wrap a form control with a label
*/
export const FormControl = ({
label, // the (top-left) label
children, // Children to go inside the form control
docs = false, // Optional top-right label
labelBL = false, // Optional bottom-left label
labelBR = false, // Optional bottom-right label
forId = false, // ID of the for element we are wrapping
Swizzled, // Object holding swizzled code
}) => {
if (labelBR && !labelBL) labelBL = <span></span>
const topLabelChildren = (
<>
<span className="label-text text-lg font-bold mb-0 text-inherit">{label}</span>
{docs ? (
<span className="label-text-alt">
<button
className="btn btn-ghost btn-sm btn-circle hover:btn-secondary"
onClick={() =>
Swizzled.methods.setModal(
<Swizzled.components.ModalWrapper
flex="col"
justify="top lg:justify-center"
slideFrom="right"
keepOpenOnClick
>
<div className="mdx max-w-prose">{docs}</div>
</Swizzled.components.ModalWrapper>
)
}
>
<Swizzled.components.DocsIcon />
</button>
</span>
) : null}
</>
)
const bottomLabelChildren = (
<>
{labelBL ? <span className="label-text-alt">{labelBL}</span> : null}
{labelBR ? <span className="label-text-alt">{labelBR}</span> : null}
</>
)
return (
<div className="form-control w-full mt-2">
{forId ? (
<label className="label pb-0" htmlFor={forId}>
{topLabelChildren}
</label>
) : (
<div className="label pb-0">{topLabelChildren}</div>
)}
{children}
{labelBL || labelBR ? (
forId ? (
<label className="label" htmlFor={forId}>
{bottomLabelChildren}
</label>
) : (
<div className="label">{bottomLabelChildren}</div>
)
) : null}
</div>
)
}
/*
* Helper method to wrap content in a button
*/
export const ButtonFrame = ({
children, // Children of the button
onClick, // onClick handler
active, // Whether or not to render the button as active/selected
accordion = false, // Set this to true to not set a background color when active
dense = false, // Use less padding
}) => (
<button
className={`
btn btn-ghost btn-secondary
w-full ${dense ? 'mt-1 py-0 btn-sm' : 'mt-2 py-4 h-auto content-start'}
border-2 border-secondary text-left bg-opacity-20
${accordion ? 'hover:bg-transparent' : 'hover:bg-secondary hover:bg-opacity-10'}
hover:border-secondary hover:border-solid hover:border-2
${active ? 'border-solid' : 'border-dotted'}
${active && !accordion ? 'bg-secondary' : 'bg-transparent'}
`}
onClick={onClick}
>
{children}
</button>
)
/*
* Input for integers
*/
export const NumberInput = ({
label, // Label to use
update, // onChange handler
valid, // Method that should return whether the value is valid or not
current, // The current value
original, // The original value
placeholder, // The placeholder text
docs = false, // Docs to load, if any
id = '', // An id to tie the input to the label
labelBL = false, // Bottom-Left label
labelBR = false, // Bottom-Right label
max = 0,
min = 220,
step = 1,
Swizzled, // Object holding swizzled code
}) => (
<Swizzled.components.FormControl {...{ label, labelBL, labelBR, docs }} forId={id}>
<input
id={id}
type="number"
placeholder={placeholder}
value={current}
onChange={(evt) => update(evt.target.value)}
className={`input w-full input-bordered ${
current === original ? 'input-secondary' : valid(current) ? 'input-success' : 'input-error'
}`}
{...{ max, min, step }}
/>
</Swizzled.components.FormControl>
)
/*
* Input for strings
*/
export const StringInput = ({
label, // Label to use
update, // onChange handler
valid, // Method that should return whether the value is valid or not
current, // The current value
original, // The original value
placeholder, // The placeholder text
docs = false, // Docs to load, if any
id = '', // An id to tie the input to the label
labelBL = false, // Bottom-Left label
labelBR = false, // Bottom-Right label
Swizzled, // Object holding swizzled code
}) => (
<Swizzled.components.FormControl {...{ label, labelBL, labelBR, docs }} forId={id}>
<input
id={id}
type="text"
placeholder={placeholder}
value={current}
onChange={(evt) => update(evt.target.value)}
className={`input w-full input-bordered ${
current === original ? 'input-secondary' : valid(current) ? 'input-success' : 'input-error'
}`}
/>
</Swizzled.components.FormControl>
)
/*
* Input for a list of things to pick from
*/
export const ListInput = ({
update, // the onChange handler
label, // The label
list, // The list of items to present { val, label, desc }
current, // The (value of the) current item
docs = false, // Docs to load, if any
Swizzled, // Object holding swizzled code
}) => (
<Swizzled.components.FormControl label={label} docs={docs}>
{list.map((item, i) => (
<Swizzled.components.ButtonFrame
key={i}
active={item.val === current}
onClick={() => update(item.val)}
>
<div className="w-full flex flex-col gap-2">
<div className="w-full text-lg leading-5">{item.label}</div>
{item.desc ? (
<div className="w-full text-normal font-normal normal-case pt-1 leading-5">
{item.desc}
</div>
) : null}
</div>
</Swizzled.components.ButtonFrame>
))}
</Swizzled.components.FormControl>
)
/*
* Input for markdown content
*/
export const MarkdownInput = ({
label, // The label
current, // The current value (markdown)
update, // The onChange handler
placeholder, // The placeholder content
docs = false, // Docs to load, if any
id = '', // An id to tie the input to the label
labelBL = false, // Bottom-Left label
labelBR = false, // Bottom-Right label
Swizzled, // Swizzled code
}) => (
<Swizzled.components.FormControl {...{ label, labelBL, labelBR, docs }} forId={id}>
<Swizzled.components.Tabs tabs={['edit', 'preview']}>
<Swizzled.components.Tab key="edit">
<div className="flex flex-row items-center mt-4">
<textarea
id={id}
rows="5"
className="textarea textarea-bordered textarea-lg w-full"
value={current}
placeholder={placeholder}
onChange={(evt) => update(evt.target.value)}
/>
</div>
</Swizzled.components.Tab>
<Swizzled.components.Tab key="preview">
<div className="mdx mt-4 shadow p-2 px-4 rounded">
<Swizzled.components.Markdown>{current}</Swizzled.components.Markdown>
</div>
</Swizzled.components.Tab>
</Swizzled.components.Tabs>
</Swizzled.components.FormControl>
)
export const MeasurementInput = ({
imperial, // True for imperial, False for metric
m, // The measurement name
original, // The original value
update, // The onChange handler
placeholder, // The placeholder content
docs = false, // Docs to load, if any
id = '', // An id to tie the input to the label
Swizzled, // Swizzled code
}) => {
const { t } = Swizzled.methods
const isDegree = Swizzled.methods.isDegreeMeasurement(m)
const units = imperial ? 'imperial' : 'metric'
const [localVal, setLocalVal] = useState(
typeof original === 'undefined'
? original
: isDegree
? Number(original)
: Swizzled.methods.measurementAsUnits(original, units)
)
const [validatedVal, setValidatedVal] = useState(
Swizzled.methods.measurementAsUnits(original, units)
)
const [valid, setValid] = useState(null)
// Update onChange
const localUpdate = (newVal) => {
setLocalVal(newVal)
const parsedVal = isDegree
? Number(newVal)
: Swizzled.methods.parseDistanceInput(newVal, imperial)
if (parsedVal) {
update(m, isDegree ? parsedVal : Swizzled.methods.measurementAsMm(parsedVal, units))
setValid(true)
setValidatedVal(parsedVal)
} else setValid(false)
}
if (!m) return null
// Various visual indicators for validating the input
let inputClasses = 'input-secondary'
let bottomLeftLabel = null
if (valid === true) {
inputClasses = 'input-success'
const val = `${validatedVal}${isDegree ? '°' : imperial ? '"' : 'cm'}`
bottomLeftLabel = <span className="font-medium text-base text-success -mt-2 block">{val}</span>
} else if (valid === false) {
inputClasses = 'input-error'
bottomLeftLabel = (
<span className="font-medium text-error text-base -mt-2 block">¯\_()_/¯</span>
)
}
/*
* I'm on the fence here about using a text input rather than number
* Obviously, number is the more correct option, but when the user enter
* text, it won't fire an onChange event and thus they can enter text and it
* will not be marked as invalid input.
* See: https://github.com/facebook/react/issues/16554
*/
return (
<Swizzled.components.FormControl
label={t(m) + (isDegree ? ' (°)' : '')}
docs={docs}
forId={id}
labelBL={bottomLeftLabel}
>
<input
id={id}
type="number"
placeholder={placeholder}
value={localVal}
onChange={(evt) => localUpdate(evt.target.value)}
className={`input w-full input-bordered ${inputClasses}`}
/>
</Swizzled.components.FormControl>
)
}
/*
* Input for booleans
*/
export const ToggleInput = ({
label, // Label to use
update, // onChange handler
current, // The current value
disabled = false, // Allows rendering a disabled view
list = [true, false], // The values to chose between
labels = ['Yes', 'No'], // The labels for the values
on = true, // The value that should show the toggle in the 'on' state
id = '', // An id to tie the input to the label
labelTR = false, // Top-Right label
labelBL = false, // Bottom-Left label
labelBR = false, // Bottom-Right label
Swizzled, // Object holding swizzled code
}) => (
<Swizzled.components.FormControl
{...{ labelBL, labelBR, labelTR }}
label={
label
? `${label} (${current === on ? labels[0] : labels[1]})`
: `${current === on ? labels[0] : labels[1]}`
}
forId={id}
>
<input
id={id}
disabled={disabled}
type="checkbox"
value={current}
onChange={() => update(list.indexOf(current) === 0 ? list[1] : list[0])}
className="toggle my-3 toggle-primary"
checked={list.indexOf(current) === 0 ? true : false}
/>
</Swizzled.components.FormControl>
)

View file

@ -0,0 +1 @@
export const LargeScreenOnly = ({ children }) => <div className="hidden xl:block">{children}</div>

View file

@ -0,0 +1,48 @@
import Link from 'next/link'
const AnchorLink = ({ id, txt = false, children, Swizzled }) => (
<a href={`#${id}`} className={Swizzled.config.classes.link} title={txt ? txt : ''}>
{txt ? txt : children}
</a>
)
const PageLink = ({ href, txt = false, children, Swizzled }) => (
<Swizzled.components.Link
href={href}
className={Swizzled.config.classes.link}
title={txt ? txt : ''}
>
{children ? children : txt}
</Swizzled.components.Link>
)
const WebLink = ({ href, txt = false, children, Swizzled }) => (
<a href={href} className={Swizzled.config.classes.link} title={txt ? txt : ''}>
{children ? children : txt}
</a>
)
const CardLink = ({
bg = 'bg-base-200',
textColor = 'text-base-content',
href,
title,
text,
icon,
Swizzled,
}) => (
<Swizzled.components.Link
href={href}
className={`px-8 ${bg} py-10 rounded-lg block ${textColor}
hover:bg-secondary hover:bg-opacity-10 shadow-lg
transition-color duration-300 grow`}
>
<h2 className="mb-4 text-inherit flex flex-row gap-4 justify-between items-center font-medium">
{title}
<span className="shrink-0">{icon}</span>
</h2>
<p className="font-medium text-inherit italic text-lg">{text}</p>
</Swizzled.components.Link>
)
export { Link, AnchorLink, PageLink, WebLink, CardLink }

View file

@ -0,0 +1,6 @@
/**
* A temporary loader 'one moment please' style
* Just a spinner in this case, but could also be branded.
*/
export const TemporaryLoader = ({ Swizzled = false }) =>
Swizzled ? <Swizzled.components.Spinner /> : <div className="text-center m-auto">...</div>

View file

@ -0,0 +1,57 @@
import { useEffect } from 'react'
export const LoadingStatus = ({ Swizzled, state, update }) => {
useEffect(() => {
if (typeof state._.loading === 'object') {
for (const conf of Object.values(state._.loading)) {
if (conf.fadeTimer)
window.setTimeout(function () {
update.fadeNotify(conf.id)
}, conf.fadeTimer)
if (conf.clearTimer)
window.setTimeout(function () {
update.clearNotify(conf.id)
}, conf.clearTimer)
}
}
}, [state._, update])
if (!state._.loading || Object.keys(state._.loading).length < 1) return null
return (
<div className="fixed bottom-0 md:buttom-28 left-0 w-full z-30 md:px-4 md:mx-auto mb-4">
<div className="flex flex-col gap-2">
{Object.entries(state._.loading).map(([id, config]) => {
const conf = {
...Swizzled.config.loadingStatus.defaults,
...config,
}
const Icon =
typeof conf.icon === 'undefined'
? Swizzled.components.Spinner
: Swizzled.components[`${Swizzled.methods.capitalize(conf.icon)}Icon`] ||
Swizzled.components.Noop
return (
<div
key={id}
className={`w-full md:max-w-2xl m-auto bg-${
conf.color
} flex flex-row items-center gap-4 p-4 px-4 ${
conf.fading ? 'opacity-0' : 'opacity-100'
}
transition-opacity delay-[${
Swizzled.config.loadingStatus.timeout * 1000 - 400
}ms] duration-300
md:rounded-lg shadow text-secondary-content text-lg lg:text-xl font-medium md:bg-opacity-90`}
>
<span className="shrink-0">
<Icon />
</span>
{conf.msg}
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,3 @@
import Markdown from 'react-markdown'
export { Markdown }

View file

@ -0,0 +1,57 @@
/**
* This MeasurementsEditor component allows inline-editing of the measurements
*
* @param {object} props - The component's props
* @param {function} props.Design - The design constructor
* @param {object} props.state - The ViewWrapper state object
* @param {object} props.state.settings - The current settings
* @param {object} props.update - Helper object for updating the ViewWrapper state
* @param {object} props.Swizzled - An object holding swizzled code
* @return {function} MeasurementsEditor - React component
*/
export const MeasurementsEditor = ({ Design, update, state, Swizzled }) => {
/*
* Helper method to handle state updates for measurements
*/
const onUpdate = (m, newVal) => {
update.settings(['measurements', m], newVal)
}
return (
<div className="max-w-2xl">
<h5>{Swizzled.methods.t('pe:requiredMeasurements')}</h5>
{Object.keys(Design.patternConfig.measurements).length === 0 ? (
<p>({Swizzled.methods.t('account:none')})</p>
) : (
<div>
{Design.patternConfig.measurements.map((m) => (
<Swizzled.components.MeasurementInput
key={m}
m={m}
imperial={state.settings.units === 'imperial' ? true : false}
original={state.settings.measurements?.[m]}
update={(m, newVal) => onUpdate(m, newVal)}
id={`edit-${m}`}
/>
))}
<br />
</div>
)}
<h5>{Swizzled.methods.t('pe:optionalMeasurements')}</h5>
{Object.keys(Design.patternConfig.optionalMeasurements).length === 0 ? (
<p>({Swizzled.methods.t('account:none')})</p>
) : (
Design.patternConfig.optionalMeasurements.map((m) => (
<Swizzled.components.MeasurementInput
key={m}
m={m}
imperial={state.settings.units === 'umperial' ? true : false}
original={state.settings.measurements?.[m]}
update={(m, newVal) => onUpdate(m, newVal)}
id={`edit-${m}`}
/>
))
)}
</div>
)
}

View file

@ -0,0 +1,94 @@
const sizes = { lg: 96, md: 52, sm: 36 }
export const MeasurementsSetCard = ({
set,
onClick = false,
href = false,
useA = false,
Design = false,
language = false,
size = 'lg',
Swizzled,
}) => {
const s = sizes[size]
const { t, hasRequiredMeasurements } = Swizzled.methods
const { NoIcon, OkIcon } = Swizzled.components
const wrapperProps = {
className: `bg-base-300 aspect-square h-${s} w-${s} mb-2
mx-auto flex flex-col items-start text-center justify-between rounded-none md:rounded shadow`,
style: {
backgroundImage: `url(${Swizzled.methods.cloudImageUrl({ type: 'w500', id: set.img })})`,
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50%',
},
}
if (!set.img || set.img === 'default-avatar')
wrapperProps.style.backgroundPosition = 'bottom right'
let icon = <span></span>
let missingMeasies = ''
let linebreak = ''
const maxLength = 75
if (Design) {
const [hasMeasies, missing] = hasRequiredMeasurements(Design, set.measies)
const iconClasses = 'w-8 h-8 p-1 rounded-full -mt-2 -ml-2 shadow'
icon = hasMeasies ? (
<OkIcon className={`${iconClasses} bg-success text-success-content`} stroke={4} />
) : (
<NoIcon className={`${iconClasses} bg-error text-error-content`} stroke={3} />
)
if (missing.length > 0) {
const translated = missing.map((m) => {
return t(m)
})
let missingString = translated.join(', ')
if (missingString.length > maxLength) {
const lastSpace = missingString.lastIndexOf(', ', maxLength)
missingString = missingString.substring(0, lastSpace) + ', ' + '...'
}
const measieClasses = 'font-normal text-xs'
missingMeasies = <span className={`${measieClasses}`}>{missingString}</span>
linebreak = <br />
}
}
const inner = (
<>
{icon}
<span className="bg-neutral text-neutral-content px-4 w-full bg-opacity-50 py-2 rounded rounded-t-none font-bold leading-5">
{language ? set[`name${Swizzled.methods.capitalize(language)}`] : set.name}
{linebreak}
{missingMeasies}
</span>
</>
)
// Is it a button with an onClick handler?
if (onClick)
return (
<button {...wrapperProps} onClick={() => onClick(set)}>
{inner}
</button>
)
// Returns a link to an internal page
if (href && !useA)
return (
<Swizzled.components.Link {...wrapperProps} href={href}>
{inner}
</Swizzled.components.Link>
)
// Returns a link to an external page
if (href && useA)
return (
<a {...wrapperProps} href={href}>
{inner}
</a>
)
// Returns a div
return <div {...wrapperProps}>{inner}</div>
}

View file

@ -0,0 +1,171 @@
import { Fragment, useEffect } from 'react'
import { horFlexClasses } from '../../utils.mjs'
const iconClasses = { className: 'w-8 h-8 md:w-10 md:h-10 lg:w-12 lg:h-12 shrink-0', stroke: 1.5 }
/**
* The measurements view is loaded to update/set measurements
*
* It will be automatically loaded if we do not have all required measurements for a design.
*
* @param {Object} props - All the props
* @param {Object} props.Swizzled - An object with swizzled components, hooks, methods, and config
* @param {Function} props.Design - The design constructor
* @param {string} props.design - The design name
* @param {Object} props.state - The editor state object
* @param {Object} props.update - Helper object for updating the ViewWrapper state
* @param {Array} props.missingMeasurements - List of missing measurements for the current design
* @return {Function} MeasurementsView - React component
*/
export const MeasurementsView = ({ Design, missingMeasurements, update, Swizzled, state }) => {
// Swizzled components
const {
Accordion,
Popout,
MeasurementsEditor,
MeasurementsSetIcon,
UserSetPicker,
BookmarkIcon,
BookmarkedSetPicker,
CuratedMeasurementsSetIcon,
CuratedSetPicker,
EditIcon,
} = Swizzled.components
// Swizzled methods
const { t, designMeasurements, capitalize } = Swizzled.methods
// Swizzled config
const { config } = Swizzled
// Editor state
const { locale } = state
/*
* If there is no view set, completing measurements will switch to the view picker
* Which is a bit confusing. So in this case, set the view to measurements.
*/
useEffect(() => {
if (!config.views.includes(state.view)) update.view('measurements')
if (state._.missingMeasurements && state._.missingMeasurements.length > 0)
update.notify({ msg: t('pe:missingMeasurementsNotify'), icon: 'tip' }, 'missingMeasurements')
else update.notifySuccess(t('pe:measurementsAreOk'))
}, [state.view, update])
const loadMeasurements = (set) => {
update.settings(['measurements'], designMeasurements(Design, set.measies))
update.settings(['units'], set.imperial ? 'imperial' : 'metric')
// Save the measurement set name to pattern settings
if (set[`name${capitalize(locale)}`])
// Curated measurement set
update.settings(['metadata'], { setName: set[`name${capitalize(locale)}`] })
else if (set.name)
// User measurement set
update.settings(['metadata'], { setName: set.name })
}
// Construct accordion items based on the editor configuration
const items = []
if (config.enableBackend)
items.push(
[
<Fragment key={1}>
<div className={horFlexClasses}>
<h5 id="ownsets">{t('pe:chooseFromOwnSets')}</h5>
<MeasurementsSetIcon {...iconClasses} />
</div>
<p className="text-left">{t('pe:chooseFromOwnSetsDesc')}</p>
</Fragment>,
<UserSetPicker
key={2}
size="md"
clickHandler={loadMeasurements}
missingClickHandler={loadMeasurements}
{...{ Swizzled, Design }}
/>,
'ownSets',
],
[
<Fragment key={1}>
<div className={horFlexClasses}>
<h5 id="bookmarkedsets">{t('pe:chooseFromBookmarkedSets')}</h5>
<BookmarkIcon {...iconClasses} />
</div>
<p className="text-left">{t('pe:chooseFromBookmarkedSetsDesc')}</p>
</Fragment>,
<BookmarkedSetPicker
key={2}
size="md"
clickHandler={loadMeasurements}
missingClickHandler={loadMeasurements}
{...{ Swizzled, Design }}
/>,
'bmSets',
],
[
<Fragment key={1}>
<div className={horFlexClasses}>
<h5 id="curatedsets">{t('pe:chooseFromCuratedSets')}</h5>
<CuratedMeasurementsSetIcon {...iconClasses} />
</div>
<p className="text-left">{t('pe:chooseFromCuratedSetsDesc')}</p>
</Fragment>,
<CuratedSetPicker
key={2}
clickHandler={loadMeasurements}
{...{ Swizzled, Design, locale }}
/>,
'csets',
]
)
// Manual editing is always an option
items.push([
<Fragment key={1}>
<div className={horFlexClasses}>
<h5 id="editmeasurements">{t('pe:editMeasurements')}</h5>
<EditIcon {...iconClasses} />
</div>
<p className="text-left">{t('pe:editMeasurementsDesc')}</p>
</Fragment>,
<MeasurementsEditor key={2} {...{ Design, Swizzled, update, state }} />,
'edit',
])
return (
<>
<Swizzled.components.HeaderMenu state={state} {...{ Swizzled, update }} />
<div className="max-w-7xl mt-8 mx-auto px-4">
<h2>{t('pe:measurements')}</h2>
{missingMeasurements && missingMeasurements.length > 0 ? (
<Popout note dense noP>
<h5>{t('pe:missingMeasurementsInfo')}:</h5>
<ol className="list list-inside flex flex-row flex-wrap">
{missingMeasurements.map((m, i) => (
<li key={i}>
{i > 0 ? <span className="pr-2">,</span> : null}
<span className="font-medium">{t(`measurements:${m}`)}</span>
</li>
))}
</ol>
<p className="text-sm m-0 p-0 pt-2">
({missingMeasurements.length} {t('pe:missingMeasurements')})
</p>
</Popout>
) : (
<Popout tip dense noP>
<h5>{t('pe:measurementsAreOk')}</h5>
<div className="flex flex-row flex-wrap gap-2 mt-2">
<button className="btn btn-primary lg:btn-lg" onClick={() => update.view('draft')}>
{t('pe:view.draft.t')}
</button>
<button
className="btn btn-primary btn-outline lg:btn-lg"
onClick={() => update.view('picker')}
>
{t('pe:chooseAnotherActivity')}
</button>
</div>
</Popout>
)}
{items.length > 1 ? <Accordion items={items} /> : items}
</div>
</>
)
}

View file

@ -0,0 +1,272 @@
import { useState, useMemo } from 'react'
/** @type {String} class to apply to buttons on open menu items */
const iconButtonClass = 'btn btn-xs btn-ghost px-0 text-accent'
/**
* A generic component for handling a menu item.
* Wraps the given input in a {@see Collapse} with the appropriate buttons
* @param {String} options.name the name of the item, for using as a key
* @param {Object} options.config the configuration for the input
* @param {Sting|Boolean|Number} options.current the current value of the item
* @param {Function} options.updateFunc the function that will be called by event handlers to update the value
* @param {Function} options.t the translation function
* @param {Object} options.passProps props to pass to the Input component
* @param {Boolean} changed has the value changed from default?
* @param {React.Component} Input the input component this menu item will use
* @param {React.Component} Value a value display component this menu item will use
* @param {Boolean} allowOverride all a text input to be used to override the given input component
* @param {Number} ux the user-defined ux level
* @param {object} props.Swizzled - An object holding swizzled code
*/
export const MenuItem = ({
name,
Swizzled,
current,
updateHandler,
passProps = {},
changed,
Input = () => {},
allowOverride = false,
ux = 5,
docs,
config,
Design,
}) => {
// Local state - whether the override input should be shown
const [override, setOverride] = useState(false)
// generate properties to pass to the Input
const drillProps = useMemo(
() => ({
name,
config,
ux,
current,
updateHandler,
t: Swizzled.methods.t,
changed,
override,
Design,
...passProps,
}),
[name, config, current, updateHandler, changed, override, passProps, ux]
)
if (!config) {
console.log('no config in containers', { name, current, config })
return null
}
// don't render if this item is more advanced than the user has chosen to see
if (config.ux && config.ux > ux) return null
// get buttons for open and closed states
const buttons = []
if (allowOverride)
buttons.push(
<button
key="edit"
className={iconButtonClass}
onClick={(evt) => {
evt.stopPropagation()
setOverride(!override)
}}
>
<Swizzled.components.EditIcon
className={`w-6 h-6 ${
override ? 'bg-secondary text-secondary-content rounded' : 'text-secondary'
}`}
/>
</button>
)
const ResetButton = ({ disabled = false }) => (
<button
className={`${iconButtonClass} disabled:bg-opacity-0`}
disabled={disabled}
onClick={(evt) => {
evt.stopPropagation()
updateHandler([name], '__UNSET__')
}}
>
<Swizzled.components.ResetIcon />
</button>
)
buttons.push(<ResetButton open disabled={!changed} key="clear" />)
return (
<Swizzled.components.FormControl
label={<span className="text-base font-normal">{Swizzled.methods.t(`${name}.d`)}</span>}
id={config.name}
labelBR={<div className="flex flex-row items-center gap-2">{buttons}</div>}
labelBL={
<span
className={`text-base font-medium -mt-2 block ${changed ? 'text-accent' : 'opacity-50'}`}
>
{Swizzled.methods.t(`pe:youAreUsing${changed ? 'ACustom' : 'TheDefault'}Value`)}
</span>
}
docs={docs}
>
<Input {...drillProps} />
</Swizzled.components.FormControl>
)
}
/**
* A component for recursively displaying groups of menu items.
* Accepts any object where menu item configurations are keyed by name
* Items that are group headings are expected to have an isGroup: true property
* @param {Boolean} options.collapsible Should this group be collapsible (use false for the top level of a menu)
* @param {Number} options.ux the user-defined ux level
* @param {String} options.name the name of the group or item
* @param {Object} options.currentValues a map of current values for items in the group, keyed by name
* @param {Object} structure the configuration for the group.
* @param {React.Component} Icon the icon to display next to closed groups
* @param {React.Component} Item the component to use for menu items
* @param {Object} values a map of Value display components to be used by menu items in the group
* @param {Object} inputs a map of Input components to be used by menu items in the group
* @param {Object} passProps properties to pass to Inputs within menu items
* @param {Object} emojis a map of emojis to use as icons for groups or items
* @param {Function} updateHandler the function called by change handlers on inputs within menu items
* @param {Boolean} topLevel is this group the top level group? false for nested
* @param {Function} t translation function
* @param {object} props.Swizzled - An object holding swizzled code
*/
export const MenuItemGroup = ({
collapsible = true,
ux,
currentValues = {},
structure,
Icon,
Item = false,
inputs = {},
values = {},
passProps = {},
updateHandler,
topLevel = false,
isDesignOptionsGroup = false,
Design,
Swizzled,
state,
}) => {
if (!Item) Item = Swizzled.components.MenuItem
// map the entries in the structure
const content = Object.entries(structure).map(([itemName, item]) => {
// if it's the isGroup property, or it is false, it shouldn't be shown
if (itemName === 'isGroup' || item === false) return null
if (!item) return null
if (item.ux && ux && item.ux > ux) return null
const ItemIcon = item.icon
? item.icon
: item.isGroup
? Swizzled.components.GroupIcon
: Icon
? Icon
: () => <span role="img">fixme-icon</span>
const Value = item.isGroup
? () => (
<div className="flex flex-row gap-2 items-center font-medium">
{Object.keys(item).filter((i) => i !== 'isGroup').length}
<Swizzled.components.OptionsIcon className="w-5 h-5" />
</div>
)
: isDesignOptionsGroup
? values[Swizzled.methods.designOptionType(item)]
: values[itemName]
? values[itemName]
: () => <span>¯\_()_/¯</span>
return [
<div className="flex flex-row items-center justify-between w-full" key="a">
<div className="flex flex-row items-center gap-4 w-full">
<ItemIcon />
<span className="font-medium">
{Swizzled.methods.t([`pe:${itemName}.t`, `pe:${itemName}`])}
</span>
</div>
<div className="font-bold">
<Value
current={currentValues[itemName]}
config={item}
t={Swizzled.methods.t}
changed={Swizzled.methods.menuValueWasChanged(currentValues[itemName], item)}
Design={Design}
/>
</div>
</div>,
item.isGroup ? (
<MenuItemGroup
key={itemName}
{...{
collapsible: true,
// it's the top level if the previous level was top but not wrapped
topLevel: topLevel && !collapsible,
state,
name: itemName,
currentValues,
structure: item,
Icon,
Item,
values,
inputs,
passProps,
updateHandler,
isDesignOptionsGroup,
Design,
Swizzled,
}}
/>
) : (
<Item
key={itemName}
{...{
name: itemName,
current: currentValues[itemName],
config: item,
state,
changed: Swizzled.methods.menuValueWasChanged(currentValues[itemName], item),
Value: values[itemName],
Input: inputs[itemName],
updateHandler,
passProps,
Design,
Swizzled,
}}
/>
),
itemName,
]
})
return <Swizzled.components.SubAccordion items={content.filter((item) => item !== null)} />
}
/**
* A generic component to present the title of a menu item
* @param {String} options.name the name of the item, to act as its translation key
* @param {Function} options.t the translation function
* @param {String|React.Component} options.current a the current value, or a Value component to display it
* @param {Boolean} options.open is the menu item open?
* @param {String} options.emoji the emoji icon of the menu item
*/
export const MenuItemTitle = ({
name,
current = null,
open = false,
emoji = '',
Icon = false,
Swizzled,
}) => (
<div className={`flex flex-row gap-1 items-center w-full ${open ? '' : 'justify-between'}`}>
<span className="font-medium capitalize flex flex-row gap-2">
{Icon ? <Icon /> : <span role="img">{emoji}</span>}
{Swizzled.methods.t([`${name}.t`, name])}
</span>
<span className="font-bold">{current}</span>
</div>
)

View file

@ -0,0 +1,135 @@
/**
* The core settings menu
* @param {Object} options.update settings and ui update functions
* @param {Object} options.settings core settings
* @param {Object} options.patternConfig the configuration from the pattern
* @param {String} options.language the menu language
* @param {Object} options.account the user account data
* @param {object} props.Swizzled - An object holding swizzled code
*/
export const CoreSettingsMenu = ({ update, state, language, Design, Swizzled }) => {
const structure = Swizzled.methods.menuCoreSettingsStructure({
language,
units: state.settings?.units,
sabool: state.settings?.sabool,
parts: Design.patternConfig.draftOrder,
})
const inputs = {
complete: Swizzled.components.MenuListInput,
expand: Swizzled.components.MenuListInput,
margin: Swizzled.components.MenuMmInput,
only: Swizzled.components.MenuOnlySettingInput,
paperless: Swizzled.components.MenuBoolInput,
sabool: Swizzled.components.MenuBoolInput,
samm: Swizzled.components.MenuMmInput,
scale: Swizzled.components.MenuSliderInput,
units: Swizzled.components.MenuBoolInput,
}
const values = {
complete: Swizzled.components.MenuListValue,
expand: Swizzled.components.MenuListValue,
margin: Swizzled.components.MenuMmValue,
only: Swizzled.components.MenuOnlySettingValue,
paperless: Swizzled.components.MenuListValue,
sabool: Swizzled.components.MenuListValue,
samm: Swizzled.components.MenuMmValue,
scale: Swizzled.components.MenuScaleSettingValue,
units: Swizzled.components.MenuListValue,
}
return (
<Swizzled.components.MenuItemGroup
{...{
structure,
ux: state.ui.ux,
currentValues: state.settings || {},
Icon: Swizzled.components.SettingsIcon,
Item: (props) => (
<Swizzled.components.CoreSetting
updateHandler={update}
{...{ inputs, values, Swizzled, Design }}
{...props}
/>
),
isFirst: true,
name: 'pe:designOptions',
language: state.locale,
passProps: {
ux: state.ui.ux,
settings: state.settings,
patternConfig: Design.patternConfig,
toggleSa: update.toggleSa,
},
updateHandler: update.settings,
isDesignOptionsGroup: false,
Swizzled,
state,
Design,
inputs,
values,
}}
/>
)
}
/** A wrapper for {@see MenuItem} to handle core settings-specific business */
export const CoreSetting = ({
Swizzled,
name,
config,
ux,
updateHandler,
current,
passProps,
...rest
}) => {
// is toggling allowed?
const allowToggle = ux > 3 && config.list?.length === 2
const handlerArgs = {
updateHandler,
current,
config,
...passProps,
}
/*
* Load a specific update handler if one is configured
*/
const handler = Swizzled.config.menuCoreSettingsHandlerMethods?.[name.toLowerCase()]
? Swizzled.methods[Swizzled.config.menuCoreSettingsHandlerMethods[name.toLowerCase()]](
handlerArgs
)
: updateHandler
return (
<Swizzled.components.MenuItem
{...{
name,
config,
ux,
current,
passProps,
...rest,
allowToggle,
updateHandler: handler,
}}
/>
)
}
export const ClearAllButton = ({ setSettings, compact = false, Swizzled }) => {
return (
<div className={`${compact ? '' : 'text-center mt-8'}`}>
<button
className={`justify-self-center btn btn-error btn-outline ${compact ? 'btn-sm' : ''}`}
onClick={() => setSettings({})}
>
<Swizzled.components.TrashIcon />
{Swizzled.methods.t('clearSettings')}
</button>
</div>
)
}

View file

@ -0,0 +1,112 @@
import { useCallback, useMemo } from 'react'
/**
* The design options menu
* @param {object} props.Design - An object holding the Design instance
* @param {String} props.isFirst - Boolean indicating whether this is the first/top entry of the menu
* @param {Object} props.state - Object holding state
* @param {Object} props.update - Object holding state handlers
* @param {object} props.Swizzled - An object holding swizzled code
*/
export const DesignOptionsMenu = ({ Design, isFirst = true, state, update, Swizzled }) => {
const structure = useMemo(
() => Swizzled.methods.menuDesignOptionsStructure(Design.patternConfig.options, state.settings),
[Design.patternConfig, state.settings]
)
const updateHandler = useCallback(
(name, value = '__UNSET__') => update.settings(['options', ...name], value),
[update.settings]
)
const drillProps = { Design, state, update }
const inputs = {
bool: (props) => <Swizzled.components.MenuBoolInput {...drillProps} {...props} />,
constant: (props) => <Swizzled.components.MenuConstantInput {...drillProps} {...props} />,
count: (props) => (
<Swizzled.components.MenuSliderInput
{...drillProps}
{...props}
config={{ ...props.config, step: 1 }}
/>
),
deg: (props) => <Swizzled.components.MenuDegInput {...drillProps} {...props} />,
list: (props) => (
<Swizzled.components.MenuListInput {...drillProps} {...props} isDesignOption />
),
mm: () => <span>FIXME: Mm options are deprecated. Please report this </span>,
pct: (props) => <Swizzled.components.MenuPctInput {...drillProps} {...props} />,
}
const values = {
bool: (props) => <Swizzled.components.MenuBoolValue {...drillProps} {...props} />,
constant: (props) => <Swizzled.components.MenuConstantOptionValue {...drillProps} {...props} />,
count: (props) => <Swizzled.components.MenuCountOptionValue {...drillProps} {...props} />,
deg: (props) => <Swizzled.components.MenuDegOptionValue {...drillProps} {...props} />,
list: (props) => <Swizzled.components.MenuListOptionValue {...drillProps} {...props} />,
mm: (props) => <Swizzled.components.MenuMmOptionValue {...drillProps} {...props} />,
pct: (props) => <Swizzled.components.MenuPctOptionValue {...drillProps} {...props} />,
}
return (
<Swizzled.components.MenuItemGroup
{...{
structure,
ux: state.ui.ux,
currentValues: state.settings.options || {},
Icon: Swizzled.components.OptionsIcon,
Item: (props) => (
<Swizzled.components.DesignOption
{...{ inputs, values, Swizzled, update, Design }}
{...props}
/>
),
isFirst,
name: 'pe:designOptions',
language: state.locale,
passProps: {
ux: state.ui.ux,
settings: state.settings,
patternConfig: Design.patternConfig,
},
updateHandler,
isDesignOptionsGroup: true,
Swizzled,
state,
Design,
inputs,
values,
}}
/>
)
}
/**
* A wrapper for {@see MenuItem} to handle design option-specific business
* @param {Object} options.config the config for the item
* @param {Object} options.settings core settings
* @param {Object} options.rest the rest of the props
*/
export const DesignOption = ({ config, settings, ux, inputs, values, Swizzled, ...rest }) => {
const type = Swizzled.methods.designOptionType(config)
const Input = inputs[type]
const Value = values[type]
const allowOverride = ['pct', 'count', 'deg'].includes(type)
const allowToggle = (ux > 3 && type === 'bool') || (type == 'list' && config.list.length === 2)
// Hide option?
if (config?.hide || (typeof config?.hide === 'function' && config.hide(settings))) return null
return (
<Swizzled.components.MenuItem
{...{
config,
ux,
...rest,
Swizzled,
Input,
Value,
allowOverride,
allowToggle,
}}
/>
)
}

View file

@ -0,0 +1,52 @@
export const DraftMenu = ({ Design, pattern, state, Swizzled, update }) => {
// Swizzled methods
const { t } = Swizzled.methods
// Swizzled components
const { FlagsAccordionTitle, FlagsAccordionEntries, Accordion } = Swizzled.components
const menuProps = { Design, state, Swizzled, pattern, update }
const sections = [
{
name: 'designOptions',
icon: <Swizzled.components.OptionsIcon className="w-8 h-8" />,
menu: <Swizzled.components.DesignOptionsMenu {...menuProps} />,
},
{
name: 'coreSettings',
icon: <Swizzled.components.SettingsIcon className="w-8 h-8" />,
menu: <Swizzled.components.CoreSettingsMenu {...menuProps} />,
},
{
name: 'uiPreferences',
icon: <Swizzled.components.UiIcon className="w-8 h-8" />,
menu: <Swizzled.components.UiPreferencesMenu {...menuProps} />,
},
]
const items = []
items.push(
...sections.map((section) => [
<>
<h5 className="flex flex-row gap-2 items-center justify-between w-full">
<span>{t(`pe:${section.name}.t`)}</span>
{section.icon}
</h5>
<p className="text-left">{t(`pe:${section.name}.d`)}</p>
</>,
section.menu,
section.name,
])
)
const flags = pattern.setStores?.[0]?.plugins?.['plugin-annotations']?.flags
if (flags)
items.push([
<FlagsAccordionTitle key={1} {...{ flags, Swizzled }} />,
<FlagsAccordionEntries {...{ update, state, flags }} key={2} />,
'flags',
])
return <Accordion items={items} />
}

View file

@ -0,0 +1,459 @@
import { useMemo, useCallback, useState } from 'react'
/** A boolean version of {@see MenuListInput} that sets up the necessary configuration */
export const MenuBoolInput = (props) => {
const { name, config, Swizzled } = props
const boolConfig = useBoolConfig(name, config)
return <Swizzled.components.MenuListInput {...props} config={boolConfig} />
}
/** A placeholder for an input to handle constant values */
export const MenuConstantInput = ({
type = 'number',
name,
current,
updateHandler,
changed,
config,
}) => (
<>
<input
type={type}
className={`
input input-bordered w-full text-base-content
input-${changed ? 'secondary' : 'accent'}
`}
value={changed ? current : config.dflt}
onChange={(evt) => updateHandler([name], evt.target.value)}
/>
</>
)
/** A {@see MenuSliderInput} to handle degree values */
export const MenuDegInput = (props) => {
const { updateHandler } = props
const { MenuSliderInput } = props.Swizzled.components
const { round } = props.Swizzled.methods
const degUpdateHandler = useCallback(
(path, newVal) => {
updateHandler(path, newVal === undefined ? undefined : Number(newVal))
},
[updateHandler]
)
return (
<MenuSliderInput {...props} suffix="°" valFormatter={round} updateHandler={degUpdateHandler} />
)
}
/**
* An input for selecting and item from a list
* @param {String} options.name the name of the property this input changes
* @param {Object} options.config configuration for the input
* @param {String|Number} options.current the current value of the input
* @param {Function} options.updateFunc the function called by the event handler to update the value
* @param {Boolean} options.compact include descriptions with the list items?
* @param {Function} options.t translation function
* @param {String} design name of the design
* @param {Boolean} isDesignOption Whether or not it's a design option
*/
export const MenuListInput = ({
name,
config,
current,
updateHandler,
compact = false,
t,
changed,
design,
isDesignOption = false,
Swizzled,
}) => {
const handleChange = useSharedHandlers({
dflt: config.dflt,
updateHandler,
name,
isDesignOption,
})
return config.list.map((entry) => {
const titleKey = config.choiceTitles
? config.choiceTitles[entry]
: isDesignOption
? `${design}:${name}.${entry}`
: `${name}.o.${entry}`
const title = config.titleMethod ? config.titleMethod(entry, t) : t(`${titleKey}.t`)
const desc = config.valueMethod ? config.valueMethod(entry, t) : t(`${titleKey}.d`)
const sideBySide = config.sideBySide || desc.length + title.length < 42
return (
<Swizzled.components.ButtonFrame
dense={config.dense || false}
key={entry}
active={
changed
? Array.isArray(current)
? current.includes(entry)
: current === entry
: entry === config.dflt
}
onClick={() => handleChange(entry)}
>
<div
className={`w-full flex items-start ${
sideBySide ? 'flex-row justify-between gap-2' : 'flex-col'
}`}
>
<div className="font-bold text-lg shrink-0">{title}</div>
{compact ? null : <div className="text-base font-normal">{desc}</div>}
</div>
</Swizzled.components.ButtonFrame>
)
})
}
/** a toggle input for list/boolean values */
export const MenuListToggle = ({ config, changed, updateHandler, name }) => {
const boolConfig = useBoolConfig(name, config)
const handleChange = useSharedHandlers({ dflt: boolConfig.dflt, updateHandler, name })
const dfltIndex = boolConfig.list.indexOf(boolConfig.dflt)
const doToggle = () =>
handleChange(boolConfig.list[changed ? dfltIndex : Math.abs(dfltIndex - 1)])
const checked = boolConfig.dflt == false ? changed : !changed
return (
<input
type="checkbox"
className={`toggle ${changed ? 'toggle-accent' : 'toggle-secondary'}`}
checked={checked}
onChange={doToggle}
onClick={(evt) => evt.stopPropagation()}
/>
)
}
export const MenuMmInput = (props) => {
const { units, updateHandler, current, config } = props
const { MenuSliderInput } = props.Swizzled.components
const mmUpdateHandler = useCallback(
(path, newCurrent) => {
const calcCurrent =
typeof newCurrent === 'undefined'
? undefined
: props.Swizzled.methods.measurementAsMm(newCurrent, units)
updateHandler(path, calcCurrent)
},
[updateHandler, units]
)
// add a default step that's appropriate to the unit. can be overwritten by config
const defaultStep = units === 'imperial' ? 0.125 : 0.1
return (
<MenuSliderInput
{...props}
{...{
config: {
step: defaultStep,
...config,
dflt: props.Swizzled.methods.measurementAsUnits(config.dflt, units),
},
current:
current === undefined
? undefined
: props.Swizzled.methods.measurementAsUnits(current, units),
updateHandler: mmUpdateHandler,
valFormatter: (val) =>
units === 'imperial' ? props.Swizzle.methods.formatFraction128(val, null) : val,
suffix: units === 'imperial' ? '"' : 'cm',
}}
/>
)
}
/**
* A number input that accepts comma or period decimal separators.
* Because our use case is almost never going to include thousands, we're using a very simple way of accepting commas:
* The validator checks for the presence of a single comma or period followed by numbers
* The parser replaces a single comma with a period
*
* optionally accepts fractions
* @param {Number} options.val the value of the input
* @param {Function} options.onUpdate a function to handle when the value is updated to a valid value
* @param {Boolean} options.fractions should the input allow fractional input
*/
//export const MenuNumberInput = ({
// value,
// onUpdate,
// onMount,
// className,
// fractions = true,
// min = -Infinity,
// max = Infinity,
// swizzled,
//}) => {
// const valid = useRef(validateVal(value, fractions, min, max))
//
// const handleChange = useCallback(
// (newVal) => {
// // only actually update if the value is valid
// if (typeof onUpdate === 'function') {
// onUpdate(valid.current, newVal)
// }
// },
// [onUpdate, valid]
// )
//
// // onChange
// const onChange = useCallback(
// (evt) => {
// const newVal = evt.target.value
// // set validity so it will display
// valid.current = validateVal(newVal, fractions, min, max)
//
// // handle the change
// handleChange(newVal)
// },
// [fractions, min, max, valid]
// )
//
// const val = typeof value === 'undefined' ? config.dflt : value
//
// useEffect(() => {
// if (typeof onMount === 'function') {
// onMount(valid.current)
// }
// }, [onMount, valid])
//
// return (
// <input
// type="text"
// inputMode="number"
// className={`input input-secondary ${className || 'input-sm grow text-base-content'}
// ${valid.current === false && 'input-error'}
// ${valid.current && 'input-success'}
// `}
// value={val}
// onChange={onChange}
// />
// )
//}
/** A {@see SliderInput} to handle percentage values */
export const MenuPctInput = ({ current, changed, updateHandler, config, Swizzled, ...rest }) => {
const factor = 100
let pctCurrent = changed ? Swizzled.methods.menuRoundPct(current, factor) : current
const pctUpdateHandler = useCallback(
(path, newVal) =>
updateHandler(
path,
newVal === undefined ? undefined : Swizzled.methods.menuRoundPct(newVal, 1 / factor)
),
[updateHandler]
)
return (
<Swizzled.components.MenuSliderInput
{...{
...rest,
config: { ...config, dflt: Swizzled.methods.menuRoundPct(config.dflt, factor) },
current: pctCurrent,
updateHandler: pctUpdateHandler,
suffix: '%',
valFormatter: Swizzled.methods.round,
changed,
}}
/>
)
}
/**
* An input component that uses a slider to change a number value
* @param {String} options.name the name of the property being changed by the input
* @param {Object} options.config configuration for the input
* @param {Number} options.current the current value of the input
* @param {Function} options.updateHandler the function called by the event handler to update the value
* @param {Function} options.t translation function
* @param {Boolean} options.override open the text input to allow override of the slider?
* @param {String} options.suffix a suffix to append to value labels
* @param {Function} options.valFormatter a function that accepts a value and formats it for display as a label
* @param {Function} options.setReset a setter for the reset function on the parent component
*/
export const MenuSliderInput = ({
name,
config,
current,
updateHandler,
override,
suffix = '',
valFormatter = (val) => val,
setReset,
children,
changed,
Swizzled,
}) => {
const { max, min } = config
const handleChange = useSharedHandlers({
current,
dflt: config.dflt,
updateHandler,
name,
setReset,
})
const val = typeof current === 'undefined' ? config.dflt : current
if (override)
return (
<>
<div className="flex flex-row justify-between">
<Swizzled.components.MenuEditOption
{...{
config,
Swizzled,
current: val,
handleChange,
min,
max,
}}
/>
</div>
{children}
</>
)
return (
<>
<div className="flex flex-row justify-between">
<span className="opacity-50">
<span dangerouslySetInnerHTML={{ __html: valFormatter(min) + suffix }} />
</span>
<span className={`font-bold ${val === config.dflt ? 'text-secondary' : 'text-accent'}`}>
<span dangerouslySetInnerHTML={{ __html: valFormatter(val) + suffix }} />
</span>
<span className="opacity-50">
<span dangerouslySetInnerHTML={{ __html: valFormatter(max) + suffix }} />
</span>
</div>
<input
type="range"
{...{ min, max, value: val, step: config.step || 0.1 }}
onChange={(evt) => handleChange(evt.target.value)}
className={`
range range-sm mt-1
${changed ? 'range-accent' : 'range-secondary'}
`}
/>
{children}
</>
)
}
export const MenuEditOption = (props) => {
const [manualEdit, setManualEdit] = useState(props.current)
const { config, handleChange, Swizzled } = props
const type = Swizzled.methods.designOptionType(config)
const onUpdate = useCallback(
(validVal) => {
if (validVal !== null && validVal !== false) handleChange(validVal)
},
[handleChange]
)
if (!['pct', 'count', 'deg', 'mm'].includes(type))
return <p>This design option type does not have a component to handle manual input.</p>
return (
<div className="form-control mb-2 w-full">
<label className="label font-medium text-accent">
<em>
{Swizzled.methods.t('pe:enterCustomValue')} ({Swizzled.config.menuOptionEditLabels[type]})
</em>
</label>
<label className="input-group input-group-sm flex flex-row items-center gap-2 -mt-4">
<Swizzled.components.NumberInput value={manualEdit} update={setManualEdit} />
<button className="btn btn-secondary mt-4" onClick={() => onUpdate(manualEdit)}>
<Swizzled.components.ApplyIcon />
</button>
</label>
</div>
)
}
/**
* A hook to get the change handler for an input.
* @param {Number|String|Boolean} options.dflt the default value for the input
* @param {Function} options.updateHandler the onChange
* @param {string} options.name the name of the property being changed
* @return the change handler for the input
*/
const useSharedHandlers = ({ dflt, updateHandler, name, isDesignOption }) => {
return useCallback(
(newCurrent = '__UNSET__') => {
/*
* When a design option is set to the default, just unset it instead
* This is both more efficient, and makes it easy to see in which ways
* your pattern differs from the defaults
*/
if (isDesignOption && newCurrent === dflt) newCurrent = '__UNSET__'
updateHandler([name], newCurrent)
},
[dflt, updateHandler, name, isDesignOption]
)
}
/** get the configuration that allows a boolean value to use the list input */
const useBoolConfig = (name, config) => {
return useMemo(
() => ({
list: [false, true],
choiceTitles: {
false: `${name}No`,
true: `${name}Yes`,
},
valueTitles: {
false: 'no',
true: 'yes',
},
...config,
}),
[name, config]
)
}
/** an input for the 'only' setting. toggles individual parts*/
export const MenuOnlySettingInput = (props) => {
const { Swizzled, config } = props
const { t } = Swizzled.methods
config.sideBySide = true
config.titleMethod = (entry, t) => {
const chunks = entry.split('.')
return <span className="font-medium text-base">{t(`${chunks[0]}:${chunks[1]}`)}</span>
}
config.valueMethod = (entry) => (
<span className="text-sm">{Swizzled.methods.capitalize(entry.split('.')[0])}</span>
)
config.dense = true
// Sort alphabetically (translated)
const order = []
for (const part of config.list) {
const [ns, name] = part.split('.')
order.push(t(`${ns}:${name}`) + `|${part}`)
}
order.sort()
config.list = order.map((entry) => entry.split('|')[1])
return <Swizzled.components.MenuListInput {...props} />
}
export const MenuUxSettingInput = (props) => {
const { state, update, Swizzled } = props
return (
<Swizzled.components.MenuListInput {...props} updateHandler={update.ui} current={state.ui.ux} />
)
}

View file

@ -0,0 +1,142 @@
import { mergeOptions } from '@freesewing/core'
/** Displays that constant values are not implemented in the front end */
export const MenuConstantOptionValue = () => (
<span className="text-error">FIXME: No ConstantOptionvalue implemented</span>
)
/** Displays a count value*/
export const MenuCountOptionValue = ({ Swizzled, config, current, changed }) => (
<Swizzled.components.MenuShowValue {...{ current, changed, dflt: config.count }} />
)
/** Displays a degree value */
export const MenuDegOptionValue = ({ config, current, changed, Swizzled }) => (
<Swizzled.components.MenuHighlightValue changed={changed}>
{' '}
{changed ? current : config.deg}&deg;
</Swizzled.components.MenuHighlightValue>
)
/**
* A component to highlight a changed value
* @param {Boolean} changed - Whether the value is changed or not
* @param {Function} children - The React children
*/
export const MenuHighlightValue = ({ changed, children }) => (
<span className={changed ? 'text-accent' : ''}> {children} </span>
)
/** Displays a list option value */
export const MenuListOptionValue = (props) => (
<MenuListValue
{...props}
t={(input) => props.Swizzled.methods.t(`${props.design}:${props.config.name}.${input}.t`)}
/>
)
/**
* Displays the correct, translated value for a list
* @param {String|Boolean} options.current the current value, if it has been changed
* @param {Function} options.t a translation function
* @param {Object} options.config the item config
* @param {Boolean} options.changed has the value been changed?
* @param {object} props.Swizzled - An object holding swizzled code
*/
export const MenuListValue = ({ current, config, changed, Swizzled }) => {
// get the values
const val = changed ? current : config.dflt
// key will be based on a few factors
let key
// are valueTitles configured?
if (config.valueTitles) key = config.valueTitles[val]
// if not, is the value a string
else if (typeof val === 'string') key = val
// otherwise stringify booleans
else if (val) key = <Swizzled.components.BoolYesIcon />
else key = <Swizzled.components.BoolNoIcon />
const translated =
config.doNotTranslate || typeof key !== 'string' ? key : Swizzled.methods.t(key)
return (
<Swizzled.components.MenuHighlightValue changed={changed}>
{translated}
</Swizzled.components.MenuHighlightValue>
)
}
/** Displays the corrent, translated value for a boolean */
export const MenuBoolValue = MenuListOptionValue
/** Displays the MmOptions are not supported */
export const MenuMmOptionValue = () => (
<span className="text-error">FIXME: No Mm Options are not supported</span>
)
/** Displays a formated mm value based on the current units */
export const MenuMmValue = ({ current, config, units, changed, Swizzled }) => (
<Swizzled.components.MenuHighlightValue changed={changed}>
<span
dangerouslySetInnerHTML={{
__html: Swizzled.methods.formatMm(changed ? current : config.dflt, units),
}}
/>
</Swizzled.components.MenuHighlightValue>
)
/** Displays the current percentage value, and the absolute value if configured
*
**************************************************************************
* SliderIcon Title THIS *
* min% max% *
* ----------------------0----------------------------------------------- *
* msg PencilIcon ResetIcon *
**************************************************************************
* */
export const MenuPctOptionValue = ({
config,
current,
settings,
changed,
patternConfig,
Swizzled,
}) => {
const val = changed ? current : config.pct / 100
return (
<Swizzled.components.MenuHighlightValue changed={changed}>
{Swizzled.methods.formatPercentage(val)}
{config.toAbs && settings?.measurements
? ` | ${Swizzled.methods.formatMm(
config.toAbs(val, settings, mergeOptions(settings, patternConfig.options))
)}`
: null}
</Swizzled.components.MenuHighlightValue>
)
}
/**
* A component to display a value, highligting it if it changed
* @param {Number|String|Boolean} options.current - The current value, if it has been changed
* @param {Number|String|Boolean} options.dflt - The default value
* @param {Boolean} options.changed - Has the value been changed?
*/
export const MenuShowValue = ({ Swizzled, current, dflt, changed }) => {
const { MenuHighlightValue } = Swizzled.components
return <MenuHighlightValue changed={changed}> {changed ? current : dflt} </MenuHighlightValue>
}
export const MenuScaleSettingValue = ({ Swizzled, current, config, changed }) => (
<Swizzled.components.MenuHighlightValue current={current} dflt={config.dflt} changed={changed} />
)
export const MenuOnlySettingValue = ({ Swizzled, current, config }) => (
<Swizzled.components.MenuHighlightValue
current={current?.length}
dflt={config.parts.length}
changed={current !== undefined}
/>
)

View file

@ -0,0 +1,60 @@
export const UiPreferencesMenu = ({ Swizzled, update, state, Design }) => {
const structure = Swizzled.methods.menuUiPreferencesStructure()
const drillProps = { Design, state, update }
const inputs = {
ux: (props) => <Swizzled.components.MenuUxSettingInput {...drillProps} {...props} />,
aside: (props) => <Swizzled.components.MenuListInput {...drillProps} {...props} />,
kiosk: (props) => <Swizzled.components.MenuListInput {...drillProps} {...props} />,
rotate: (props) => <Swizzled.components.MenuListInput {...drillProps} {...props} />,
renderer: (props) => <Swizzled.components.MenuListInput {...drillProps} {...props} />,
}
const values = {
ux: (props) => <Swizzled.components.Ux ux={state.ui.ux} {...props} />,
aside: Swizzled.components.MenuListValue,
kiosk: Swizzled.components.MenuListValue,
rotate: Swizzled.components.MenuListValue,
renderer: Swizzled.components.MenuListValue,
}
return (
<Swizzled.components.MenuItemGroup
{...{
structure,
ux: state.ui?.ux,
currentValues: state.ui || {},
Item: (props) => (
<Swizzled.components.UiPreference
updateHandler={update}
{...{ inputs, values, Swizzled, Design }}
{...props}
/>
),
isFirst: true,
name: 'pe:uiPreferences',
language: state.locale,
passProps: {
ux: state.ui?.ux,
settings: state.settings,
patternConfig: Design.patternConfig,
},
updateHandler: update.ui,
isDesignOptionsGroup: false,
Swizzled,
state,
Design,
inputs,
values,
}}
/>
)
}
export const UiPreference = ({ Swizzled, name, ux, ...rest }) => (
<Swizzled.components.MenuItem
{...rest}
name={name}
allowToggle={!['ux', 'view'].includes(name) && ux > 3}
ux={ux}
/>
)

View file

@ -0,0 +1,5 @@
/*
* When debugging, you can override this component to catch errors
* as it is typically used when a component can't be found.
*/
export const Null = () => null

View file

@ -0,0 +1,34 @@
/**
* A layout for views that include a drafted pattern
*
* @param {object} settings - The pattern settings/state
* @param {object} ui - The UI settings/state
* @param {object} update - Object holding methods to manipulate state
* @param {function} Design - The Design contructor
* @param {object] pattern - The drafted pattern
* @param {object} props.Swizzled - An object holding swizzled code
*/
export const PatternLayout = (props) => {
const { menu = null, Design, pattern, update, Swizzled } = props
return (
<Swizzled.components.ZoomContextProvider>
<div className="flex flex-col h-full">
<Swizzled.components.HeaderMenu
state={props.state}
{...{ Swizzled, update, Design, pattern }}
/>
<div className="flex lg:flex-row grow lg:max-h-[90vh] max-h-[calc(100vh-3rem)] h-full py-4 lg:mt-6">
<div className="lg:w-2/3 flex flex-col h-full grow px-4">{props.output}</div>
{menu ? (
<div
className={`hidden xl:block w-1/3 shrink grow-0 lg:p-4 max-w-2xl h-full overflow-scroll`}
>
{menu}
</div>
) : null}
</div>
</div>
</Swizzled.components.ZoomContextProvider>
)
}

View file

@ -0,0 +1,103 @@
// __SDEFILE__ - This file is a dependency for the stand-alone environment
import { useState } from 'react'
const colors = {
comment: 'secondary',
note: 'primary',
tip: 'accent',
warning: 'error',
error: 'error',
fixme: 'warning',
link: 'secondary',
related: 'info',
tldr: 'info',
none: '',
}
/**
* This popout component is a way to make some content stand out
*
* @param {object} components - Object holding possibly swizzled components
* @param {object} methods - Object holding possibly swizzled methods
*/
export const Popout = (props) => {
/*
* Load (swizzled) components
*/
const { CloseIcon } = props.Swizzled.components
/*
* Load (swizzled) methods
*/
const { t } = props.Swizzled.methods
const [hide, setHide] = useState(false)
if (hide) return null
let type = 'none'
for (const t in colors) {
if (props[t]) type = t
}
const color = colors[type]
const { className = '' } = props
return props.compact ? (
<div
className={`relative ${
props.dense ? 'my-1' : 'my-8'
} bg-${color} bg-opacity-5 -ml-4 -mr-4 sm:ml-0 sm:mr-0 ${className}`}
>
<div
className={`
border-y-4 sm:border-0 sm:border-l-4 px-4
shadow text-base border-${color}
flex flex-row items-center
`}
>
<div className={`font-bold uppercase text-${color}`}>
{props.title || (
<>
<span>{t(`pe:${type}`).toUpperCase()}</span>
<span className="px-3">|</span>
</>
)}
</div>
<div className="popout-content">{props.noP ? props.children : <p>{props.children}</p>}</div>
</div>
</div>
) : (
<div
className={`relative my-8 bg-${color} bg-opacity-5 -ml-4 -mr-4 sm:ml-0 sm:mr-0 ${className}`}
>
<div
className={`
border-y-4 sm:border-0 sm:border-l-4 px-6 sm:px-8 py-4 sm:py-2
shadow text-base border-${color}
`}
>
<div className={`font-bold flex flex-row gap-1 items-end justify-between`}>
<div>
<span className={`font-bold uppercase text-${color}`}>
{type === 'tldr' ? 'TL;DR' : t(`pe:${type}`).toUpperCase()}
</span>
<span className={`font-normal text-base text-${color}`}>
{type === 'comment' && (
<>
{' '}
by <b>{props.by}</b>
</>
)}
</span>
</div>
{props.hideable && (
<button onClick={() => setHide(true)} className="hover:text-secondary" title="Close">
<CloseIcon />
</button>
)}
</div>
<div className="py-1 first:mt-0 popout-content">{props.children}</div>
{type === 'comment' && <div className={`font-bold italic text-${color}`}>{props.by}</div>}
</div>
</div>
)
}

View file

@ -0,0 +1,214 @@
import { useState } from 'react'
import yaml from 'js-yaml'
export const SaveView = ({ Swizzled, state, update }) => {
// Hooks
const backend = Swizzled.hooks.useBackend()
const { t } = Swizzled.methods
// State
const [name, setName] = useState(
`${Swizzled.methods.capitalize(state.design)} / ${Swizzled.methods.shortDate(status.locale)}`
)
const [withNotes, setWithNotes] = useState(false)
const [notes, setNotes] = useState('')
const [savedId, setSavedId] = useState()
const [bookmarkedId] = useState() // FIXME
const [saveAs] = useState(false) // FIXME
const addSettingsToNotes = () => {
setNotes(
notes +
'\n\n#### ' +
Swizzled.methods.t('pe:settings') +
'\n\n' +
'```yaml\n' +
yaml.dump(state.settings) +
'````'
)
}
const saveAsNewPattern = async () => {
const loadingId = 'savePatternAs'
update.startLoading(loadingId)
const patternData = {
design: state.design,
name,
public: false,
settings: state.settings,
data: {},
}
if (withNotes) patternData.notes = notes
const result = await backend.createPattern(patternData)
if (result.success) {
const id = result.data.pattern.id
update.stopLoading(loadingId)
update.view('draft')
update.notifySuccess(
<span>
{t('pe:patternSavedAs')}:{' '}
<Swizzled.components.Link
href={`/account/pattern?id=${id}`}
className={`${Swizzled.config.classes.link} text-secondary-content`}
>
/account/pattern?id={id}
</Swizzled.components.Link>
</span>,
id
)
} else update.notifyFailure('oops', loadingId)
}
const savePattern = async () => {
//setLoadingStatus([true, 'savingPattern'])
const patternData = {
design: state.design,
settings: state.settings,
name,
public: false,
data: {},
}
const result = await backend.updatePattern(saveAs.pattern, patternData)
if (result.success) {
//setLoadingStatus([
// true,
// <>
// {t('status:patternSaved')} <small>[#{saveAs.pattern}]</small>
// </>,
// true,
// true,
//])
setSavedId(saveAs.pattern)
update.notify({ color: 'success', msg: 'boom' }, saveAs.pattern)
} //else setLoadingStatus([true, 'backendError', true, false])
}
//const bookmarkPattern = async () => {
// setLoadingStatus([true, 'creatingBookmark'])
// const result = await backend.createBookmark({
// type: 'pattern',
// title: name,
// url: window.location.pathname + window.location.search + window.location.hash,
// })
// if (result.success) {
// const id = result.data.bookmark.id
// setLoadingStatus([
// true,
// <>
// {t('status:bookmarkCreated')} <small>[#{id}]</small>
// </>,
// true,
// true,
// ])
// setBookmarkedId(id)
// } else setLoadingStatus([true, 'backendError', true, false])
//}
return (
<Swizzled.components.AuthWrapper>
<Swizzled.components.HeaderMenu {...{ state, update }} />
<div className="m-auto mt-8 max-w-2xl px-4">
{saveAs && saveAs.pattern ? (
<>
<h2>{t('pe:savePattern')}</h2>
{savedId && (
<Swizzled.components.Popout link>
<h5>{t('workbend:patternSaved')}</h5>
{t('pe:see')}:{' '}
<Swizzled.components.PageLink
href={`/account/patterns/${savedId}`}
txt={`/account/patterns/${savedId}`}
/>
</Swizzled.components.Popout>
)}
<button
className={`${Swizzled.config.classeshorFlexNoSm} btn btn-primary btn-lg w-full mt-2 my-8`}
onClick={savePattern}
>
<Swizzled.components.SaveIcon className="h-8 w-8" />
{t('pe:savePattern')} #{saveAs.pattern}
</button>
</>
) : null}
<h2>{t('pe:saveAsNewPattern')}</h2>
{bookmarkedId && (
<Swizzled.components.Popout link>
<h5>{t('pe:patternBookmarkCreated')}</h5>
{t('pe:see')}:{' '}
<Swizzled.components.PageLink
href={`/account/bookmarks/${bookmarkedId}`}
txt={`/account/bookmarks/${bookmarkedId}`}
/>
</Swizzled.components.Popout>
)}
<div className="mb-4">
<Swizzled.components.StringInput
label={t('pe:patternTitle')}
current={name}
update={setName}
valid={Swizzled.methods.notEmpty}
labelBR={
<>
{withNotes ? (
<div className="flex flex-row items-center gap-4">
<button
className={`font-bold ${Swizzled.config.classes.link}`}
onClick={() => setWithNotes(false)}
>
{t('pe:hideNotes')}
</button>
<button
className={`font-bold ${Swizzled.config.classes.link}`}
onClick={addSettingsToNotes}
>
{t('pe:addSettingsToNotes')}
</button>
</div>
) : (
<button
className={`font-bold ${Swizzled.config.classes.link}`}
onClick={() => setWithNotes(true)}
>
{t('pe:addNotes')}
</button>
)}
</>
}
/>
{withNotes ? (
<Swizzled.components.MarkdownInput
label={t('pe:patternNotes')}
current={notes}
update={setNotes}
/>
) : null}
<div className="flex flex-row gap-2 mt-8">
<button
className={`btn btn-primary btn-lg btn-outline`}
onClick={update.viewBack}
title={t('pe:cancel')}
>
<span>{t('pe:cancel')}</span>
</button>
<button
className={`flex flex-row items-center justify-between btn btn-primary btn-lg grow`}
onClick={saveAsNewPattern}
title={t('pe:saveAsNewPattern')}
>
<Swizzled.components.SaveAsIcon className="w-8 h-8" />
<span>{t('pe:saveAsNewPattern')}</span>
</button>
</div>
<p className="text-sm text-right">
To access your saved patterns, go to:{' '}
<b>
<Swizzled.components.PageLink href="//account/patterns">
/account/patterns
</Swizzled.components.PageLink>
</b>
</p>
</div>
</div>
</Swizzled.components.AuthWrapper>
)
}

View file

@ -0,0 +1,225 @@
import { useState, useEffect } from 'react'
import orderBy from 'lodash.orderby'
export const UserSetPicker = ({
Design,
clickHandler,
missingClickHandler,
size = 'lg',
Swizzled,
}) => {
// Swizzled components
const { Popout, MeasurementsSetCard } = Swizzled.components
// Swizzled hooks
const { useBackend } = Swizzled.hooks
const backend = useBackend()
// Swizzled methods
const { t, hasRequiredMeasurements } = Swizzled.methods
// Swizzled config
const { config } = Swizzled
// Local state
const [sets, setSets] = useState({})
// Effects
useEffect(() => {
const getSets = async () => {
const result = await backend.getSets()
if (result.success) {
const all = {}
for (const set of result.data.sets) all[set.id] = set
setSets(all)
}
}
getSets()
}, [backend])
let hasSets = false
const okSets = []
const lackingSets = []
if (Object.keys(sets).length > 0) {
hasSets = true
for (const setId in sets) {
const [hasMeasies] = hasRequiredMeasurements(Design, sets[setId].measies)
if (hasMeasies) okSets.push(sets[setId])
else lackingSets.push(sets[setId])
}
}
if (!hasSets)
return (
<div className="w-full max-w-3xl mx-auto">
<Popout tip>
<h5>{t('pe:noOwnSets')}</h5>
<p className="">{t('pe:noOwnSetsMsg')}</p>
{config.hrefNewSet ? (
<a
href={config.hrefNewSet}
className="btn btn-accent capitalize"
target="_BLANK"
rel="nofollow"
>
{t('pe:newSet')}
</a>
) : null}
<p className="text-sm">{t('pe:pleaseMtm')}</p>
</Popout>
</div>
)
return (
<>
{okSets.length > 0 && (
<div className="flex flex-row flex-wrap gap-2">
{okSets.map((set) => (
<MeasurementsSetCard
href={false}
{...{ set, Design, config }}
onClick={clickHandler}
key={set.id}
size={size}
/>
))}
</div>
)}
{lackingSets.length > 0 ? (
<div className="my-4">
<Popout note compact>
{t('pe:someSetsLacking')}
</Popout>
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-2">
{lackingSets.map((set) => (
<MeasurementsSetCard
{...{ set, Design }}
onClick={missingClickHandler}
href={false}
key={set.id}
size={size}
/>
))}
</div>
</div>
) : null}
</>
)
}
export const BookmarkedSetPicker = ({
Design,
clickHandler,
missingClickHandler,
size = 'lg',
Swizzled,
}) => {
// Swizzled components
const { Popout, MeasurementsSetCard } = Swizzled.components
// Swizzled hooks
const { useBackend } = Swizzled.hooks
const backend = useBackend()
// Swizzled methods
const { t, hasRequiredMeasurements } = Swizzled.methods
// Swizzled config
const { config } = Swizzled
// Local state
const [sets, setSets] = useState({})
// Effects
useEffect(() => {
const getBookmarks = async () => {
const result = await backend.getBookmarks()
const loadedSets = {}
if (result.success) {
for (const bookmark of result.data.bookmarks.filter(
(bookmark) => bookmark.type === 'set'
)) {
let set
try {
set = await backend.getSet(bookmark.url.slice(6))
if (set.success) {
const [hasMeasies] = hasRequiredMeasurements(Design, set.data.set.measies)
loadedSets[set.data.set.id] = { ...set.data.set, hasMeasies }
}
} catch (err) {
console.log(err)
}
}
}
setSets(loadedSets)
}
getBookmarks()
}, [])
const okSets = Object.values(sets).filter((set) => set.hasMeasies)
const lackingSets = Object.values(sets).filter((set) => !set.hasMeasies)
return (
<>
{okSets.length > 0 && (
<div className="flex flex-row flex-wrap gap-2">
{okSets.map((set) => (
<MeasurementsSetCard
href={false}
{...{ set, Design, config }}
onClick={clickHandler}
key={set.id}
size={size}
/>
))}
</div>
)}
{lackingSets.length > 0 && (
<div className="my-4">
<Popout note compact>
{t('pe:someSetsLacking')}
</Popout>
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-2">
{lackingSets.map((set) => (
<MeasurementsSetCard
href={false}
{...{ set, Design }}
onClick={missingClickHandler}
key={set.id}
size={size}
/>
))}
</div>
</div>
)}
</>
)
}
export const CuratedSetPicker = ({ clickHandler, Swizzled, locale }) => {
// Swizzled components
const { CuratedMeasurementsSetLineup } = Swizzled.components
// Swizzled hooks
const { useBackend } = Swizzled.hooks
const backend = useBackend()
// Local state
const [sets, setSets] = useState([])
// Effects
useEffect(() => {
const getSets = async () => {
const result = await backend.getCuratedSets()
if (result.success) {
const allSets = {}
for (const set of result.data.curatedSets) {
if (set.published) allSets[set.id] = set
}
setSets(allSets)
}
}
getSets()
}, [])
return (
<div className="max-w-7xl xl:pl-4">
<CuratedMeasurementsSetLineup
{...{ locale, clickHandler }}
sets={orderBy(sets, 'height', 'asc')}
/>
</div>
)
}

View file

@ -0,0 +1,21 @@
export const Spinner = ({ className = 'h-6 w-6 animate-spin' }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)
export const Loading = () => (
<Spinner className="w-24 h-24 color-primary animate-spin m-auto mt-8" />
)

View file

@ -0,0 +1,66 @@
import React, { useState } from 'react'
export const Tabs = ({ tabs = '', active = 0, children, withModal = false, Swizzled }) => {
// Keep active tab in state
const [activeTab, setActiveTab] = useState(active)
/*
* In MDX, tabs are passed as a comma-seperated list.
* In React, they come as an array.
* So we need to handle both cases.
*/
const tablist = Array.isArray(tabs) ? tabs : tabs.split(',').map((tab) => tab.trim())
/*
* Don't bother unless there's actual tabs to show
*/
if (!tablist) return null
/*
* Pass down activeTab and tabId for conditional rendering
*/
const childrenWithTabSetter = children.map((child, tabId) =>
React.cloneElement(child, { activeTab, tabId, key: tabId })
)
return (
<div className="my-4">
<div className="tabs">
{tablist.map((title, tabId) => {
const btnClasses = `text-lg font-bold capitalize tab h-auto tab-bordered grow py-2 ${
activeTab === tabId ? 'tab-active' : ''
}`
return withModal && activeTab === tabId ? (
<button
key={tabId}
className={btnClasses}
onClick={() =>
Swizzled.methods.setModal(
<Swizzled.components.ModalWrapper
flex="col"
justify="top lg:justify-center"
slideFrom="right"
fullWidth
>
{childrenWithTabSetter}
</Swizzled.components.ModalWrapper>
)
}
>
<span className="pr-2">{title}</span>
<Swizzled.components.KioskIcon className="w-6 h-6 hover:text-secondary" />
</button>
) : (
<button key={tabId} className={btnClasses} onClick={() => setActiveTab(tabId)}>
{title}
</button>
)
})}
</div>
<div>{childrenWithTabSetter}</div>
</div>
)
}
export const Tab = ({ children, tabId, activeTab }) => (activeTab === tabId ? children : null)

View file

@ -0,0 +1,13 @@
export const Tooltip = (props) => {
const { children, tip, ...rest } = props
return (
<div
{...rest}
className={`tooltip tooltip-bottom before:bg-base-200 before:shadow before:text-base-content`}
data-tip={tip}
>
{children}
</div>
)
}

View file

@ -0,0 +1,144 @@
import orderBy from 'lodash.orderby'
/**
* The undos view shows the undo history, and allows restoring any undo state
*
* @param (object) props - All the props
* @param {object} props.swizzled - An object with swizzled components, hooks, methods, config, and defaults
* @param {object} designs - Object holding all designs
* @param {object} update - ViewWrapper state update object
*/
export const UndosView = ({ Design, Swizzled, update, state }) => {
const steps = orderBy(state._.undos, 'time', 'desc')
return (
<>
<Swizzled.components.HeaderMenu state={state} {...{ Swizzled, update, Design }} />
<div className="text-left mt-8 mb-24 px-4 max-w-xl mx-auto">
<h2>{Swizzled.methods.t('pe:view.undos.t')}</h2>
<p>{Swizzled.methods.t('pe:view.undos.d')}</p>
<small>
<b>Tip:</b> Click on any change to undo all changes up to, and including, that change.
</small>
{steps.length < 1 ? (
<Swizzled.components.Popout note>
<h4>Your undo history is currently empty</h4>
<p>When you make changes to your pattern, they will show up here.</p>
<p>For example, you can click the button below to change the pattern rotation:</p>
<button
className="btn btn-primary capitalize"
onClick={() => update.settings('ui.rotate', state.settings?.ui?.rotate ? 0 : 1)}
>
{Swizzled.methods.t('pe:example')}: {Swizzled.methods.t('pe:rotate.t')}
</button>
<p>As soon as you do, the change will show up here, and you can undo it.</p>
</Swizzled.components.Popout>
) : (
<div className="flex flex-col gap-2 mt-4">
{steps.map((step, index) => (
<Swizzled.components.UndoStep
key={step.time}
{...{ step, update, state, Design, index }}
/>
))}
</div>
)}
</div>
</>
)
}
export const UndoStepTimeAgo = ({ Swizzled, step }) => {
if (!step.time) return null
const secondsAgo = Math.floor((Date.now() - step.time) / 100) / 10
const minutesAgo = Math.floor(secondsAgo / 60)
const hoursAgo = Math.floor(minutesAgo / 60)
return hoursAgo ? (
<span>
{hoursAgo} {Swizzled.methods.t('pe:hoursAgo')}
</span>
) : minutesAgo ? (
<span>
{minutesAgo} {Swizzled.methods.t('pe:minutesAgo')}
</span>
) : (
<span>
{secondsAgo} {Swizzled.methods.t('pe:secondsAgo')}
</span>
)
}
export const UndoStep = ({ Swizzled, update, state, step, Design, compact = false, index = 0 }) => {
const { t } = Swizzled.methods
/*
* Ensure path is always an array
*/
if (!Array.isArray(step.path)) step.path = step.path.split('.')
/*
* Figure this out once
*/
const imperial = state.settings?.units === 'imperial' ? true : false
/*
* Metadata can be ignored
*/
if (step.name === 'settings' && step.path[1] === 'metadata') return null
/*
* Defer for anything else to this method
*/
const data = Swizzled.methods.getUndoStepData({ step, state, Design, imperial })
if (data === false) return <pre>{JSON.stringify(step, null, 2)}</pre> //null
if (data === null) return <p>Unsupported</p>
if (compact)
return (
<Swizzled.components.ButtonFrame dense onClick={() => update.restore(index, state._)}>
<div className="flex flex-row items-center align-start gap-2 w-full">
<Swizzled.components.UndoIcon text={index} className="w-5 h-5 text-secondary" />
{data.msg ? data.msg : t(`pe:${data.optCode}`)}
</div>
</Swizzled.components.ButtonFrame>
)
return (
<>
<p className="text-sm italic font-medium opacity-70 text-right p-0 m-0 -mb-2 pr-2">
<Swizzled.components.UndoStepTimeAgo step={step} />
</p>
<Swizzled.components.ButtonFrame onClick={() => update.restore(index, state._)}>
<div className="flex flex-row items-center justify-between gap-2 w-full m-0 p-0 -mt-2 text-lg">
<span className="flex flex-row gap-2 items-center">
{data.fieldIcon || null}
{t(`pe:${data.optCode}`)}
</span>
<span className="opacity-70 flex flex-row gap-1 items-center text-base">
{data.icon || null} {t(`pe:${data.titleCode}`)}
</span>
</div>
<div className="flex flex-row gap-1 items-center align-start w-full">
{data.msg ? (
data.msg
) : (
<>
<span className="">
{Array.isArray(data.newVal) ? data.newVal.join(', ') : data.newVal}
</span>
<Swizzled.components.LeftIcon
className="w-4 h-4 text-secondary shrink-0"
stroke={4}
/>
<span className="line-through decoration-1 opacity-70">
{Array.isArray(data.oldVal) ? data.oldVal.join(', ') : data.oldVal}
</span>
</>
)}
</div>
</Swizzled.components.ButtonFrame>
</>
)
}

View file

@ -0,0 +1,12 @@
export const Ux = ({ Swizzled, ux = 0 }) => (
<div className="flex flex-row">
{[0, 1, 2, 3, 4].map((i) => (
<Swizzled.components.CircleIcon
key={i}
fill={i < ux ? true : false}
className={`w-6 h-6 ${i < ux ? 'stroke-secondary fill-secondary' : 'stroke-current'}`}
fillOpacity={0.3}
/>
))}
</div>
)

View file

@ -0,0 +1,111 @@
import { useState } from 'react'
/**
* The design view is loaded if and only if not design is passed to the editor
*
* @param (object) props - All the props
* @param {object} props.swizzled - An object with swizzled components, hooks, methods, config, and defaults
* @param {object} designs - Object holding all designs
* @param {object} update - ViewWrapper state update object
*/
export const ViewPicker = ({ Design, Swizzled, update, state }) => {
const [showDev, setShowDev] = useState(false)
/*
* If we don't have the measurments, only present measurements free views
*/
if (state._.missingMeasurements.length > 1)
return (
<div className="text-center mt-8 mb-24 px-4 max-w-xl mx-auto">
<h2>{Swizzled.methods.t('Choose an activity')}</h2>
<div className="flex flex-col mx-auto justify-center gap-2 mt-4">
{Swizzled.config.measurementsFreeViews
.filter((view) => view !== 'picker')
.map((view) => (
<MainCard key={view} {...{ view, update, Design, Swizzled }} />
))}
<Swizzled.components.Popout note>
<div className="text-left">
<h5>{Swizzled.methods.t('pe:measurementsFreeViewsOnly.t')}:</h5>
<p>{Swizzled.methods.t('pe:measurementsFreeViewsOnly.d')}</p>
</div>
</Swizzled.components.Popout>
</div>
</div>
)
return (
<div className="text-center mt-8 mb-24 px-4">
<h2>{Swizzled.methods.t('Choose an activity')}</h2>
<div className="max-w-6xl grid grid-cols-1 lg:grid-cols-2 mx-auto justify-center gap-2 lg:gap-4 mt-4">
{Swizzled.config.mainViews.map((view) => (
<MainCard key={view} {...{ view, update, Design, Swizzled }} />
))}
</div>
<div className="max-w-6xl grid grid-cols-1 lg:grid-cols-4 mx-auto justify-center gap-2 lg:gap-4 mt-4">
{Swizzled.config.extraViews.map((view) => (
<ExtraCard key={view} {...{ view, update, Swizzled }} />
))}
</div>
{showDev || state.ui.ux > 3 ? (
<div className="max-w-6xl grid grid-cols-1 lg:grid-cols-4 mx-auto justify-center gap-2 lg:gap-4 mt-4">
{Swizzled.config.devViews.map((view) => (
<ExtraCard key={view} {...{ view, update, Swizzled }} />
))}
</div>
) : null}
{state.ui.ux < 4 ? (
<button className="btn btn-ghost mt-2" onClick={() => setShowDev(!showDev)}>
{Swizzled.methods.t(`pe:${showDev ? 'hide' : 'show'}AdvancedOptions`)}
</button>
) : null}
</div>
)
}
const MainCard = ({ view, Swizzled, update, Design }) => {
const Icon = Swizzled.components[`View${Swizzled.methods.capitalize(view)}Icon`]
const { NoIcon } = Swizzled.components
const color = Swizzled.config.mainViewColors[view] || 'neutral'
return (
<button
className={`border shadow p-4 rounded-lg w-full bg-${
color === 'none' ? 'secondary' : color
} ${
color === 'none' ? 'bg-opacity-10 hover:bg-opacity-20' : 'hover:bg-opacity-90'
} flex flex-col`}
title={Swizzled.methods.t(`pe:view.${view}.t`)}
onClick={() => update.view(view)}
>
<h4
className={`flex flex-row items-center justify-between p-0 text-${color}-content mb-2 text-left`}
>
{Swizzled.methods.t(`pe:view.${view}.t`)}
{Icon ? <Icon className="w-10 h-10" /> : <NoIcon className="w-10 h-10" />}
</h4>
<p className={`text-left text-lg m-0 p-0 text-${color}-content grow-2`}>
{Swizzled.methods.t(`pe:view.${view}.d`, {
design: `${Design.designConfig.data.name} v${Design.designConfig.data.version}`,
})}
</p>
</button>
)
}
const ExtraCard = ({ view, Swizzled, update }) => {
const Icon = Swizzled.components[`View${Swizzled.methods.capitalize(view)}Icon`]
const { NoIcon } = Swizzled.components
return (
<button
className="border shadow p-3 rounded-lg w-full hover:bg-secondary hover:bg-opacity-20 flex flex-col"
title={Swizzled.methods.t(`pe:view.${view}.t`)}
onClick={() => update.view(view)}
>
<h5 className="flex flex-row items-center justify-between p-0 mb-1 text-left">
{Swizzled.methods.t(`pe:view.${view}.t`)}
{Icon ? <Icon className="w-8 h-8" /> : <NoIcon className="w-8 h-8" />}
</h5>
<p className="text-left m-0 p-0 grow-2">{Swizzled.methods.t(`pe:view.${view}.d`)}</p>
</button>
)
}

View file

@ -0,0 +1,88 @@
import React, { useState, useMemo, useCallback, forwardRef, useContext } from 'react'
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
/*
* A pattern you can pan and zoom
*/
export const ZoomablePattern = forwardRef(function ZoomablePatternRef(props, ref) {
const { renderProps, Swizzled, rotate } = props
const { onTransformed, setZoomFunctions } = useContext(ZoomContext)
return (
<TransformWrapper
minScale={0.1}
centerZoomedOut={true}
wheel={{ activationKeys: ['Control'] }}
doubleClick={{ mode: 'reset' }}
onTransformed={onTransformed}
onInit={setZoomFunctions}
>
<TransformComponent
wrapperStyle={{ width: '100%', height: '100%' }}
contentStyle={{ width: '100%', height: '100%' }}
wrapperClass={'pan-zoom-pattern'}
id="pan-zoom-pattern"
>
{props.children || (
<Swizzled.components.Pattern
{...{ renderProps }}
t={Swizzled.methods.t}
ref={ref}
className={`freesewing pattern w-full ${rotate ? '-rotate-90' : ''}`}
/>
)}
</TransformComponent>
</TransformWrapper>
)
})
/*
* Context
*/
/**
* A context for managing zoom state of a ZoomablePattern
* Makes transform handlers to be available in components
*/
const ZoomContext = React.createContext({})
/**
* Provider for the ZoomContext
*/
export const ZoomContextProvider = ({ children }) => {
const [zoomed, setZoomed] = useState(false)
const [zoomFunctions, _setZoomFunctions] = useState(false)
const setZoomFunctions = useCallback(
(zoomInstance) => {
const reset = () => {
setZoomed(false)
zoomInstance.resetTransform()
}
if (zoomInstance) {
const { zoomIn, zoomOut, resetTransform } = zoomInstance
_setZoomFunctions({ zoomIn, zoomOut, resetTransform, reset })
}
},
[_setZoomFunctions, setZoomed]
)
const onTransformed = useCallback(
(_ref, state) => {
setZoomed(state.scale !== 1)
},
[setZoomed]
)
const value = useMemo(() => {
return {
zoomed,
zoomFunctions,
setZoomFunctions,
onTransformed,
}
}, [zoomed, zoomFunctions, setZoomFunctions, onTransformed])
return <ZoomContext.Provider value={value}>{children}</ZoomContext.Provider>
}

View file

@ -0,0 +1,198 @@
/*************************************************************************
* *
* FreeSewing's pattern editor allows swizzling the config *
* *
* To 'swizzle' means to replace a default implementation with a *
* custom one. It allows one to customize the pattern editor. *
* *
* This file holds the 'swizzleConfig' method that will return *
* the merged configuration. *
* *
* To use a custom config, simply pas it as a prop into the editor *
* under the 'config' key. So to pass a custom 'newSet' link (used to *
* link to a page to create a new measurements set), you do: *
* *
* <PatternEditor config={{ newSet: '/my/custom/page' }} /> *
* *
*************************************************************************/
/*
* Default config for the FreeSewing pattern editor
*/
export const defaultConfig = {
// Enable use of a (FreeSewing) backend to load data from
enableBackend: true,
// Link to create a new measurements set, set to false to disable
hrefNewSet: 'https://freesewing.org/new/set',
// Cloud default image
cloudImageDflt:
'https://imagedelivery.net/ouSuR9yY1bHt-fuAokSA5Q/365cc64e-1502-4d2b-60e0-cc8beee73f00/public',
// Cloud image base URL
cloudImageUrl: 'https://imagedelivery.net/ouSuR9yY1bHt-fuAokSA5Q/',
// Cloud image variants
cloudImageVariants: ['public', 'sq100', 'sq200', 'sq500', 'w200', 'w500', 'w1000', 'w2000'],
// Views
mainViews: ['draft', 'designs', 'save', 'export'],
extraViews: ['measurements', 'undos', 'printLayout', 'docs'],
devViews: ['editSettings', 'inspect', 'logs', 'test', 'timing'],
utilViews: ['picker'],
measurementsFreeViews: ['designs', 'measurements', 'docs', 'picker'],
mainViewColors: {
draft: 'primary',
save: 'none',
export: 'none',
designs: 'accent',
measurements: 'primary',
docs: 'secondary',
},
// View components
// Facilitate lookup of view components
viewComponents: {
draft: 'DraftView',
designs: 'DesignsView',
save: 'SaveView',
export: 'ViewPicker',
measurements: 'MeasurementsView',
undos: 'UndosView',
printLayout: 'ViewPicker',
editSettings: 'ViewPicker',
docs: 'ViewPicker',
inspect: 'ViewPicker',
logs: 'ViewPicker',
test: 'ViewPicker',
timing: 'ViewPicker',
picker: 'ViewPicker',
error: 'ViewPicker',
},
// Facilitate lookup of menu value components
menuValueComponents: {
bool: 'BoolValue',
constant: 'ConstantOptionValue',
count: 'CountOptionValue',
deg: 'DegOptionValue',
list: 'ListOptionValue',
mm: 'MmOptionValue',
pct: 'PctOptionValue',
},
// Facilitate custom handlers for core settings
menuCoreSettingsHandlerMethods: {
only: 'menuCoreSettingsOnlyHandler',
sabool: 'menuCoreSettingsSaboolHandler',
samm: 'menuCoreSettingsSammHandler',
},
menuGroupEmojis: {
advanced: '🤓',
fit: '👕',
style: '💃🏽',
dflt: '🕹️',
groupDflt: '📁',
},
menuOptionEditLabels: {
pct: '%',
count: '#',
deg: '°',
mm: 'mm',
},
// i18n
i18nPatternNamespaces: ['plugin-annotations'],
// State backend (one of 'react', 'storage', 'session', or 'url')
stateBackend: 'url',
// UX levels
uxLevels: {
core: {
sa: 1,
paperless: 2,
units: 1,
complete: 3,
expand: 4,
only: 4,
scale: 3,
margin: 3,
},
ui: {
renderer: 4,
aside: 3,
kiosk: 3,
rotate: 4,
ux: 1,
},
views: {
draft: 1,
measurements: 1,
test: 4,
timing: 4,
printLayout: 2,
export: 1,
save: 1,
undos: 2,
editSettings: 3,
logs: 4,
inspect: 4,
docs: 1,
designs: 1,
picker: 1,
},
},
defaultUx: 4,
// Flag types
flagTypes: ['error', 'warn', 'note', 'info', 'tip', 'fixme'],
// Initial state
initialState: {
settings: {},
ui: {
renderer: 'react',
kiosk: 0,
aside: 1,
ux: 4,
},
locale: 'en',
},
loadingStatus: {
timeout: 2,
defaults: {
color: 'secondary',
icon: 'Spinner',
},
},
classes: {
horFlex: 'flex flex-row items-center justify-between gap-4 w-full',
horFlexNoSm: 'md:flex md:flex-row md:items-center md:justify-between md:gap-4 md-w-full',
link: 'underline decoration-2 hover:decoration-4 text-secondary hover:text-secondary-focus',
},
roles: {
levels: {
readNone: 0,
readSome: 1,
readOnly: 2,
writeSome: 3,
user: 4,
tester: 4,
curator: 5,
bughunter: 6,
support: 8,
admin: 9,
},
base: 'user',
},
// Ms before to fade out a notification
notifyTimeout: 6660,
degreeMeasurements: ['shoulderSlope'],
}
/*
* This method returns the swizzled configuration
*/
export const swizzleConfig = (config = {}) => {
const mergedConfig = {
...defaultConfig,
...config,
}
mergedConfig.views = [
...mergedConfig.mainViews,
...mergedConfig.extraViews,
...mergedConfig.devViews,
...mergedConfig.utilViews,
]
return mergedConfig
}

View file

@ -0,0 +1,73 @@
/*************************************************************************
* *
* FreeSewing's pattern editor allows swizzling hooks *
* *
* To 'swizzle' means to replace the default implementation of a *
* hook with a custom one. It allows one to customize *
* the pattern editor. *
* *
* This file holds the 'swizzleHooks' method that will return *
* the various hooks that can be swizzled, or their default *
* implementation. *
* *
* To use a custom version, simply pas it as a prop into the editor *
* under the 'hooks' key. So to pass a custom 'useAccount' method *
* (used for loading the user's account data) you do: *
* *
* <PatternEditor hooks={{ useAccount: myCustomHook }} /> *
* *
*************************************************************************/
/*
* Import of components that can be swizzled
*/
// useAccount
import { useAccount } from './use-account.mjs'
import { useBackend } from './use-backend.mjs'
import {
useReactEditorState,
useStorageEditorState,
useSessionEditorState,
useUrlEditorState,
} from './use-editor-state.mjs'
/*
* We support different state backend, so let's handle those
*/
const stateBackends = {
react: useReactEditorState,
storage: useStorageEditorState,
session: useSessionEditorState,
url: useUrlEditorState,
}
/**
* This object holds all hooks that can be swizzled
*/
const defaultHooks = (config) => ({
useAccount,
useBackend,
useEditorState: stateBackends[config.stateBackend] || useReactEditorState,
})
/*
* This method returns hooks that can be swizzled
* So either the passed-in methods, or the default ones
*/
export const swizzleHooks = (hooks = {}, Swizzled) => {
/*
* We need to return the resulting hooks, swizzled or not
* So we put this in this object so we can pass that down
*/
const all = {}
for (const [name, hook] of Object.entries(defaultHooks(Swizzled.config))) {
all[name] = hooks[name]
? (...params) => hooks[name](Swizzled, ...params)
: (...params) => hook(Swizzled, ...params)
}
/*
* Return all hooks
*/
return all
}

View file

@ -0,0 +1,59 @@
import useLocalStorageState from 'use-local-storage-state'
/*
* Make it possible to always check for account.username and account.ux
*/
const noAccount = { username: false, ux: 3 }
/*
* The useAccount hook
*/
export function useAccount(Swizzled) {
// (persisted) State (saved to local storage)
const [account, setAccount] = useLocalStorageState('fs-account', { defaultValue: noAccount })
const [admin, setAdmin] = useLocalStorageState('fs-admin', { defaultValue: noAccount })
const [token, setToken] = useLocalStorageState('fs-token', { defaultValue: null })
const [seenUser, setSeenUser] = useLocalStorageState('fs-seen-user', { defaultValue: false })
// Clear user data. This gets called when signing out
const signOut = () => {
setAccount(noAccount)
setToken(null)
}
// Impersonate a user.
// Only admins can do this but that is enforced at the backend.
const impersonate = (data) => {
setAdmin({ token, account })
const newAccount = {
...data.account,
impersonatingAdmin: { id: account.id, username: account.username },
}
setAdmin({ token, account: { ...account } })
setAccount(newAccount)
setToken(data.token)
}
const stopImpersonating = () => {
setAccount(admin.account)
setToken(admin.token)
clearAdmin()
}
const clearAdmin = () => setAdmin(noAccount)
return {
account,
setAccount,
token,
setToken,
seenUser,
setSeenUser,
signOut,
admin,
clearAdmin,
impersonate,
stopImpersonating,
ux: account?.control || account?.ux || Swizzled.config.defaultUx,
}
}

View file

@ -0,0 +1,633 @@
// Dependencies
import axios from 'axios'
// Hooks
import { useMemo } from 'react'
import { freeSewingConfig } from 'shared/config/freesewing.config.mjs'
/*
* Helper methods to interact with the FreeSewing backend
*/
const apiHandler = axios.create({
baseURL: freeSewingConfig.backend,
timeout: 6660,
})
const auth = (token) =>
token ? { headers: { Authorization: 'Bearer ' + token } } : { headers: {} }
/*
* This api object handles async code for different HTTP methods
*/
const api = {
get: async (uri, config = {}) => {
let result
try {
result = await apiHandler.get(uri, config)
return result
} catch (err) {
return err
}
},
post: async (uri, data = null, config = {}) => {
let result
try {
result = await apiHandler.post(uri, data, config)
return result
} catch (err) {
return err
}
},
put: async (uri, data = null, config = {}) => {
let result
try {
result = await apiHandler.put(uri, data, config)
return result
} catch (err) {
return err
}
},
patch: async (uri, data = null, config = {}) => {
let result
try {
result = await apiHandler.patch(uri, data, config)
return result
} catch (err) {
return err
}
},
delete: async (uri, config = {}) => {
let result
try {
result = await apiHandler.delete(uri, config)
return result
} catch (err) {
return err
}
},
}
/*
* Helper method to handle the response and verify that it was successful
*/
const responseHandler = (response, expectedStatus = 200, expectData = true) => {
if (response && response.status === expectedStatus) {
if (!expectData || response.data) {
return { success: true, data: response.data, response }
}
return { success: true, response }
}
// Unpack axios errors
if (response?.name === 'AxiosError')
return {
success: false,
status: response.response?.status,
data: response.response?.data,
error: response.message,
}
return { success: false, response }
}
function Backend(auth) {
this.auth = auth
}
/*
* backend.signUp: User signup
*/
Backend.prototype.signUp = async function ({ email, language }) {
return responseHandler(await api.post('/signup', { email, language }), 201)
}
/*
* backend.oauthInit: Init Oauth flow for oauth provider
*/
Backend.prototype.oauthInit = async function ({ provider, language }) {
return responseHandler(await api.post('/signin/oauth/init', { provider, language }))
}
/*
* backend.oauthSignIn: User sign in via oauth provider
*/
Backend.prototype.oauthSignIn = async function ({ state, code, provider }) {
return responseHandler(await api.post('/signin/oauth', { state, code, provider }))
}
/*
* Backend.prototype.loadConfirmation: Load a confirmation
*/
Backend.prototype.loadConfirmation = async function ({ id, check }) {
return responseHandler(await api.get(`/confirmations/${id}/${check}`))
}
/*
* Backend.prototype.confirmSignup: Confirm a signup
*/
Backend.prototype.confirmSignup = async function ({ id, consent }) {
return responseHandler(await api.post(`/confirm/signup/${id}`, { consent }))
}
/*
* Backend.prototype.signIn: User signin/login
*/
Backend.prototype.signIn = async function ({ username, password = false, token = false }) {
return password === false
? responseHandler(await api.post('/signinlink', { username }))
: responseHandler(await api.post('/signin', { username, password, token }))
}
/*
* Backend.prototype.signInFromLink: Trade in sign-in link id & check for JWT token
*/
Backend.prototype.signInFromLink = async function ({ id, check }) {
return responseHandler(await api.post(`/signinlink/${id}/${check}`))
}
/*
* Generic update account method
*/
Backend.prototype.updateAccount = async function (data) {
return responseHandler(await api.patch(`/account/jwt`, data, this.auth))
}
/*
* Update consent (uses the jwt-guest middleware)
*/
Backend.prototype.updateConsent = async function (consent) {
return responseHandler(await api.patch(`/consent/jwt`, { consent }, this.auth))
}
/*
* Checks whether a username is available
*/
Backend.prototype.isUsernameAvailable = async function (username) {
const response = await api.post(`/available/username/jwt`, { username }, this.auth)
// 404 means username is available, which is success in this case
return response.status === 404
? { success: true, data: false, available: true, response }
: { success: false, available: false, response }
}
/*
* Remove account method
*/
Backend.prototype.removeAccount = async function () {
return responseHandler(await api.delete(`/account/jwt`, this.auth))
}
/*
* Enable MFA
*/
Backend.prototype.enableMfa = async function () {
return responseHandler(await api.post(`/account/mfa/jwt`, { mfa: true }, this.auth))
}
/*
* Confirm MFA
*/
Backend.prototype.confirmMfa = async function (data) {
return responseHandler(await api.post(`/account/mfa/jwt`, { ...data, mfa: true }, this.auth))
}
/*
* Disable MFA
*/
Backend.prototype.disableMfa = async function (data) {
return responseHandler(await api.post(`/account/mfa/jwt`, { ...data, mfa: false }, this.auth))
}
/*
* Reload account
*/
Backend.prototype.reloadAccount = async function () {
return responseHandler(await api.get(`/whoami/jwt`, this.auth))
}
/*
* Export account data
*/
Backend.prototype.exportAccount = async function () {
return responseHandler(await api.get(`/account/export/jwt`, this.auth))
}
/*
* Restrict processing of account data
*/
Backend.prototype.restrictAccount = async function () {
return responseHandler(await api.get(`/account/restrict/jwt`, this.auth))
}
/*
* Remove account
*/
Backend.prototype.restrictAccount = async function () {
return responseHandler(await api.delete(`/account/jwt`, this.auth))
}
/*
* Load all user data
*/
Backend.prototype.getUserData = async function (uid) {
return responseHandler(await api.get(`/users/${uid}/jwt`, this.auth))
}
/*
* Load user profile
*/
Backend.prototype.getProfile = async function (uid) {
return responseHandler(await api.get(`/users/${uid}`))
}
/*
* Load user count
*/
Backend.prototype.getUserCount = async function () {
return responseHandler(await api.get(`/info/users`))
}
/*
* Load stats
*/
Backend.prototype.getStats = async function () {
return responseHandler(await api.get(`/info/stats`))
}
/*
* Create bookmark
*/
Backend.prototype.createBookmark = async function (data) {
return responseHandler(await api.post(`/bookmarks/jwt`, data, this.auth), 201)
}
/*
* Get bookmark
*/
Backend.prototype.getBookmark = async function (id) {
return responseHandler(await api.get(`/bookmarks/${id}/jwt`, this.auth))
}
/*
* Get bookmarks
*/
Backend.prototype.getBookmarks = async function () {
return responseHandler(await api.get(`/bookmarks/jwt`, this.auth))
}
/*
* Remove bookmark
*/
Backend.prototype.removeBookmark = async function (id) {
const response = await api.delete(`/bookmarks/${id}/jwt`, this.auth)
return response && response.status === 204 ? true : false
}
/*
* Create API key
*/
Backend.prototype.createApikey = async function (data) {
return responseHandler(await api.post(`/apikeys/jwt`, data, this.auth), 201)
}
/*
* Get API key
*/
Backend.prototype.getApikey = async function (id) {
return responseHandler(await api.get(`/apikeys/${id}/jwt`, this.auth))
}
/*
* Get API keys
*/
Backend.prototype.getApikeys = async function () {
return responseHandler(await api.get(`/apikeys/jwt`, this.auth))
}
/*
* Remove API key
*/
Backend.prototype.removeApikey = async function (id) {
const response = await api.delete(`/apikeys/${id}/jwt`, this.auth)
return response && response.status === 204 ? true : false
}
/*
* Get measurements sets
*/
Backend.prototype.getSets = async function () {
return responseHandler(await api.get(`/sets/jwt`, this.auth))
}
/*
* Get measurements set
*/
Backend.prototype.getSet = async function (id) {
return responseHandler(await api.get(`/sets/${id}/jwt`, this.auth))
}
/*
* Get public measurements set
*/
Backend.prototype.getPublicSet = async function (id) {
return responseHandler(await api.get(`/sets/${id}.json`))
}
/*
* Create measurements set
*/
Backend.prototype.createSet = async function (data) {
return responseHandler(await api.post(`/sets/jwt`, data, this.auth), 201)
}
/*
* Remove measurements set
*/
Backend.prototype.removeSet = async function (id) {
const response = await api.delete(`/sets/${id}/jwt`, this.auth)
return response && response.status === 204 ? true : false
}
/*
* Generic update measurements set method
*/
Backend.prototype.updateSet = async function (id, data) {
return responseHandler(await api.patch(`/sets/${id}/jwt`, data, this.auth))
}
/*
* Get curated measurements sets
*/
Backend.prototype.getCuratedSets = async function () {
return responseHandler(await api.get(`/curated-sets`))
}
/*
* Get measurements sets suggested for curation
*/
Backend.prototype.getSuggestedSets = async function () {
return responseHandler(await api.get(`/suggested-sets/jwt`, this.auth))
}
/*
* Get option packs suggested for curation
*/
Backend.prototype.getSuggestedPacks = async function () {
return responseHandler(await api.get(`/suggested-packs/jwt`, this.auth))
}
/*
* Remove suggested measurements set
*/
Backend.prototype.removeSuggestedMeasurementsSet = async function (id) {
const response = await api.delete(`/suggested-sets/${id}/jwt`, this.auth)
return response && response.status === 204 ? true : false
}
/*
* Get curated measurements set
*/
Backend.prototype.getCuratedSet = async function (id) {
return responseHandler(await api.get(`/curated-sets/${id}`))
}
/*
* Generic update curated measurements set method
*/
Backend.prototype.updateCuratedSet = async function (id, data) {
return responseHandler(await api.patch(`/curated-sets/${id}/jwt`, data, this.auth))
}
/*
* Remove curated measurements set
*/
Backend.prototype.removeCuratedMeasurementsSet = async function (id) {
const response = await api.delete(`/curated-sets/${id}/jwt`, this.auth)
return response && response.status === 204 ? true : false
}
/*
* Get pattern
*/
Backend.prototype.getPattern = async function (id) {
return responseHandler(await api.get(`/patterns/${id}/jwt`, this.auth))
}
/*
* Get public pattern
*/
Backend.prototype.getPublicPattern = async function (id) {
return responseHandler(await api.get(`/patterns/${id}.json`))
}
/*
* Get patterns
*/
Backend.prototype.getPatterns = async function () {
return responseHandler(await api.get(`/patterns/jwt`, this.auth))
}
/*
* Create pattern
*/
Backend.prototype.createPattern = async function (data) {
return responseHandler(await api.post(`/patterns/jwt`, data, this.auth), 201)
}
/*
* Remove pattern
*/
Backend.prototype.removePattern = async function (id) {
const response = await api.delete(`/patterns/${id}/jwt`, this.auth)
return response && response.status === 204 ? true : false
}
/*
* Generic update pattern set method
*/
Backend.prototype.updatePattern = async function (id, data) {
return responseHandler(await api.patch(`/patterns/${id}/jwt`, data, this.auth))
}
/*
* Create GitHub issue
*/
Backend.prototype.createIssue = async function (data) {
return responseHandler(await api.post(`/issues`, data), 201)
}
/*
* Create GitHub discussion
*/
Backend.prototype.createDiscussion = async function (data) {
return responseHandler(await api.post(`/discussions`, data), 201)
}
/*
* Check whether a slug is available
*/
Backend.prototype.isSlugAvailable = async function ({ slug, type }) {
const response = await api.get(`/slugs/${type}/${slug}/jwt`, this.auth)
// 404 means username is available, which is success in this case
return response.status === 200
? { success: false, available: false, response }
: { success: true, data: false, available: true, response }
}
/*
* Create showcase/blog post (pull request)
*/
Backend.prototype.createPost = async function (type, data) {
return responseHandler(await api.post(`/flows/pr/${type}/jwt`, data, this.auth), 201)
}
/*
* Send translation invite
*/
Backend.prototype.sendTranslatorInvite = async function (language) {
return responseHandler(await api.post(`/flows/translator-invite/jwt`, { language }, this.auth))
}
/*
* Send language suggestion
*/
Backend.prototype.sendLanguageSuggestion = async function (data) {
return responseHandler(await api.post(`/flows/language-suggestion/jwt`, data, this.auth))
}
/*
* Subscribe to newsletter
*/
Backend.prototype.newsletterSubscribe = async function ({ email, language }) {
return responseHandler(await api.post('/subscriber', { email, language }))
}
/*
* Confirm newsletter subscribe
*/
Backend.prototype.confirmNewsletterSubscribe = async function ({ id, ehash }) {
return responseHandler(await api.put('/subscriber', { id, ehash }))
}
/*
* Newsletter unsubscribe
*/
Backend.prototype.newsletterUnsubscribe = async function (ehash) {
return responseHandler(await api.delete(`/subscriber/${ehash}`))
}
/*
* Upload an image
*/
Backend.prototype.uploadImage = async function (body) {
return responseHandler(await api.post('/images/jwt', body, this.auth))
}
/*
* Upload an image anonymously
*/
Backend.prototype.uploadAnonImage = async function (body) {
return responseHandler(await api.post('/images', body))
}
/*
* Remove an (uploaded) image
*/
Backend.prototype.removeImage = async function (id) {
return responseHandler(await api.delete(`/images/${id}/jwt`, this.auth))
}
/*
* Suggest a measurements set for curation
*/
Backend.prototype.suggestCset = async function (data) {
return responseHandler(await api.post(`/curated-sets/suggest/jwt`, data, this.auth))
}
/*
* Suggest an option pack
*/
Backend.prototype.suggestOpack = async function (data) {
return responseHandler(await api.post(`/option-packs/suggest/jwt`, data, this.auth))
}
/*
* Create a curated set from a suggested set
*/
Backend.prototype.csetFromSuggestedSet = async function (id) {
return responseHandler(await api.post(`/curated-sets/from/${id}/jwt`, {}, this.auth))
}
/*
* Ping backend to see if current token is still valid
*/
Backend.prototype.ping = async function () {
return responseHandler(await api.get(`/whoami/jwt`, this.auth))
}
/*
* Search user (admin method)
*/
Backend.prototype.adminSearchUsers = async function (q) {
return responseHandler(await api.post('/admin/search/users/jwt', { q }, this.auth))
}
/*
* Load user (admin method)
*/
Backend.prototype.adminLoadUser = async function (id) {
return responseHandler(await api.get(`/admin/user/${id}/jwt`, this.auth))
}
/*
* Update user (admin method)
*/
Backend.prototype.adminUpdateUser = async function ({ id, data }) {
return responseHandler(await api.patch(`/admin/user/${id}/jwt`, data, this.auth))
}
/*
* Impersonate user (admin method)
*/
Backend.prototype.adminImpersonateUser = async function (id) {
return responseHandler(await api.get(`/admin/impersonate/${id}/jwt`, this.auth))
}
/*
* Load newsletter subscribers (admin method)
*/
Backend.prototype.adminLoadSubscribers = async function () {
return responseHandler(await api.get(`/admin/subscribers/jwt`, this.auth))
}
/*
* Verify an admin account while impersonating another user
*/
Backend.prototype.adminPing = async function (token) {
return responseHandler(await api.get(`/whoami/jwt`, auth(token)))
}
/*
* backend.img: Generate a social media image
*/
Backend.prototype.img = async function (data) {
return responseHandler(await api.post('/img', data, { responseType: 'arraybuffer' }))
}
export function useBackend(Swizzled) {
/*
* Load swizzled useAccount hook and use it
*/
const { token } = Swizzled.hooks.useAccount()
/*
* This backend object is what we'll end up returning
*/
const backend = useMemo(() => new Backend(auth(token)), [token])
return backend
}

View file

@ -0,0 +1,120 @@
import { useMemo, useState, useEffect } from 'react'
import useLocalStorageState from 'use-local-storage-state'
import useSessionStorageState from 'use-session-storage-state'
import { useQueryState, createParser } from 'nuqs'
/**
* react
* This holds the editor state, using React state.
* It also provides helper methods to manipulate state.
*
* @params {object} init - Initial pattern settings
* @return {array} return - And array with get, set, and update methods
*/
export const useReactEditorState = (Swizzled, init = {}, setEphemeralState) => {
const [state, setState] = useState(init)
const update = useMemo(
() => Swizzled.methods.stateUpdateFactory(setState, setEphemeralState),
[setState]
)
return [state, setState, update]
}
/**
* storage
* This holds the editor state, using local storage.
* It also provides helper methods to manipulate state.
*
* @params {object} init - Initial pattern settings
* @return {array} return - And array with get, set, and update methods
*/
export const useStorageEditorState = (Swizzled, init = {}, setEphemeralState) => {
const [state, setState] = useLocalStorageState('fs-editor', { defaultValue: init })
const update = useMemo(
() => Swizzled.methods.stateUpdateFactory(setState, setEphemeralState),
[setState]
)
return [state, setState, update]
}
/**
* session
* This holds the editor state, using session storage.
* It also provides helper methods to manipulate state.
*
* @params {object} init - Initial pattern settings
* @return {array} return - And array with get, set, and update methods
*/
export const useSessionEditorState = (Swizzled, init = {}, setEphemeralState) => {
const [state, setState] = useSessionStorageState('fs-editor', { defaultValue: init })
const update = useMemo(
() => Swizzled.methods.stateUpdateFactory(setState, setEphemeralState),
[setState]
)
return [state, setState, update]
}
/**
* url
* This holds the editor state, using session storage.
* It also provides helper methods to manipulate state.
*
* @params {object} init - Initial pattern settings
* @return {array} return - And array with get, set, and update methods
*/
export const useUrlEditorState = (Swizzled, init = {}, setEphemeralState) => {
const [state, setState] = useQueryState('s', pojoParser)
const update = useMemo(
() => Swizzled.methods.stateUpdateFactory(setState, setEphemeralState),
[setState]
)
/*
* Set the initial state
*/
useEffect(() => {
// Handle state on a hard reload or cold start
if (typeof URLSearchParams !== 'undefined') {
let urlState = false
try {
const params = new URLSearchParams(document.location.search)
const s = params.get('s')
if (typeof s === 'string' && s.length > 0) urlState = JSON.parse(s)
if (urlState) setState(urlState)
else setState(init)
} catch (err) {
setState(init)
}
}
}, [])
return [state, setState, update]
}
/*
* Our URL state library does not support storing Javascript objects out of the box.
* But it allows us to pass a customer parser to handle them, so this is that parser
*/
const pojoParser = createParser({
parse: (v) => {
let val
try {
val = JSON.parse(v)
} catch (err) {
val = null
}
return val
},
serialize: (v) => {
let val
try {
val = JSON.stringify(v)
} catch (err) {
val = null
}
return val
},
})

View file

@ -0,0 +1,149 @@
export function defaultSa(Swizzled, units, inMm = true) {
const dflt = units === 'imperial' ? 0.5 : 1
return inMm ? Swizzled.methods.measurementAsMm(dflt, units) : dflt
}
export function defaultSamm(Swizzled, units, inMm = true) {
const dflt = units === 'imperial' ? 0.5 : 1
return inMm ? Swizzled.methods.measurementAsMm(dflt, units) : dflt
}
/** custom event handlers for inputs that need them */
export function menuCoreSettingsOnlyHandler(Swizzled, { updateHandler, current }) {
return function (path, part) {
// Is this a reset?
if (part === undefined || part === '__UNSET__') return updateHandler(path, part)
// add or remove the part from the set
let newParts = new Set(current || [])
if (newParts.has(part)) newParts.delete(part)
else newParts.add(part)
// if the set is now empty, reset
if (newParts.size < 1) newParts = undefined
// otherwise use the new set
else newParts = [...newParts]
updateHandler(path, newParts)
}
}
export function menuCoreSettingsSammHandler(Swizzled, { updateHandler, config }) {
return function (_path, newCurrent) {
// convert to millimeters if there's a value
newCurrent = newCurrent === undefined ? config.dflt : newCurrent
// update both values to match
updateHandler(['samm'], newCurrent)
updateHandler(['sa'], newCurrent)
}
}
export function menuCoreSettingsSaboolHandler(Swizzled, { toggleSa }) {
return toggleSa
}
export function menuCoreSettingsStructure(
Swizzled,
{ units = 'metric', sabool = false, parts = [] }
) {
return {
sabool: {
ux: Swizzled.config.uxLevels.core.sa,
list: [0, 1],
choiceTitles: {
0: 'saNo',
1: 'saYes',
},
valueTitles: {
0: 'no',
1: 'yes',
},
dflt: 0,
icon: Swizzled.components.SaIcon,
},
samm: sabool
? {
ux: Swizzled.config.uxLevels.core.sa,
min: 0,
max: units === 'imperial' ? 2 : 2.5,
dflt: Swizzled.methods.defaultSamm(units),
icon: Swizzled.components.SaIcon,
}
: false,
paperless: {
ux: Swizzled.config.uxLevels.core.paperless,
list: [0, 1],
choiceTitles: {
0: 'paperlessNo',
1: 'paperlessYes',
},
valueTitles: {
0: 'no',
1: 'yes',
},
dflt: 0,
icon: Swizzled.components.PaperlessIcon,
},
units: {
ux: Swizzled.config.uxLevels.core.units,
list: ['metric', 'imperial'],
dflt: 'metric',
choiceTitles: {
metric: 'metric',
imperial: 'imperial',
},
valueTitles: {
metric: 'metric',
imperial: 'imperial',
},
icon: Swizzled.components.UnitsIcon,
},
complete: {
ux: Swizzled.config.uxLevels.core.complete,
list: [1, 0],
dflt: 1,
choiceTitles: {
0: 'completeNo',
1: 'completeYes',
},
valueTitles: {
0: 'no',
1: 'yes',
},
icon: Swizzled.components.DetailIcon,
},
expand: {
ux: Swizzled.config.uxLevels.core.expand,
list: [1, 0],
dflt: 1,
choiceTitles: {
0: 'expandNo',
1: 'expandYes',
},
valueTitles: {
0: 'no',
1: 'yes',
},
icon: Swizzled.components.ExpandIcon,
},
only: {
ux: Swizzled.config.uxLevels.core.only,
dflt: false,
list: parts,
parts,
icon: Swizzled.components.IncludeIcon,
},
scale: {
ux: Swizzled.config.uxLevels.core.scale,
min: 0.1,
max: 5,
dflt: 1,
step: 0.1,
icon: Swizzled.components.ScaleIcon,
},
margin: {
ux: Swizzled.config.uxLevels.core.margin,
min: 0,
max: 2.5,
dflt: Swizzled.methods.measurementAsMm(units === 'imperial' ? 0.125 : 0.2, units),
icon: Swizzled.components.MarginIcon,
},
}
}

View file

@ -0,0 +1,87 @@
export function designOptionType(Swizzled, 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'
}
import { mergeOptions } from '@freesewing/core'
import set from 'lodash.set'
import orderBy from 'lodash.orderby'
export function menuDesignOptionsStructure(Swizzled, 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 = Swizzled.methods.designOptionType(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
}
/*
* Helper method to grab an option from an Design options structure
*
* Since these structures can be nested with option groups, this needs some extra logic
*/
export function getOptionStructure(Swizzled, option, Design, state) {
const structure = Swizzled.methods.menuDesignOptionsStructure(
Design.patternConfig.options,
state.settings
)
console.log({ structure })
return Swizzled.methods.findOption(structure, option)
}
export function findOption(Swizzled, structure, option) {
for (const [key, val] of Object.entries(structure)) {
if (key === option) return val
if (val.isGroup) {
const sub = findOption(val, option)
if (sub) return sub
}
}
return false
}

View file

@ -0,0 +1,716 @@
/*
* This method drafts the pattern
*
* @param {object} Swizzled - Swizzled code, not used here
* @param {function} Design - The Design constructor
* @param {object} settings - The settings for the pattern
* @return {object} data - The drafted pattern, along with errors and failure data
*/
export function draft(Swizzled, Design, settings) {
const data = {
// The pattern
pattern: new Design(settings),
// 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
}
export function flattenFlags(Swizzled, flags) {
const all = {}
for (const type of Swizzled.config.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(Swizzled, { step }) {
/*
* We'll need these
*/
const field = step.name === 'ui' ? step.path[1] : step.path[2]
const structure = Swizzled.methods.menuUiPreferencesStructure()[field]
/*
* This we'll end up returning
*/
const data = {
icon: <Swizzled.components.UiIcon />,
field,
optCode: `${field}.t`,
titleCode: 'uiPreferences.t',
structure: Swizzled.methods.menuUiPreferencesStructure()[field],
}
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'])
data[key + 'Val'] = Swizzled.methods.t(
structure.choiceTitles[
structure.choiceTitles[String(step[key])] ? String(step[key]) : String(structure.dflt)
] + '.t'
)
return data
}
export function getCoreSettingUndoStepData(Swizzled, { step, state, Design }) {
const field = step.path[1]
const structure = Swizzled.methods.menuCoreSettingsStructure({
language: state.language,
units: state.settings.units,
sabool: state.settings.sabool,
parts: Design.patternConfig.draftOrder,
})
const data = {
field,
titleCode: 'coreSettings.t',
optCode: `${field}.t`,
icon: <Swizzled.components.SettingsIcon />,
structure: structure[field],
}
if (!data.structure && field === 'sa') data.structure = structure.samm
const FieldIcon = data.structure?.icon || Swizzled.components.FixmeIcon
data.fieldIcon = <FieldIcon />
/*
* Save us some typing
*/
const cord = Swizzled.methods.settingsValueCustomOrDefault
const formatMm = Swizzled.methods.formatMm
const Html = Swizzled.components.HtmlSpan
/*
* 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':
if (data.field !== 'margin') {
data.optCode = `samm.t`
}
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':
data.oldVal = Swizzled.methods.t(
step.new === 'imperial' ? 'pe:metricUnits' : 'pe:imperialUnits'
)
data.newVal = Swizzled.methods.t(
step.new === 'imperial' ? 'pe:imperialUnits' : 'pe:metricUnits'
)
return data
case 'only':
data.oldVal = cord(step.old, data.structure.dflt) || Swizzled.methods.t('pe:includeAllParts')
data.newVal = cord(step.new, data.structure.dflt) || Swizzled.methods.t('pe:includeAllParts')
return data
default:
data.oldVal = Swizzled.methods.t(
(data.structure.choiceTitles[String(step.old)]
? data.structure.choiceTitles[String(step.old)]
: data.structure.choiceTitles[String(data.structure.dflt)]) + '.t'
)
data.newVal = Swizzled.methods.t(
(data.structure.choiceTitles[String(step.new)]
? data.structure.choiceTitles[String(step.new)]
: data.structure.choiceTitles[String(data.structure.dflt)]) + '.t'
)
return data
}
}
export function getDesignOptionUndoStepData(Swizzled, { step, state, Design }) {
const option = Design.patternConfig.options[step.path[2]]
const data = {
icon: <Swizzled.components.OptionsIcon />,
field: step.path[2],
optCode: `${state.design}:${step.path[2]}.t`,
titleCode: `designOptions.t`,
oldVal: Swizzled.methods.formatDesignOptionValue(option, step.old, state.units === 'imperial'),
newVal: Swizzled.methods.formatDesignOptionValue(option, step.new, state.units === 'imperial'),
}
return data
}
export function getUndoStepData(Swizzled, props) {
/*
* UI Preferences
*/
if ((props.step.name === 'settings' && props.step.path[1] === 'ui') || props.step.name === 'ui')
return Swizzled.methods.getUiPreferenceUndoStepData(props)
/*
* Design options
*/
if (props.step.name === 'settings' && props.step.path[1] === 'options')
return Swizzled.methods.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 Swizzled.methods.getCoreSettingUndoStepData(props)
/*
* Measurements
*/
if (props.step.name === 'settings' && props.step.path[1] === 'measurements') {
const data = {
icon: <Swizzled.components.MeasurementsIcon />,
field: 'measurements',
optCode: `measurements`,
titleCode: 'measurements',
}
/*
* Single measurements change?
*/
if (props.step.path[2])
return {
...data,
field: props.step.path[2],
oldVal: Swizzled.methods.formatMm(props.step.old, props.imperial),
newVal: Swizzled.methods.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: Swizzled.methods.t('pe:xMeasurementsChanged', { count }) }
}
/*
* 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
* @param {object} Swizzled - The swizzled data
*/
export function initialEditorState(Swizzled) {
/*
* Create initial state object
*/
const initial = { ...Swizzled.config.initialState }
/*
* 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 {object} Swizzled - Swizzled code, not used here
* @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(Swizzled, num, factor) {
const { round } = Swizzled.methods
// 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 {object} Swizzled - Swizzled code, not used here
* @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(
Swizzled,
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 ? Swizzled.methods.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 {object} Swizzled - Swizzled code, not used here
* @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(Swizzled, 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} methods - An object holding possibly swizzled methods (unused here)
* @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(Swizzled, 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} methods - An object holding possibly swizzled methods (unused here)
* @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(
Swizzled,
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: Swizzled.methods.cloneObject(obj),
},
...cur.undos,
],
}
})
return Swizzled.methods.objUpdate(obj, path, val)
}
/*
* 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} Swizzled - An object holding possibly swizzled code (unused here)
* @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(Swizzled, undo, restore, setEphemeralState) {
setEphemeralState((cur) => {
if (!Array.isArray(cur.undos)) cur.undos = []
return {
...cur,
undos: [
{ time: Date.now(), ...undo, restore: Swizzled.methods.cloneObject(restore) },
...cur.undos,
],
}
})
}
/*
* Helper method to clone an object
*/
export function cloneObject(Swizzled, 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(Swizzled, 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(Swizzled, setState, setEphemeralState) {
return {
/*
* This allows raw access to the entire state object
*/
state: (path, val) => setState((cur) => Swizzled.methods.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) =>
Swizzled.methods.undoableObjUpdate(
'settings',
{ ...cur },
Swizzled.methods.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 = Swizzled.methods.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) Swizzled.methods.objUpdate(cur, `settings.${key}`, val)
// Which we'll group as 1 undo action
Swizzled.methods.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) =>
Swizzled.methods.undoableObjUpdate(
'ui',
{ ...cur },
Swizzled.methods.statePrefixPath('ui', path),
val,
setEphemeralState
)
),
/*
* These only hold a string, so we only take a value
*/
design: (val) => setState((cur) => Swizzled.methods.objUpdate({ ...cur }, 'design', val)),
view: (val) => {
// Only take valid view names
if (!Swizzled.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) && Swizzled.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
})
},
ux: (val) => setState((cur) => Swizzled.methods.objUpdate({ ...cur }, 'ux', val)),
clearPattern: () =>
setState((cur) => {
const newState = { ...cur }
Swizzled.methods.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
*/
Swizzled.methods.objUpdate(newState, 'ui', { ...newState.ui, renderer: 'react' })
return newState
}),
clearAll: () => setState(Swizzled.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: Swizzled.methods.t('pe:genericLoadingMsg'),
...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: Swizzled.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: Swizzled.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: Swizzled.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
* @param {object} Swizzled - Swizzled code, not used here
*/
export function cloudImageUrl(Swizzled, { id = 'default-avatar', variant = 'public' }) {
/*
* Return something default so that people will actually change it
*/
if (!id || id === 'default-avatar') return Swizzled.config.cloudImageDflt
/*
* If the variant is invalid, set it to the smallest thumbnail so
* people don't load enourmous images by accident
*/
if (!Swizzled.config.cloudImageVariants.includes(variant)) variant = 'sq100'
return `${Swizzled.config.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
}
/**
* Helper method to merge arrays of translation namespaces
*
* Note that this method is variadic
*
* @param {object} methods - An object holding possibly swizzled methods (unused here)
* @param {[string]} namespaces - A string or array of strings of namespaces
* @return {[string]} namespaces - A merged array of all namespaces
*/
export function nsMerge(Swizzled, ...args) {
const ns = new Set()
for (const arg of args) {
if (typeof arg === 'string') ns.add(arg)
else if (Array.isArray(arg)) {
for (const el of nsMerge(...arg)) ns.add(el)
}
}
return [...ns]
}
/*
* A translation fallback method in case none is passed in
*
* @param {object} Swizzled - Swizzled code, not used here
* @param {string} key - The input
* @return {string} key - The input is returned
*/
export function t(Swizzled, key) {
return Array.isArray(key) ? key[0] : key
}
export function settingsValueIsCustom(Swizzled, val, dflt) {
return typeof val === 'undefined' || val === '__UNSET__' || val === dflt ? false : true
}
export function settingsValueCustomOrDefault(Swizzled, val, dflt) {
return typeof val === 'undefined' || val === '__UNSET__' || val === dflt ? dflt : val
}

View file

@ -0,0 +1,245 @@
/*
* Method that capitalizes a string (make the first character uppercase)
*
* @param {object} Swizzled - Swizzled code, not used here
* @param {string} string - The input string to capitalize
* @return {string} String - The capitalized input string
*/
export function capitalize(Swizzled, string) {
return typeof string === 'string' ? string.charAt(0).toUpperCase() + string.slice(1) : ''
}
export function formatDesignOptionValue(Swizzled, option, value, imperial) {
const oType = Swizzled.methods.designOptionType(option)
if (oType === 'pct') return Swizzled.methods.formatPercentage(value ? value : option.pct / 100)
if (oType === 'deg') return `${value ? value : option.deg}°`
if (oType === 'bool')
return typeof value === 'undefined' ? (
option.bool
) : value ? (
<Swizzled.components.BoolYesIcon />
) : (
<Swizzled.components.BoolNoIcon />
)
if (oType === 'mm')
return Swizzled.methods.formatMm(typeof value === 'undefined' ? option.mm : value, imperial)
if (oType === 'list') return typeof value === 'undefined' ? option.dflt : value
return value
}
/**
* format a value to the nearest fraction with a denominator that is a power of 2
* or a decimal if the value is between fractions
* NOTE: this method does not convert mm to inches. It will turn any given value directly into its equivalent fractional representation
*
* fraction: the value to process
* format: the type of formatting to apply. html, notags, or anything else which will only return numbers
*/
export function formatFraction128(Swizzled, 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 Swizzled.methods.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 Swizzled.methods.formatImperial(
negative,
inches,
fraction128 / numoFactor,
Math.pow(2, i),
format
)
}
return (
negative +
Math.round(fraction * 100) / 100 +
(format === 'html' || format === 'notags' ? '"' : '')
)
}
// Formatting for imperial values
export function formatImperial(Swizzled, neg, inch, numo = false, deno = false, format = 'html') {
if (format === 'html') {
if (numo) return `${neg}${inch}&nbsp;<sup>${numo}</sup>/<sub>${deno}</sub>"`
else return `${neg}${inch}"`
} else if (format === 'notags') {
if (numo) return `${neg}${inch} ${numo}/${deno}"`
else return `${neg}${inch}"`
} else {
if (numo) return `${neg}${inch} ${numo}/${deno}`
else return `${neg}${inch}`
}
}
// Format a value in mm based on the user's units
// Format can be html, notags, or anything else which will only return numbers
export function formatMm(Swizzled, val, units, format = 'html') {
val = Swizzled.methods.roundMm(val)
if (units === 'imperial' || units === true) {
if (val == 0) return Swizzled.methods.formatImperial('', 0, false, false, format)
let fraction = val / 25.4
return Swizzled.methods.formatFraction128(fraction, format)
} else {
if (format === 'html' || format === 'notags') return Swizzled.methods.roundMm(val / 10) + 'cm'
else return Swizzled.methods.roundMm(val / 10)
}
}
// Format a percentage (as in, between 0 and 1)
export function formatPercentage(Swizzled, val) {
return Math.round(1000 * val) / 10 + '%'
}
/**
* A generic rounding method
*
* @param {object} Swizzled - Swizzled code, not used here
* @param {number} val - The input number to round
* @param {number} decimals - The number of decimal points to use when rounding
* @return {number} result - The rounded number
*/
export function round(methods, val, decimals = 1) {
return Math.round(val * Math.pow(10, decimals)) / Math.pow(10, decimals)
}
// Rounds a value in mm
export function roundMm(Swizzled, val, units) {
if (units === 'imperial') return Math.round(val * 1000000) / 1000000
else return Math.round(val * 10) / 10
}
/**
* Converts a value that contain a fraction to a decimal
*
* @param {object} Swizzled - Swizzled code, not used here
* @param {number} value - The input value
* @return {number} result - The resulting decimal value
*/
export function fractionToDecimal(Swizzled, 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
}
/**
* Helper method to turn a measurement in millimeter regardless of units
*
* @param {object} Swizzled - Swizzled code, including methods
* @param {number} value - The input value
* @param {string} units - One of 'metric' or 'imperial'
* @return {number} result - Value in millimeter
*/
export function measurementAsMm(Swizzled, 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 = Swizzled.methods.fractionToDecimal(value)
if (isNaN(decimal)) return false
return decimal * 24.5
}
}
/**
* Converts a millimeter value to a Number value in the given units
*
* @param {object} Swizzled - Swizzled code, not used here
* @param {number} mmValue - The input value in millimeter
* @param {string} units - One of 'metric' or 'imperial'
* @result {number} result - The result in millimeter
*/
export function measurementAsUnits(Swizzled, mmValue, units = 'metric') {
return Swizzled.methods.round(mmValue / (units === 'imperial' ? 25.4 : 10), 3)
}
export function shortDate(locale = 'en', 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(locale, options)
}
/*
* Parses value that should be a distance (cm or inch)
*
* @param {object} Swizzled - Swizzled code, including methods
* @param {number} val - The input value
* @param {bool} imperial - True if the units are imperial, false for metric
* @return {number} result - The distance in the relevant units
*/
export function parseDistanceInput(Swizzled, 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 = Swizzled.methods.fractionToDecimal(val)
return isNaN(val) ? false : Number(val)
}

View file

@ -0,0 +1,174 @@
/*************************************************************************
* *
* FreeSewing's pattern editor allows swizzling methods *
* *
* To 'swizzle' means to replace the default implementation of a *
* method with a custom one. It allows one to customize *
* the pattern editor. *
* *
* This file holds the 'swizzleMethods' method that will return *
* the various methods that can be swizzled, or their default *
* implementation. *
* *
* To use a custom version, simply pas it as a prop into the editor *
* under the 'methods' key. So to pass a custom 't' method (used for *
* translation(, you do: *
* *
* <PatternEditor methods={{ t: myCustomTranslationMethod }} /> *
* *
*************************************************************************/
/*
* Import of methods that can be swizzled
*/
import {
defaultSa,
defaultSamm,
menuCoreSettingsOnlyHandler,
menuCoreSettingsSaboolHandler,
menuCoreSettingsSammHandler,
menuCoreSettingsStructure,
} from './core-settings.mjs'
import {
designOptionType,
findOption,
getOptionStructure,
menuDesignOptionsStructure,
} from './design-options.mjs'
import {
addUndoStep,
cloneObject,
cloudImageUrl,
draft,
flattenFlags,
getCoreSettingUndoStepData,
getDesignOptionUndoStepData,
getUiPreferenceUndoStepData,
getUndoStepData,
initialEditorState,
menuRoundPct,
menuValidateNumericValue,
menuValueWasChanged,
noop,
notEmpty,
nsMerge,
objUpdate,
settingsValueIsCustom,
settingsValueCustomOrDefault,
statePrefixPath,
stateUpdateFactory,
t,
undoableObjUpdate,
} from './editor.mjs'
import {
capitalize,
formatDesignOptionValue,
formatFraction128,
formatImperial,
formatMm,
formatPercentage,
round,
roundMm,
fractionToDecimal,
measurementAsMm,
measurementAsUnits,
shortDate,
parseDistanceInput,
} from './formatting.mjs'
import {
designMeasurements,
hasRequiredMeasurements,
isDegreeMeasurement,
missingMeasurements,
structureMeasurementsAsDesign,
} from './measurements.mjs'
import { menuUiPreferencesStructure } from './ui-preferences.mjs'
/**
* This object holds all methods that can be swizzled
*/
const defaultMethods = {
// core-settings.mjs
defaultSa,
defaultSamm,
menuCoreSettingsOnlyHandler,
menuCoreSettingsSaboolHandler,
menuCoreSettingsSammHandler,
menuCoreSettingsStructure,
// design-options.mjs
designOptionType,
findOption,
getOptionStructure,
menuDesignOptionsStructure,
// editor.mjs
addUndoStep,
cloneObject,
cloudImageUrl,
draft,
flattenFlags,
getCoreSettingUndoStepData,
getDesignOptionUndoStepData,
getUiPreferenceUndoStepData,
getUndoStepData,
initialEditorState,
menuRoundPct,
menuValidateNumericValue,
menuValueWasChanged,
noop,
notEmpty,
nsMerge,
objUpdate,
settingsValueIsCustom,
settingsValueCustomOrDefault,
statePrefixPath,
stateUpdateFactory,
t,
undoableObjUpdate,
// formatting.mjs
capitalize,
formatDesignOptionValue,
formatFraction128,
formatImperial,
formatMm,
formatPercentage,
round,
roundMm,
fractionToDecimal,
measurementAsMm,
measurementAsUnits,
shortDate,
parseDistanceInput,
// measurements.mjs
designMeasurements,
hasRequiredMeasurements,
isDegreeMeasurement,
missingMeasurements,
structureMeasurementsAsDesign,
// ui-preferences.mjs
menuUiPreferencesStructure,
}
/*
* This method returns methods that can be swizzled
* So either the passed-in methods, or the default ones
*/
export const swizzleMethods = (methods, Swizzled) => {
/*
* We need to pass down the resulting methods, swizzled or not
* because some methods rely on other (possibly swizzled) methods.
* So we put this in this object so we can pass that down
*/
const all = {}
for (const [name, method] of Object.entries(defaultMethods)) {
if (typeof method !== 'function')
console.warn(`${name} is not defined as default method in swizzleMethods`)
all[name] = methods[name]
? (...params) => methods[name](all, ...params)
: (...params) => method(Swizzled, ...params)
}
/*
* Return all methods
*/
return all
}

View file

@ -0,0 +1,84 @@
/*
* Returns a list of measurements for a design
*
* @param {object} Swizzled - Swizzled code, not used here
* @param {object} Design - The Design object
* @param {object} measies - The current set of measurements
* @return {object} measurements - Object holding measurements that are relevant for this design
*/
export function designMeasurements(Swizzled, Design, measies = {}) {
const measurements = {}
for (const m of Design.patternConfig?.measurements || []) measurements[m] = measies[m]
for (const m of Design.patternConfig?.optionalMeasurements || []) measurements[m] = measies[m]
return measurements
}
/**
* Helper method to determine whether all required measurements for a design are present
*
* @param {object} Swizzled - Swizzled code, including methods
* @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(Swizzled, 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 = Swizzled.methods.structureMeasurementsAsDesign(Design)
/*
* Walk required measuremnets, 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]
}
/**
* Helper method to determine whether a measurement uses degrees
*
* @param {object} Swizzled - Swizzled code, not used here
* @param {string} name - The name of the measurement
* @return {bool} isDegree - True if the measurment is a degree measurement
*/
export function isDegreeMeasurement(Swizzled, name) {
return Swizzled.config.degreeMeasurements.indexOf(name) !== -1
}
/*
* Helper method to check whether measururements are missing
*
* Note that this does not actually check the settings against
* the chose design, but rather relies on the missing measurements
* being set in state. That's because checking is more expensive,
* so we do it only once in the non-Swizzled ViewWrapper components
*
* @param {object} Swizzled - Object holding Swizzled code
* @param {object } state - The Editor state
* @return {bool} missing - True if there are missing measurments, false if not
*/
export function missingMeasurements(Swizzled, state) {
return (
!Swizzled.config.measurementsFreeViews.includes(state.view) &&
state._.missingMeasurements &&
state._.missingMeasurements.length > 0
)
}
/*
* This takes a POJO of measurements, and turns it into a structure that matches a design object
*
* @param {object} Swizzled - Swizzled code, not used here
* @param {object} measurements - The POJO of measurments
* @return {object} design - The measurements structured as a design object
*/
export function structureMeasurementsAsDesign(Swizzled, measurements) {
return measurements.patternConfig ? measurements : { patternConfig: { measurements } }
}

View file

@ -0,0 +1,60 @@
export function menuUiPreferencesStructure(Swizzled) {
const uiUx = Swizzled.config.uxLevels.ui
const uiPreferences = {
ux: {
ux: uiUx.ux,
emoji: '🖥️',
list: [1, 2, 3, 4, 5],
choiceTitles: {},
icon: Swizzled.components.UxIcon,
dflt: Swizzled.config.defaultUx,
},
aside: {
ux: uiUx.aside,
list: [0, 1],
choiceTitles: {
0: 'pe:noAside',
1: 'pe:withAside',
},
dflt: 1,
icon: Swizzled.components.MenuIcon,
},
kiosk: {
ux: uiUx.kiosk,
list: [0, 1],
choiceTitles: {
0: 'pe:websiteMode',
1: 'pe:kioskMode',
},
dflt: 0,
icon: Swizzled.components.KioskIcon,
},
rotate: {
ux: uiUx.rotate,
list: [0, 1],
choiceTitles: {
0: 'pe:rotateNo',
1: 'pe:rotateYes',
},
dflt: 0,
icon: Swizzled.components.RotateIcon,
},
renderer: {
ux: uiUx.renderer,
list: ['react', 'svg'],
choiceTitles: {
react: 'pe:renderWithReact',
svg: 'pe:renderWithCore',
},
valueTitles: {
react: 'React',
svg: 'SVG',
},
dflt: 'react',
icon: Swizzled.components.RocketIcon,
},
}
uiPreferences.ux.list.forEach((i) => (uiPreferences.ux.choiceTitles[i] = 'pe:ux' + i))
return uiPreferences
}

View file

@ -0,0 +1,4 @@
/*
* To spread icon + text horizontal
*/
export const horFlexClasses = 'flex flex-row items-center justify-between gap-4 w-full'

View file

@ -1,45 +1,47 @@
// __SDEFILE__ - This file is a dependency for the stand-alone environment
// Components
// Pattern
import { Pattern as PatternComponent } from './pattern/index.mjs'
import { Svg as SvgComponent } from './pattern/svg.mjs'
import { Defs as DefsComponent } from './pattern/defs.mjs'
import { Group as GroupComponent } from './pattern/group.mjs'
import { Stack as StackComponent } from './pattern/stack.mjs'
import { Part as PartComponent } from './pattern/part.mjs'
import { Point as PointComponent } from './pattern/point.mjs'
import { Snippet as SnippetComponent } from './pattern/snippet.mjs'
import { Path as PathComponent } from './pattern/path.mjs'
import { Grid as GridComponent } from './pattern/grid.mjs'
import { Text as TextComponent, TextOnPath as TextOnPathComponent } from './pattern/text.mjs'
// Pattern components
import { Defs } from './pattern/defs.mjs'
import { Grid } from './pattern/grid.mjs'
import { Group } from './pattern/group.mjs'
import { Part } from './pattern/part.mjs'
import { Path } from './pattern/path.mjs'
import { Pattern } from './pattern/index.mjs'
import { PatternXray } from './pattern-xray/index.mjs'
import { Point } from './pattern/point.mjs'
import { Stack } from './pattern/stack.mjs'
import { Snippet } from './pattern/snippet.mjs'
import { Svg } from './pattern/svg.mjs'
import { Text, TextOnPath } from './pattern/text.mjs'
// Pattern Utils
import { getProps, withinPartBounds, getId, translateStrings } from './pattern/utils.mjs'
// PatternXray
import { PatternXray as PatternXrayComponent } from './pattern-xray/index.mjs'
import { getId, getProps, translateStrings, withinPartBounds } from './pattern/utils.mjs'
/**
* Translation namespaces used by these components
*/
const ns = ['editor']
/*
* Export all components as named exports
*/
export const Pattern = PatternComponent
export const Svg = SvgComponent
export const Defs = DefsComponent
export const Group = GroupComponent
export const Stack = StackComponent
export const Part = PartComponent
export const Point = PointComponent
export const Path = PathComponent
export const Snippet = SnippetComponent
export const Grid = GridComponent
export const Text = TextComponent
export const TextOnPath = TextOnPathComponent
export const PatternXray = PatternXrayComponent
/*
* Export pattern utils
*/
export const utils = {
getProps,
withinPartBounds,
export {
// Pattern components
Pattern,
Svg,
Defs,
Group,
Stack,
Part,
Point,
Path,
Snippet,
Grid,
Text,
TextOnPath,
PatternXray,
// These are not React components but pattern helpers
getId,
getProps,
translateStrings,
withinPartBounds,
// These are not React components but various helpers
ns,
}