diff --git a/config/exceptions.yaml b/config/exceptions.yaml
index 106d3201095..07d1c992dc5 100644
--- a/config/exceptions.yaml
+++ b/config/exceptions.yaml
@@ -6,6 +6,7 @@ customBuild:
- new-design
- prettier-config
- plugin-bundle
+ - react-components
- rehype-jargon
- rehype-highlight-lines
skipTests:
diff --git a/packages/react-components/CHANGELOG.md b/packages/react-components/CHANGELOG.md
new file mode 100644
index 00000000000..5f230c77d5b
--- /dev/null
+++ b/packages/react-components/CHANGELOG.md
@@ -0,0 +1,9 @@
+# Change log for: @freesewing/react-components
+
+
+
+This is the **initial release**, and the start of this change log.
+
+> Prior to version 2, FreeSewing was not a JavaScript project.
+> As such, that history is out of scope for this change log.
+
diff --git a/packages/react-components/README.md b/packages/react-components/README.md
new file mode 100644
index 00000000000..350e3999662
--- /dev/null
+++ b/packages/react-components/README.md
@@ -0,0 +1,301 @@
+
+
+
+
+
+
+
+
+
+
+
+
+# @freesewing/react-components
+
+React components by/for FreeSewing
+
+
+
+
+> #### Note: Version 3 is a work in progress
+>
+> We are working on a new major version (v3) but it is not ready for prime-time.
+> For production use, please refer to our v2 packages (the `latest` on NPM)
+> or [the `v2` branch in our monorepo](https://github.com/freesewing/freesewing/tree/v2).
+>
+> We the `main` branch and `next` packages on NPM holds v3 code. But it's alpha for now.
+
+## What am I looking at? π€
+
+This repository is our *monorepo* holding all our NPM designs, plugins, other NPM packages, and (web)sites.
+
+This folder holds: @freesewing/react-components
+
+If you're not entirely sure what to do or how to start, type this command:
+
+```
+npm run tips
+```
+
+> If you don't want to set up a dev environment, you can run it in your browser:
+>
+> [](https://gitpod.io/#https://github.com/freesewing/freesewing)
+>
+> We recommend that you fork our repository and then
+> put `gitpod.io/# to start up a browser-based dev environment of your own.
+
+## About FreeSewing π
+
+Where the world of makers and developers collide, that's where you'll find FreeSewing.
+
+If you're a maker, checkout [freesewing.org](https://freesewing.org/) where you can generate
+our sewing patterns adapted to your measurements.
+
+If you're a developer, our documentation is on [freesewing.dev](https://freesewing.dev/).
+Our [core library](https://freesewing.dev/reference/api/) is a *batteries-included* toolbox
+for parametric design of sewing patterns. But we also provide a range
+of [plugins](https://freesewing.dev/reference/plugins/) that further extend the
+functionality of the platform.
+
+If you have NodeJS installed, you can try it right now by running:
+
+```bash
+npx create-freesewing-pattern
+```
+
+Or, consult our getting started guides
+for [Linux](https://freesewing.dev/tutorials/getting-started-linux/),
+[MacOS](https://freesewing.dev/tutorials/getting-started-mac/),
+or [Windows](https://freesewing.dev/tutorials/getting-started-windows/).
+
+We also have a [pattern design tutorial](https://freesewing.dev/tutorials/pattern-design/) that
+walks you through your first parametric design,
+and [a friendly community](https://freesewing.org/community/where/) with
+people who can help you when you get stuck.
+
+## Support FreeSewing: Become a patron π₯°
+
+FreeSewing is an open source project run by a community,
+and financially supported by our patrons.
+
+If you feel what we do is worthwhile, and you can spend a few coind without
+hardship, then you should [join us and become a patron](https://freesewing.org/community/join).
+
+## Links π©βπ»
+
+ - π» Makers website: [freesewing.org](https://freesewing.org)
+ - π» Developers website: [freesewing.dev](https://freesewing.dev)
+ - π¬ Chat: On Discord via [discord.freesewing.org](https://discord.freesewing.org/)
+ - β
Todo list/Kanban board: On Github via [todo.freesewing.org](https://todo.freesewing.org/)
+ - π¦ Twitter: [@freesewing_org](https://twitter.com/freesewing_org)
+ - π· Instagram: [@freesewing_org](https://instagram.com/freesewing_org)
+
+## License: MIT π€
+
+Β© [Joost De Cock](https://github.com/joostdecock).
+See [the license file](https://github.com/freesewing/freesewing/blob/develop/LICENSE) for details.
+
+## Where to get help π€―
+
+Our [chatrooms on Discord](https://chat.freesewing.org/) are the best place to ask questions,
+share your feedback, or just hang out.
+
+If you want to report a problem, please [create an issue](https://github.com/freesewing/freesewing/issues/new).
+
+
+
+## Contributors β¨
+
+Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
+
+
+
+
+
+
+
+
+
+
+
+This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
+
diff --git a/packages/react-components/build.mjs b/packages/react-components/build.mjs
new file mode 100644
index 00000000000..30420e8d453
--- /dev/null
+++ b/packages/react-components/build.mjs
@@ -0,0 +1,36 @@
+/* This script will build the package with esbuild */
+import esbuild from 'esbuild'
+import pkg from './package.json' assert { type: 'json' }
+
+// Create banner based on package info
+const banner = `/**
+ * ${pkg.name} | v${pkg.version}
+ * ${pkg.description}
+ * (c) ${new Date().getFullYear()} ${pkg.author}
+ * @license ${pkg.license}
+ */`
+
+// Shared esbuild options
+const options = {
+ banner: { js: banner },
+ bundle: true,
+ entryPoints: ['src/index.mjs'],
+ format: 'esm',
+ outfile: 'dist/index.mjs',
+ external: ['@freesewing', 'react'],
+ metafile: process.env.VERBOSE ? true : false,
+ minify: process.env.NO_MINIFY ? false : true,
+ sourcemap: true,
+ loader: { '.mjs': 'jsx' },
+}
+
+// Let esbuild generate the build
+const build = async () => {
+ const result = await esbuild.build(options).catch(() => process.exit(1))
+
+ if (process.env.VERBOSE) {
+ const info = await esbuild.analyzeMetafile(result.metafile)
+ console.log(info)
+ }
+}
+build()
diff --git a/packages/react-components/data.mjs b/packages/react-components/data.mjs
new file mode 100644
index 00000000000..0bc5ffa900d
--- /dev/null
+++ b/packages/react-components/data.mjs
@@ -0,0 +1,4 @@
+// This file is auto-generated | All changes you make will be overwritten.
+export const name = '@freesewing/react-components'
+export const version = '3.0.0-alpha.10'
+export const data = { name, version }
diff --git a/packages/react-components/package.json b/packages/react-components/package.json
new file mode 100644
index 00000000000..255d5702022
--- /dev/null
+++ b/packages/react-components/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@freesewing/react-components",
+ "version": "3.0.0-alpha.10",
+ "description": "React components by/for FreeSewing",
+ "author": "Joost De Cock (https://github.com/joostdecock)",
+ "homepage": "https://freesewing.org/",
+ "repository": "github:freesewing/freesewing",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/freesewing/freesewing/issues"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://freesewing.org/patrons/join"
+ },
+ "keywords": [
+ "freesewing",
+ "freesewing"
+ ],
+ "type": "module",
+ "module": "dist/index.mjs",
+ "exports": {
+ ".": "./dist/index.mjs"
+ },
+ "scripts": {
+ "build": "node build.mjs",
+ "clean": "rimraf dist",
+ "mbuild": "NO_MINIFY=1 node build.mjs",
+ "symlink": "mkdir -p ./node_modules/@freesewing && cd ./node_modules/@freesewing && ln -s -f ../../../* . && cd -",
+ "test": "echo \"react-components: No tests configured. Perhaps you could write some?\" && exit 0",
+ "vbuild": "VERBOSE=1 node build.mjs",
+ "lab": "cd ../../sites/lab && yarn start",
+ "tips": "node ../../scripts/help.mjs",
+ "lint": "npx eslint 'src/**' 'tests/*.mjs'",
+ "cibuild_step6": "node build.mjs",
+ "wbuild": "node build.mjs",
+ "wcibuild_step6": "node build.mjs"
+ },
+ "peerDependencies": {},
+ "dependencies": {},
+ "devDependencies": {},
+ "files": [
+ "dist/*",
+ "README.md"
+ ],
+ "publishConfig": {
+ "access": "public",
+ "tag": "next"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8"
+ }
+}
diff --git a/packages/react-components/src/index.mjs b/packages/react-components/src/index.mjs
new file mode 100644
index 00000000000..80ab7b1c173
--- /dev/null
+++ b/packages/react-components/src/index.mjs
@@ -0,0 +1,27 @@
+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'
+
+/*
+ * 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 = DefsComponent
+export const Point = PointComponent
+export const Path = PathComponent
+export const Snippet = SnippetComponent
+export const Grid = GridComponent
+export const Text = TextComponent
+export const TextOnPath = TextOnPathComponent
diff --git a/packages/react-components/src/pattern/circle.mjs b/packages/react-components/src/pattern/circle.mjs
new file mode 100644
index 00000000000..86609f251e7
--- /dev/null
+++ b/packages/react-components/src/pattern/circle.mjs
@@ -0,0 +1,14 @@
+import React from 'react'
+
+export const Circle = ({ point }) =>
+ point.attributes.list['data-circle'].map((r, i) => {
+ const circleProps = point.attributes.circleProps
+ const extraProps = {}
+ for (const prop in circleProps) {
+ const val = point.attributes.list[`data-circle-${prop === 'className' ? 'class' : prop}`]
+ if (val.length >= i) extraProps[prop] = val[i]
+ else extraProps[prop] = val.join(' ')
+ }
+
+ return
+ })
diff --git a/packages/react-components/src/pattern/defs.mjs b/packages/react-components/src/pattern/defs.mjs
new file mode 100644
index 00000000000..e134cf49bb0
--- /dev/null
+++ b/packages/react-components/src/pattern/defs.mjs
@@ -0,0 +1,25 @@
+import React from 'react'
+
+const style = ` style="fill: none; stroke: currentColor;" `
+const grids = {
+ imperial: ` `,
+ metric: ` `,
+}
+
+export const Defs = (props) => {
+ let defs = props.svg.defs.render()
+ if (props.settings[0].paperless) {
+ defs += grids[props.settings[0].units || 'metric']
+ for (let p in props.parts[0]) {
+ let anchor = { x: 0, y: 0 }
+ if (typeof props.parts[0][p].points.gridAnchor !== 'undefined')
+ anchor = props.parts[0][p].points.gridAnchor
+ else if (typeof props.parts[0][p].points.anchor !== 'undefined')
+ anchor = props.parts[0][p].points.anchor
+
+ defs += ` `
+ }
+ }
+
+ return
+}
diff --git a/packages/react-components/src/pattern/grid.mjs b/packages/react-components/src/pattern/grid.mjs
new file mode 100644
index 00000000000..084963cc3e2
--- /dev/null
+++ b/packages/react-components/src/pattern/grid.mjs
@@ -0,0 +1,12 @@
+import React from 'react'
+
+export const Grid = ({ part, partName, settings }) => (
+
+)
diff --git a/packages/react-components/src/pattern/group.mjs b/packages/react-components/src/pattern/group.mjs
new file mode 100644
index 00000000000..92d04df3de2
--- /dev/null
+++ b/packages/react-components/src/pattern/group.mjs
@@ -0,0 +1,3 @@
+import React from 'react'
+
+export const Group = (props) => {props.children}
diff --git a/packages/react-components/src/pattern/index.mjs b/packages/react-components/src/pattern/index.mjs
new file mode 100644
index 00000000000..ce185800aa1
--- /dev/null
+++ b/packages/react-components/src/pattern/index.mjs
@@ -0,0 +1,80 @@
+import React from 'react'
+// Components that can be swizzled
+import { Svg as DefaultSvg } from './svg.mjs'
+import { Defs as DefaultDefs } from './defs.mjs'
+import { Group as DefaultGroup } from './group.mjs'
+import { Stack as DefaultStack } from './stack.mjs'
+import { Part as DefaultPart } from './part.mjs'
+import { Point as DefaultPoint } from './point.mjs'
+import { Snippet as DefaultSnippet } from './snippet.mjs'
+import { Path as DefaultPath } from './path.mjs'
+import { Grid as DefaultGrid } from './grid.mjs'
+import { Text as DefaultText, TextOnPath as DefaultTextOnPath } from './text.mjs'
+
+/*
+ * Allow people to swizzle these components
+ */
+const defaultComponents = {
+ Svg: DefaultSvg,
+ Defs: DefaultDefs,
+ Group: DefaultGroup,
+ Stack: DefaultStack,
+ Part: DefaultPart,
+ Point: DefaultPoint,
+ Path: DefaultPath,
+ Snippet: DefaultSnippet,
+ Grid: DefaultGrid,
+ Text: DefaultText,
+ TextOnPath: DefaultTextOnPath,
+}
+
+export const Pattern = ({
+ renderProps = false,
+ t = (string) => string,
+ components = {},
+ children = false,
+ className = 'freesewing pattern',
+ ref = false,
+}) => {
+ if (!renderProps) return null
+
+ // Merge default and swizzled components
+ components = {
+ ...defaultComponents,
+ ...components,
+ }
+
+ const { Svg, Defs, Stack, Group } = components
+
+ const optionalProps = {}
+ if (ref) optionalProps.ref = ref
+ if (className) optionalProps.className = className
+
+ return (
+
+
+
+
+ {children
+ ? children
+ : Object.keys(renderProps.stacks).map((stackName) => (
+
+ ))}
+
+
+ )
+}
diff --git a/packages/react-components/src/pattern/part.mjs b/packages/react-components/src/pattern/part.mjs
new file mode 100644
index 00000000000..a415171e23c
--- /dev/null
+++ b/packages/react-components/src/pattern/part.mjs
@@ -0,0 +1,50 @@
+import React, { forwardRef } from 'react'
+import { getId, getProps } from './utils.mjs'
+
+export const PartInner = forwardRef(
+ ({ stackName, partName, part, settings, components, t }, ref) => {
+ const { Group, Grid, Path, Point, Snippet } = components
+
+ return (
+
+ {settings.paperless ? : null}
+ {Object.keys(part.paths).map((pathName) => (
+
+ ))}
+ {Object.keys(part.points).map((pointName) => (
+
+ ))}
+ {Object.keys(part.snippets).map((snippetName) => (
+
+ ))}
+
+ )
+ }
+)
+
+export const Part = ({ stackName, partName, part, settings, components, t }) => {
+ const { Group } = components
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/react-components/src/pattern/path.mjs b/packages/react-components/src/pattern/path.mjs
new file mode 100644
index 00000000000..16e4b71b2cd
--- /dev/null
+++ b/packages/react-components/src/pattern/path.mjs
@@ -0,0 +1,22 @@
+import React from 'react'
+import { getId, getProps } from './utils.mjs'
+
+export const Path = ({ stackName, pathName, path, partName, part, settings, components, t }) => {
+ // Don't render hidden paths
+ if (path.hidden) return null
+
+ // Get potentially swizzled components
+ const { TextOnPath } = components
+
+ const output = []
+ const pathId = getId({ settings, stackName, partName, pathName })
+
+ return (
+ <>
+
+ {path.attributes.text.length > 0 ? : null}
+ >
+ )
+
+ return output
+}
diff --git a/packages/react-components/src/pattern/point.mjs b/packages/react-components/src/pattern/point.mjs
new file mode 100644
index 00000000000..e956c368fcd
--- /dev/null
+++ b/packages/react-components/src/pattern/point.mjs
@@ -0,0 +1,17 @@
+import React from 'react'
+import { withinPartBounds } from './utils.mjs'
+
+export const Point = ({ stackName, partName, pointName, part, point, settings, components, t }) => {
+ // Don't include points outside the part bounding box
+ if (!withinPartBounds(point, part)) return null
+
+ // Get potentially swizzled components
+ const { Circle, Text } = components
+
+ return point.attributes ? (
+ <>
+ {point.attributes.text ? : null}
+ {point.attributes.circle ? : null}
+ >
+ ) : null
+}
diff --git a/packages/react-components/src/pattern/snippet.mjs b/packages/react-components/src/pattern/snippet.mjs
new file mode 100644
index 00000000000..43f859dcfd4
--- /dev/null
+++ b/packages/react-components/src/pattern/snippet.mjs
@@ -0,0 +1,26 @@
+import React from 'react'
+import { getProps } from './utils.mjs'
+
+export const Snippet = ({ snippet }) => {
+ if (!snippet?.anchor || !snippet.def) return null
+ const snippetProps = {
+ xlinkHref: '#' + snippet.def,
+ x: snippet.anchor.x,
+ y: snippet.anchor.y,
+ }
+ const scale = snippet.attributes.list['data-scale']?.[0] || false
+ const rotate = snippet.attributes.list['data-rotate']?.[0] || false
+ if (scale || rotate) {
+ snippetProps.transform = ''
+ if (scale) {
+ snippetProps.transform += `translate(${snippetProps.x}, ${snippetProps.y}) `
+ snippetProps.transform += `scale(${scale}) `
+ snippetProps.transform += `translate(${snippetProps.x * -1}, ${snippetProps.y * -1}) `
+ }
+ if (rotate) {
+ snippetProps.transform += `rotate(${rotate}, ${snippetProps.x}, ${snippetProps.y}) `
+ }
+ }
+
+ return
+}
diff --git a/packages/react-components/src/pattern/stack.mjs b/packages/react-components/src/pattern/stack.mjs
new file mode 100644
index 00000000000..7dc7da6956a
--- /dev/null
+++ b/packages/react-components/src/pattern/stack.mjs
@@ -0,0 +1,18 @@
+import React from 'react'
+import { getProps } from './utils.mjs'
+
+export const Stack = ({ stackName, stack, settings, components, t }) => {
+ const { Group, Part } = components
+
+ return (
+
+ {[...stack.parts].map((part) => (
+
+ ))}
+
+ )
+}
diff --git a/packages/react-components/src/pattern/svg.mjs b/packages/react-components/src/pattern/svg.mjs
new file mode 100644
index 00000000000..50cd0f58846
--- /dev/null
+++ b/packages/react-components/src/pattern/svg.mjs
@@ -0,0 +1,41 @@
+import React from 'react'
+import { forwardRef } from 'react'
+
+export const Svg = forwardRef(
+ (
+ {
+ embed = true,
+ locale = 'en',
+ className = 'freesewing pattern',
+ style = {},
+ viewBox = false,
+ width,
+ height,
+ children,
+ },
+ ref
+ ) => {
+ if (width < 1) width = 1000
+ if (height < 1) height = 1000
+ let attributes = {
+ xmlns: 'http://www.w3.org/2000/svg',
+ 'xmlns:svg': 'http://www.w3.org/2000/svg',
+ xmlnsXlink: 'http://www.w3.org/1999/xlink',
+ xmlLang: locale,
+ viewBox: viewBox || `0 0 ${width} ${height}`,
+ className,
+ style,
+ }
+
+ if (!embed) {
+ attributes.width = width + 'mm'
+ attributes.height = height + 'mm'
+ }
+
+ return (
+
+ {children}
+
+ )
+ }
+)
diff --git a/packages/react-components/src/pattern/text.mjs b/packages/react-components/src/pattern/text.mjs
new file mode 100644
index 00000000000..d9cdbe4b8e5
--- /dev/null
+++ b/packages/react-components/src/pattern/text.mjs
@@ -0,0 +1,52 @@
+import React from 'react'
+import { translateStrings } from './utils.mjs'
+
+export const TextSpans = ({ point, t }) => {
+ const translated = translateStrings(t, point.attributes.list['data-text'])
+ const text = []
+ if (translated.indexOf('\n') !== -1) {
+ // Handle muti-line text
+ let key = 0
+ let lines = translated.split('\n')
+ text.push({lines.shift()} )
+ for (let line of lines) {
+ key++
+ text.push(
+
+ {line.toString().replace(/"/g, '"')}
+
+ )
+ }
+ } else text.push({translated} )
+
+ return text
+}
+
+export const Text = ({ point, t }) => (
+
+
+
+)
+
+export const TextOnPath = ({ path, pathId, t }) => {
+ const textPathProps = {
+ xlinkHref: '#' + pathId,
+ startOffset: '0%',
+ }
+ const translated = translateStrings(t, path.attributes.text)
+ const align = path.attributes.list['data-text-class'].join(' ')
+ if (align && align.indexOf('center') > -1) textPathProps.startOffset = '50%'
+ else if (align && align.indexOf('right') > -1) textPathProps.startOffset = '100%'
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/packages/react-components/src/pattern/utils.mjs b/packages/react-components/src/pattern/utils.mjs
new file mode 100644
index 00000000000..b2d724d438f
--- /dev/null
+++ b/packages/react-components/src/pattern/utils.mjs
@@ -0,0 +1,76 @@
+import React from 'react'
+
+export const getProps = (obj) => {
+ /** I can't believe it but there seems to be no method on NPM todo this */
+ const cssKey = (key) => {
+ let chunks = key.split('-')
+ if (chunks.length > 1) {
+ key = chunks.shift()
+ for (let s of chunks) key += s.charAt(0).toUpperCase() + s.slice(1)
+ }
+
+ return key
+ }
+
+ const convert = (css) => {
+ let style = {}
+ let rules = css.split(';')
+ for (let rule of rules) {
+ let chunks = rule.split(':')
+ if (chunks.length === 2) style[cssKey(chunks[0].trim())] = chunks[1].trim()
+ }
+ return style
+ }
+
+ let rename = {
+ class: 'className',
+ 'marker-start': 'markerStart',
+ 'marker-end': 'markerEnd',
+ }
+ let props = {}
+ for (let key in obj.attributes.list) {
+ if (key === 'style') props[key] = convert(obj.attributes.list[key].join(' '))
+ if (Object.keys(rename).indexOf(key) !== -1)
+ props[rename[key]] = obj.attributes.list[key].join(' ')
+ else if (key !== 'style') props[key] = obj.attributes.list[key].join(' ')
+ }
+
+ return props
+}
+
+export const withinPartBounds = (point, part) =>
+ point.x >= part.topLeft.x &&
+ point.x <= part.bottomRight.x &&
+ point.y >= part.topLeft.y &&
+ point.y <= part.bottomRight.y
+ ? true
+ : false
+
+export const getId = ({
+ settings,
+ stackName = false,
+ partName = false,
+ pathName = false,
+ pointName = false,
+ snippetName = false,
+ name = false,
+}) => {
+ let id = settings.idPrefix || ''
+ if (stackName) id += `${stackName}-`
+ if (partName) id += `${partName}-`
+ if (pathName) id += `${pathName}-`
+ if (pointName) id += `${pointName}-`
+ if (snippetName) id += `${snippetName}-`
+ if (name) id += name
+
+ return id
+}
+
+export const translateStrings = (t, list) => {
+ let translated = ''
+ for (const string of list) {
+ if (string) translated += t(string.toString()).replace(/"/g, '"') + ' '
+ }
+
+ return translated
+}