diff --git a/package-lock.json b/package-lock.json
index 3137e1eb..0f60c8fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,8 @@
"d3-delaunay": "^6.0.4",
"d3-fisheye": "^2.1.2",
"gcode-toolpath": "^3.0.0",
+ "i18next": "^24.2.3",
+ "i18next-browser-languagedetector": "^8.0.4",
"javascript-algorithms": "0.0.5",
"kdbush": "^4.0.2",
"konva": "^10.0.12",
@@ -42,6 +44,7 @@
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-ga4": "^2.1.0",
+ "react-i18next": "^15.4.1",
"react-icons": "^5.5.0",
"react-konva": "^19.2.1",
"react-redux": "^9.2.0",
@@ -8855,6 +8858,14 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -8899,6 +8910,44 @@
"node": ">=10.17.0"
}
},
+ "node_modules/i18next": {
+ "version": "24.2.3",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz",
+ "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://locize.com"
+ },
+ {
+ "type": "individual",
+ "url": "https://locize.com/i18next.html"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ }
+ ],
+ "dependencies": {
+ "@babel/runtime": "^7.26.10"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
+ "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -12036,6 +12085,31 @@
"resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-2.1.0.tgz",
"integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ=="
},
+ "node_modules/react-i18next": {
+ "version": "15.7.4",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
+ "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
+ "dependencies": {
+ "@babel/runtime": "^7.27.6",
+ "html-parse-stringify": "^3.0.1"
+ },
+ "peerDependencies": {
+ "i18next": ">= 23.4.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@@ -13747,7 +13821,7 @@
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
- "dev": true,
+ "devOptional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
@@ -14145,6 +14219,14 @@
"integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==",
"dev": true
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
diff --git a/package.json b/package.json
index 7730c464..2ebe95e8 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"d3-delaunay": "^6.0.4",
"d3-fisheye": "^2.1.2",
"gcode-toolpath": "^3.0.0",
+ "i18next": "^24.2.3",
+ "i18next-browser-languagedetector": "^8.0.4",
"javascript-algorithms": "0.0.5",
"kdbush": "^4.0.2",
"konva": "^10.0.12",
@@ -38,6 +40,7 @@
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-ga4": "^2.1.0",
+ "react-i18next": "^15.4.1",
"react-icons": "^5.5.0",
"react-konva": "^19.2.1",
"react-redux": "^9.2.0",
diff --git a/src/components/CheckboxOption.js b/src/components/CheckboxOption.js
index 409a5e54..fb26d158 100644
--- a/src/components/CheckboxOption.js
+++ b/src/components/CheckboxOption.js
@@ -1,4 +1,5 @@
import React from "react"
+import { useTranslation } from "react-i18next"
import Col from "react-bootstrap/Col"
import Row from "react-bootstrap/Row"
import Form from "react-bootstrap/Form"
@@ -13,6 +14,7 @@ const CheckboxOption = ({
onChange,
label = true,
}) => {
+ const { t } = useTranslation()
const option = options[optionKey]
const visible =
option.isVisible === undefined ? true : option.isVisible(model, data)
@@ -36,7 +38,7 @@ const CheckboxOption = ({
htmlFor="options-step"
className="mb-0"
>
- {option.title}
+ {t(option.title)}
)}
diff --git a/src/components/DropdownOption.js b/src/components/DropdownOption.js
index 882ef3af..043b357d 100644
--- a/src/components/DropdownOption.js
+++ b/src/components/DropdownOption.js
@@ -1,6 +1,7 @@
/* global document */
import React from "react"
+import { useTranslation } from "react-i18next"
import Col from "react-bootstrap/Col"
import Row from "react-bootstrap/Row"
import Form from "react-bootstrap/Form"
@@ -14,6 +15,7 @@ const DropdownOption = ({
onChange,
index,
}) => {
+ const { t } = useTranslation()
const option = options[optionKey]
const currentChoice = data[optionKey]
@@ -24,10 +26,10 @@ const DropdownOption = ({
choices = Array.isArray(choices)
? choices.map((choice) => {
- return { value: choice, label: choice }
+ return { value: choice, label: t(choice) }
})
: Object.keys(choices).map((key) => {
- return { value: key, label: choices[key] }
+ return { value: key, label: t(choices[key]) }
})
const currentLabel = (
choices.find((choice) => choice.value == currentChoice) || choices[0]
@@ -57,7 +59,7 @@ const DropdownOption = ({
className="m-0"
htmlFor="options-dropdown"
>
- {option.title}
+ {t(option.title)}
diff --git a/src/components/InputOption.js b/src/components/InputOption.js
index 32819bdb..91466789 100644
--- a/src/components/InputOption.js
+++ b/src/components/InputOption.js
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from "react"
+import { useTranslation } from "react-i18next"
import Col from "react-bootstrap/Col"
import Row from "react-bootstrap/Row"
import Form from "react-bootstrap/Form"
@@ -16,6 +17,7 @@ const InputOption = ({
label = true,
}) => {
inputRef ||= useRef()
+ const { t } = useTranslation()
const [value, setValue] = useState(data[optionKey])
const shiftKeyPressed = useKeyPress("Shift", inputRef)
@@ -109,7 +111,7 @@ const InputOption = ({
htmlFor={`option-${optionKey}`}
className="mb-0"
>
- {title}
+ {t(title)}
)}
@@ -130,7 +132,7 @@ const InputOption = ({
className="me-2 mb-0"
style={{ width: "22px" }}
>
- {title}
+ {t(title)}
)}
{renderedInput}
diff --git a/src/components/QuadrantButtonsOption.js b/src/components/QuadrantButtonsOption.js
index 9cf3b4ba..0c3396d8 100644
--- a/src/components/QuadrantButtonsOption.js
+++ b/src/components/QuadrantButtonsOption.js
@@ -1,4 +1,5 @@
import React from "react"
+import { useTranslation } from "react-i18next"
import Col from "react-bootstrap/Col"
import Row from "react-bootstrap/Row"
import Form from "react-bootstrap/Form"
@@ -6,6 +7,7 @@ import ToggleButton from "react-bootstrap/ToggleButton"
import ToggleButtonGroup from "react-bootstrap/ToggleButtonGroup"
const QuadrantButtonsOption = (props) => {
+ const { t } = useTranslation()
const option = props.options[props.optionKey]
const { data } = props
const value = data[props.optionKey]
@@ -22,7 +24,7 @@ const QuadrantButtonsOption = (props) => {
sm={5}
className="mb-1"
>
-
{option.title}
+ {t(option.title)}
{
className="px-4"
style={{ borderRadius: 0 }}
>
- upper left
+ {t("upper left")}
{
className="px-4"
style={{ borderRadius: 0 }}
>
- upper right
+ {t("upper right")}
{
className="px-4"
style={{ borderRadius: 0 }}
>
- lower left
+ {t("lower left")}
{
className="px-4"
style={{ borderRadius: 0 }}
>
- lower right
+ {t("lower right")}
diff --git a/src/components/SliderOption.js b/src/components/SliderOption.js
index 797f779d..92fb8a47 100644
--- a/src/components/SliderOption.js
+++ b/src/components/SliderOption.js
@@ -1,5 +1,6 @@
import Slider from "rc-slider"
import React, { useState, useEffect } from "react"
+import { useTranslation } from "react-i18next"
import Col from "react-bootstrap/Col"
import Row from "react-bootstrap/Row"
import Form from "react-bootstrap/Form"
@@ -12,6 +13,7 @@ const SliderOption = ({
model,
label = true,
}) => {
+ const { t } = useTranslation()
const [value, setValue] = useState(data[optionKey])
useEffect(() => {
@@ -114,7 +116,7 @@ const SliderOption = ({
htmlFor={`option-${optionKey}`}
className="mb-0"
>
- {title}
+ {t(title)}
)}
diff --git a/src/components/ToggleButtonOption.js b/src/components/ToggleButtonOption.js
index 6f45cf1c..d304050a 100644
--- a/src/components/ToggleButtonOption.js
+++ b/src/components/ToggleButtonOption.js
@@ -1,4 +1,5 @@
import React from "react"
+import { useTranslation } from "react-i18next"
import Col from "react-bootstrap/Col"
import Row from "react-bootstrap/Row"
import Form from "react-bootstrap/Form"
@@ -6,6 +7,7 @@ import ToggleButton from "react-bootstrap/ToggleButton"
import ToggleButtonGroup from "react-bootstrap/ToggleButtonGroup"
const ToggleButtonOption = (props) => {
+ const { t } = useTranslation()
const option = props.options[props.optionKey]
const { data } = props
const model = props.model || data
@@ -27,7 +29,7 @@ const ToggleButtonOption = (props) => {
return (
- {option.title}
+ {t(option.title)}
@@ -48,7 +50,7 @@ const ToggleButtonOption = (props) => {
variant="light"
value={choice}
>
- {choice}
+ {t(choice)}
)
})}
diff --git a/src/features/app/About.js b/src/features/app/About.js
index c9581540..8762b7d2 100644
--- a/src/features/app/About.js
+++ b/src/features/app/About.js
@@ -1,4 +1,5 @@
import React from "react"
+import { useTranslation } from "react-i18next"
import Container from "react-bootstrap/Container"
import Row from "react-bootstrap/Row"
import Col from "react-bootstrap/Col"
@@ -9,6 +10,8 @@ import { SANDIFY_VERSION } from "@/features/app/appSlice"
import "./About.scss"
const About = () => {
+ const { t } = useTranslation()
+
return (
)}
{model.description && (
- {model.description}
+
+ {t(`description.${type}`, { defaultValue: model.description })}
+
)}
)
diff --git a/src/features/effects/EffectList.js b/src/features/effects/EffectList.js
index 647c851c..064769e0 100644
--- a/src/features/effects/EffectList.js
+++ b/src/features/effects/EffectList.js
@@ -1,4 +1,5 @@
import React from "react"
+import { useTranslation } from "react-i18next"
import { useDispatch, useSelector } from "react-redux"
import Button from "react-bootstrap/Button"
import ListGroup from "react-bootstrap/ListGroup"
@@ -25,6 +26,7 @@ const EffectRow = ({
effect,
handleEffectSelected,
handleToggleEffectVisible,
+ t,
}) => {
const { name, id, visible } = effect
const instance = getEffect(effect.type)
@@ -58,7 +60,7 @@ const EffectRow = ({
className="layer-button"
variant="light"
data-id={id}
- data-tooltip-content={visible ? "Hide effect" : "Show effect"}
+ data-tooltip-content={visible ? t("Hide effect") : t("Show effect")}
data-tooltip-id="tooltip-toggle-visible"
onClick={() => handleToggleEffectVisible(id, effect.visible)}
>
@@ -72,7 +74,7 @@ const EffectRow = ({
className="me-3"
style={{ fontSize: "80%" }}
>
- {instance.label}
+ {t(instance.label)}
@@ -81,6 +83,7 @@ const EffectRow = ({
}
const EffectList = ({ effects, selectedLayer }) => {
+ const { t } = useTranslation()
const dispatch = useDispatch()
const currentEffectId = useSelector(selectCurrentEffectId)
const selectedEffectId = useSelector(selectSelectedEffectId)
@@ -145,6 +148,7 @@ const EffectList = ({ effects, selectedLayer }) => {
handleEffectSelected={handleEffectSelected}
handleToggleEffectVisible={handleToggleEffectVisible}
index={index}
+ t={t}
/>
))}
diff --git a/src/features/effects/EffectManager.js b/src/features/effects/EffectManager.js
index fabf1c10..2e7f1c1e 100644
--- a/src/features/effects/EffectManager.js
+++ b/src/features/effects/EffectManager.js
@@ -1,4 +1,5 @@
import React, { useState } from "react"
+import { useTranslation } from "react-i18next"
import Button from "react-bootstrap/Button"
import Accordion from "react-bootstrap/Accordion"
import { Tooltip } from "react-tooltip"
@@ -22,6 +23,7 @@ import NewEffect from "./NewEffect"
import CopyEffect from "./CopyEffect"
const EffectManager = () => {
+ const { t } = useTranslation()
const dispatch = useDispatch()
const selectedLayer = useSelector(selectSelectedLayer)
const selectedEffect = useSelector(selectSelectedEffect)
@@ -69,7 +71,7 @@ const EffectManager = () => {
variant="secondary"
onClick={toggleNewEffectModal}
>
- Add effect
+ {t("Add effect")}
)}
{numEffects > 0 && (
@@ -78,7 +80,9 @@ const EffectManager = () => {
className="mt-3"
>
- Effects
+
+ {t("Effects")}
+
{
className="ms-2 layer-button"
variant="light"
size="sm"
- data-tooltip-content="Create new effect"
+ data-tooltip-content={t("Create new effect")}
data-tooltip-id="tooltip-new-layer"
onClick={toggleNewEffectModal}
>
@@ -101,7 +105,7 @@ const EffectManager = () => {
- Copy
+ {t("Copy")}
diff --git a/src/features/layers/LayerEditor.js b/src/features/layers/LayerEditor.js
index 6b5df4ba..ac4e730e 100644
--- a/src/features/layers/LayerEditor.js
+++ b/src/features/layers/LayerEditor.js
@@ -1,6 +1,7 @@
/* global document */
import React from "react"
+import { useTranslation, Trans } from "react-i18next"
import { useDispatch, useSelector } from "react-redux"
import Col from "react-bootstrap/Col"
import Row from "react-bootstrap/Row"
@@ -21,35 +22,50 @@ import EffectManager from "@/features/effects/EffectManager"
import { selectSelectedLayer } from "./layersSlice"
const LayerEditor = () => {
+ const { t } = useTranslation()
const dispatch = useDispatch()
const layer = useSelector(selectSelectedLayer)
const instance = new Layer(layer.type)
const model = instance.model
const layerOptions = instance.getOptions()
const modelOptions = model.getOptions()
- const selectOptions = getShapeSelectOptions()
+ const selectOptions = getShapeSelectOptions().map((group) => ({
+ ...group,
+ label: t(group.label),
+ options: group.options.map((opt) => ({ ...opt, label: t(opt.label) })),
+ }))
const allowModelSelection = model.selectGroup !== "import"
const selectedOption = {
value: model.type,
- label: model.label,
+ label: t(model.label),
}
const link = model.link
const linkText = model.linkText || "here"
- const description = model.description ? model.description + " " : ""
+ const description = model.description || ""
+ const translatedDescription = description
+ ? t(`description.${model.type}`, { defaultValue: description })
+ : ""
+ const translatedLinkText = t(`linkText.${model.type}`, {
+ defaultValue: linkText,
+ })
const renderedLink =
link || description ? (
- {description}
- See{" "}
-
- {linkText}
- {" "}
- for more information.
+ {translatedDescription}{" "}
+
+ {translatedLinkText}
+ ,
+ ]}
+ />
) : undefined
@@ -77,7 +93,7 @@ const LayerEditor = () => {
sm={5}
className="mb-1"
>
- Type
+ {t("Type")}
{
{model.canTransform(layer) && (
- Transform
+ {t("Transform")}
@@ -159,7 +175,7 @@ const LayerEditor = () => {
diff --git a/src/features/layers/LayerList.js b/src/features/layers/LayerList.js
index 6d113344..2c448ab6 100644
--- a/src/features/layers/LayerList.js
+++ b/src/features/layers/LayerList.js
@@ -1,4 +1,5 @@
import React from "react"
+import { useTranslation } from "react-i18next"
import Button from "react-bootstrap/Button"
import ListGroup from "react-bootstrap/ListGroup"
import { Tooltip } from "react-tooltip"
@@ -29,6 +30,7 @@ const LayerRow = ({
layer,
handleLayerSelected,
handleToggleLayerVisible,
+ t,
}) => {
const { name, id, visible } = layer
const activeClass = current ? "active" : selected ? "selected" : ""
@@ -65,7 +67,7 @@ const LayerRow = ({
className="layer-button"
variant="light"
data-id={id}
- data-tooltip-content={visible ? "Hide layer" : "Show layer"}
+ data-tooltip-content={visible ? t("Hide layer") : t("Show layer")}
data-tooltip-id={tooltipId}
data-tooltip-place="top-end"
onClick={(e) => {
@@ -82,7 +84,7 @@ const LayerRow = ({
className="me-2"
style={{ fontSize: "80%" }}
>
- {instance.model.label}
+ {t(instance.model.label)}
@@ -91,6 +93,7 @@ const LayerRow = ({
}
const LayerList = () => {
+ const { t } = useTranslation()
// row has to be dragged 3 pixels before dragging starts; this allows the buttons
// on the row to work properly.
const sensors = useSensors(
@@ -157,6 +160,7 @@ const LayerList = () => {
handleLayerSelected={handleLayerSelected}
handleToggleLayerVisible={handleToggleLayerVisible}
index={index}
+ t={t}
/>
))}
diff --git a/src/features/layers/LayerManager.js b/src/features/layers/LayerManager.js
index f4aa39d2..d8210c33 100644
--- a/src/features/layers/LayerManager.js
+++ b/src/features/layers/LayerManager.js
@@ -1,6 +1,7 @@
/* global document */
import React, { useState, useEffect } from "react"
+import { useTranslation } from "react-i18next"
import Button from "react-bootstrap/Button"
import { Tooltip } from "react-tooltip"
import { useSelector, useDispatch } from "react-redux"
@@ -23,6 +24,7 @@ import Layer from "./Layer"
import "./LayerManager.scss"
const LayerManager = () => {
+ const { t } = useTranslation()
const dispatch = useDispatch()
const selectedLayerId = useSelector(selectSelectedLayerId)
const selectedLayer = useSelector(selectSelectedLayer)
@@ -76,7 +78,7 @@ const LayerManager = () => {
className="ms-2 layer-button"
variant="light"
size="sm"
- data-tooltip-content="Create new layer"
+ data-tooltip-content={t("Create new layer")}
data-tooltip-id="tooltip-new-layer"
onClick={toggleNewLayerModal}
>
@@ -87,7 +89,7 @@ const LayerManager = () => {
@@ -98,7 +100,7 @@ const LayerManager = () => {
@@ -108,7 +110,7 @@ const LayerManager = () => {
@@ -119,7 +121,7 @@ const LayerManager = () => {
diff --git a/src/features/layers/NewLayer.js b/src/features/layers/NewLayer.js
index af7df1d7..efde77ae 100644
--- a/src/features/layers/NewLayer.js
+++ b/src/features/layers/NewLayer.js
@@ -1,4 +1,5 @@
import React, { useState, useRef } from "react"
+import { useTranslation } from "react-i18next"
import { useSelector, useDispatch } from "react-redux"
import { selectCurrentMachine } from "@/features/machines/machinesSlice"
import Select from "react-select"
@@ -27,11 +28,12 @@ const customStyles = {
}
const NewLayer = ({ toggleModal, showModal }) => {
+ const { t } = useTranslation()
const dispatch = useDispatch()
const selectRef = useRef()
const selectOptions = getShapeSelectOptions()
const [type, setType] = useState(defaultShape.type)
- const [name, setName] = useState(defaultShape.label)
+ const [name, setName] = useState(t(defaultShape.label))
const [randomize, setRandomize] = useState(false)
const selectedShape = getShape(type)
@@ -49,7 +51,7 @@ const NewLayer = ({ toggleModal, showModal }) => {
const shape = getShape(selected.value)
setType(selected.value)
- setName(shape.label.toLowerCase())
+ setName(t(shape.label).toLowerCase())
}
const handleChangeNewName = (event) => {
@@ -88,13 +90,13 @@ const NewLayer = ({ toggleModal, showModal }) => {
onEntered={handleInitialFocus}
>
- Create new layer
+ {t("Create new layer")}
- Create
+ {t("Create")}
diff --git a/src/features/machines/CopyMachine.js b/src/features/machines/CopyMachine.js
index 6d728fc8..4791c673 100644
--- a/src/features/machines/CopyMachine.js
+++ b/src/features/machines/CopyMachine.js
@@ -1,4 +1,5 @@
import React, { useRef, useState, useEffect } from "react"
+import { useTranslation } from "react-i18next"
import Button from "react-bootstrap/Button"
import Modal from "react-bootstrap/Modal"
import Col from "react-bootstrap/Col"
@@ -8,6 +9,7 @@ import { useDispatch, useSelector } from "react-redux"
import { selectCurrentMachine, addMachine } from "./machinesSlice"
const CopyMachine = ({ toggleModal, showModal }) => {
+ const { t } = useTranslation()
const dispatch = useDispatch()
const currentMachine = useSelector(selectCurrentMachine)
const namedInputRef = useRef(null)
@@ -49,13 +51,15 @@ const CopyMachine = ({ toggleModal, showModal }) => {
onEntered={handleInitialFocus}
>
- Copy {currentMachine?.name || ""}
+
+ {t("Copy")} {currentMachine?.name || ""}
+
- Copy
+ {t("Copy")}
diff --git a/src/features/machines/MachineManager.js b/src/features/machines/MachineManager.js
index 2f5181b2..0608f666 100644
--- a/src/features/machines/MachineManager.js
+++ b/src/features/machines/MachineManager.js
@@ -1,4 +1,5 @@
import React, { useState } from "react"
+import { useTranslation } from "react-i18next"
import Button from "react-bootstrap/Button"
import { Tooltip } from "react-tooltip"
import { FaTrash, FaCopy, FaPlusSquare } from "react-icons/fa"
@@ -14,6 +15,7 @@ import CopyMachine from "./CopyMachine"
import NewMachine from "./NewMachine"
const MachineManager = () => {
+ const { t } = useTranslation()
const dispatch = useDispatch()
const numMachines = useSelector(selectNumMachines)
const currentMachineId = useSelector(selectCurrentMachineId)
@@ -44,7 +46,7 @@ const MachineManager = () => {
className="ms-2 layer-button"
variant="light"
size="sm"
- data-tooltip-content="Create new machine"
+ data-tooltip-content={t("Create new machine")}
data-tooltip-id="tooltip-new-machine"
onClick={toggleNewMachineModal}
>
@@ -55,7 +57,7 @@ const MachineManager = () => {
@@ -66,7 +68,7 @@ const MachineManager = () => {
diff --git a/src/features/machines/NewMachine.js b/src/features/machines/NewMachine.js
index 3cc0b127..60a11e31 100644
--- a/src/features/machines/NewMachine.js
+++ b/src/features/machines/NewMachine.js
@@ -1,4 +1,5 @@
import React, { useState, useRef } from "react"
+import { useTranslation } from "react-i18next"
import { useDispatch } from "react-redux"
import Select from "react-select"
import Col from "react-bootstrap/Col"
@@ -23,6 +24,7 @@ const customStyles = {
}
const NewMachine = ({ toggleModal, showModal }) => {
+ const { t } = useTranslation()
const dispatch = useDispatch()
const selectRef = useRef()
const selectOptions = getMachineSelectOptions()
@@ -74,13 +76,13 @@ const NewMachine = ({ toggleModal, showModal }) => {
onEntered={handleInitialFocus}
>
- Create new machine
+ {t("Create new machine")}
- Create
+ {t("Create")}
diff --git a/src/features/preview/PreviewManager.js b/src/features/preview/PreviewManager.js
index 899d5a1d..71a19c46 100644
--- a/src/features/preview/PreviewManager.js
+++ b/src/features/preview/PreviewManager.js
@@ -20,7 +20,7 @@ import "./PreviewManager.scss"
import { updatePreview } from "./previewSlice"
import PreviewWindow from "./PreviewWindow"
-const PreviewManager = () => {
+const PreviewManager = ({ isActive }) => {
const dispatch = useDispatch()
const currentLayer = useSelector(selectCurrentLayer)
const currentEffectLayer = useSelector(selectCurrentEffect)
@@ -109,7 +109,7 @@ const PreviewManager = () => {
className={`preview-wrapper d-flex flex-column${previewAlignClass}`}
ref={wrapperRef}
>
-
+
diff --git a/src/features/preview/PreviewWindow.js b/src/features/preview/PreviewWindow.js
index ec1e5eb2..715c5205 100644
--- a/src/features/preview/PreviewWindow.js
+++ b/src/features/preview/PreviewWindow.js
@@ -17,7 +17,7 @@ import ShapePreview from "./ShapePreview"
import ConnectorPreview from "./ConnectorPreview"
import { setPreviewSize, selectPreviewZoom } from "./previewSlice"
-const PreviewWindow = () => {
+const PreviewWindow = ({ isActive }) => {
const dispatch = useDispatch()
const machine = useSelector(selectCurrentMachine)
const machineInstance = getMachine(machine)
@@ -41,7 +41,7 @@ const PreviewWindow = () => {
parseInt(getComputedStyle(wrapper).getPropertyValue("height")) -
2 * stagePadding
- if (canvasWidth !== width || canvasHeight !== height) {
+ if (width > 0 && height > 0) {
dispatch(setPreviewSize({ width, height }))
}
}
@@ -55,7 +55,7 @@ const PreviewWindow = () => {
return () => {
window.removeEventListener("resize", throttledResize, false)
}
- }, [])
+ }, [dispatch, isActive])
const { width, height } = machineInstance
const scaleWidth = canvasWidth / width
diff --git a/src/i18n/README.md b/src/i18n/README.md
new file mode 100644
index 00000000..0fa78d41
--- /dev/null
+++ b/src/i18n/README.md
@@ -0,0 +1,49 @@
+# Internationalization (i18n)
+
+Sandify uses [i18next](https://www.i18next.com/) with a natural language keys pattern.
+
+Outside of these translations, sandify code, comments, and (ideally) git commit messages should be in English.
+
+Any additional translations and maintenance is welcome. Any translations should be as close to English as reasonably possible. Translations do not have to be literally the same.
+
+## How It Works
+
+English strings are used as translation keys:
+
+```jsx
+t("Name") // Returns "Name" in English, "名称" in Chinese
+t("Save pattern") // Falls back to "Save pattern" if not translated
+```
+
+For longer content, use named keys:
+
+```jsx
+t("description.heart", { defaultValue: "The heart curve..." })
+```
+
+Missing translations fall back to English automatically.
+
+## Adding Translations
+
+1. Find the English string in the code (e.g., `t("New label")`)
+2. Add the translation to `locales/zh.json`:
+ ```json
+ "New label": "新标签"
+ ```
+
+Keys in `zh.json` are alphabetized for easier lookup.
+
+## Adding a New Language
+
+1. Create `locales/xx.json` (copy structure from `zh.json`)
+2. Register in `index.js`:
+ ```js
+ import xxTranslations from "./locales/xx.json"
+
+ const translations = {
+ en: enTranslations,
+ zh: zhTranslations,
+ xx: xxTranslations, // add here
+ }
+ ```
+3. Add to the language selector in `src/features/app/Settings.js`
diff --git a/src/i18n/index.js b/src/i18n/index.js
new file mode 100644
index 00000000..75167259
--- /dev/null
+++ b/src/i18n/index.js
@@ -0,0 +1,70 @@
+/* global localStorage, navigator */
+
+import i18n from "i18next"
+import { initReactI18next } from "react-i18next"
+import enTranslations from "./locales/en.json"
+import zhTranslations from "./locales/zh.json"
+
+// All available translations (add new languages here)
+const translations = {
+ en: enTranslations,
+ zh: zhTranslations,
+}
+
+// Normalize language code (e.g., zh-CN → zh)
+const normalizeLanguage = (lang) => {
+ if (!lang) return null
+ if (lang.startsWith("zh")) return "zh"
+
+ return lang.split("-")[0]
+}
+
+// Detect language: localStorage > browser > default to English
+const detectLanguage = () => {
+ const saved = normalizeLanguage(localStorage.getItem("sandify-language"))
+ if (saved && translations[saved]) return saved
+
+ const browser = normalizeLanguage(navigator.language)
+ if (browser && translations[browser]) return browser
+
+ return "en"
+}
+
+const detectedLang = detectLanguage()
+
+// Build resources - always include English for named key fallbacks
+const resources = {
+ en: { translation: enTranslations },
+ ...(translations[detectedLang] && detectedLang !== "en"
+ ? { [detectedLang]: { translation: translations[detectedLang] } }
+ : {}),
+}
+
+// Initialize i18next with natural language keys pattern
+// English strings are used as keys, other translations loaded on demand
+i18n.use(initReactI18next).init({
+ resources,
+ lng: detectedLang,
+ fallbackLng: "en",
+ nsSeparator: false,
+ keySeparator: false,
+ interpolation: {
+ escapeValue: false,
+ },
+ returnEmptyString: false,
+ parseMissingKeyHandler: (key) => key,
+})
+
+export const changeLanguage = async (language) => {
+ const lang = normalizeLanguage(language)
+
+ // Add translations if available and not already loaded
+ if (translations[lang] && !i18n.hasResourceBundle(lang, "translation")) {
+ i18n.addResourceBundle(lang, "translation", translations[lang], true, true)
+ }
+
+ localStorage.setItem("sandify-language", lang)
+ await i18n.changeLanguage(lang)
+}
+
+export default i18n
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
new file mode 100644
index 00000000..193c4ce1
--- /dev/null
+++ b/src/i18n/locales/en.json
@@ -0,0 +1,32 @@
+{
+ "about.tagline": "create patterns for robots that draw in sand with ball bearings",
+ "description.circlePacker": "Circle packing is an arrangement of circles of different sizes such that no overlapping occurs and no circle can be enlarged without creating an overlap.",
+ "description.epicycloid": "A clover shape is an epicycloid. Imagine two circles, with the outer circle rolling around the inside one. The shape traced by a point on the outer circle as it rolls is called an epicycloid.",
+ "description.fractalSpirograph": "A fractal spirograph is generated by a series of circles that rotate around one another. The pattern is created by tracing a point that rides along the outermost circle as it rolls.",
+ "description.heart": "A heart curve can be defined in a number of ways mathematically. Ours is a parametric equation.",
+ "description.hypocycloid": "A web shape is a hypocycloid. Imagine two circles, with the inner one rolling around inside the outer one. The shape traced by a point on the inner circle as it rolls is called a hypocycloid.",
+ "description.lsystem": "A fractal line writer is a Lindenmayer (L) system. L-systems string together symbols to specify instructions for moving in 2D space (e.g., turn left or right, move left or right). When applied recursively, they generate fractal patterns.",
+ "description.noise_wave": "Perlin noise is a type of gradient noise that can be used to generate textures and terrain. Here we use it to create a pleasing series of wavy lines.",
+ "description.programCode": "When exporting the pattern to a file, the provided program code is added before and/or after this layer is rendered.",
+ "description.reuleaux": "A Reuleaux polygon is a curve of constant width made up of circular arcs of constant radius. It is named after the 19th-century German engineer Frances Reuleaux, who used the Reuleaux triangle in his designs.",
+ "description.rose": "A rose is a curve that has a petal-like shape.",
+ "description.spaceFiller": "A space filling curve draws a continuous line that covers every point in a space without missing any location or crossing itself.",
+ "description.tessellationTwist": "A tessellation twist shape is a form of tessellation. Tessellation covers a surface with tiles (in our case, equilateral triangles) with no overlaps or gaps.",
+ "description.v1Engineering": "This shape represents the V1 Engineering logo. V1 Engineering provides low-cost, customizable machine designs. Sandify was created in 2017 by users of their forum.",
+ "description.voronoi": "A Voronoi diagram divides a space into regions based on a set of seed points. Each region contains all the points that are closer to its seed point than to any other seed point.",
+ "export.wikiNote": "See the <0>wiki0> for details on program export variables.",
+ "layer.seeMoreInfo": "See <0>{{linkText}}0> for more information.",
+ "linkText.circlePacker": "Wikipedia",
+ "linkText.epicycloid": "Wolfram Mathworld",
+ "linkText.fractalSpirograph": "this blog post",
+ "linkText.heart": "Wolfram Mathworld",
+ "linkText.hypocycloid": "Wolfram Mathworld",
+ "linkText.lsystem": "Wikipedia",
+ "linkText.noise_wave": "Wikipedia",
+ "linkText.reuleaux": "Wikipedia",
+ "linkText.rose": "Wolfram Mathworld",
+ "linkText.spaceFiller": "Wikipedia",
+ "linkText.tessellationTwist": "Wikipedia",
+ "linkText.v1Engineering": "V1 Engineering",
+ "linkText.voronoi": "Wikipedia"
+}
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
new file mode 100644
index 00000000..9f0bd101
--- /dev/null
+++ b/src/i18n/locales/zh.json
@@ -0,0 +1,280 @@
+{
+ "about.tagline": "为使用滚珠在沙子中绘画的机器人创建图案",
+ "description.circlePacker": "圆形包装是不同大小圆形的排列,使得不会发生重叠,且在不产生重叠的情况下无法放大任何圆形。",
+ "description.epicycloid": "三叶草形状是外摆线。想象两个圆,外圆绕着内圆滚动。外圆上的点在滚动过程中形成的轨迹称为外摆线。",
+ "description.fractalSpirograph": "分形螺旋图由一系列相互旋转的圆生成。图案是通过跟踪沿最外圆滚动的点创建的。",
+ "description.heart": "心形曲线可以通过多种数学方式定义。我们的心形是一个参数方程。",
+ "description.hypocycloid": "网形是内摆线。想象两个圆,内圆在外圆内部滚动。内圆上的点在滚动过程中形成的轨迹称为内摆线。",
+ "description.lsystem": "分形线绘制器是一种林登迈尔(L)系统。L系统将符号链接在一起以指定在二维空间中移动的指令(例如,左转或右转,左移或右移)。当递归应用时,它们会生成分形图案。",
+ "description.noise_wave": "Perlin 噪声是一种梯度噪声,可用于生成纹理和地形。这里我们用它来创建吸引人的波浪线系列。",
+ "description.programCode": "将图案导出到文件时,提供的程序代码会在渲染此图层之前和/或之后添加。",
+ "description.reuleaux": "鲁洛多边形是由恒定半径的圆弧组成的恒定宽度曲线。它以19世纪德国工程师弗朗西斯·鲁洛(Frances Reuleaux)命名,他在设计中使用了鲁洛三角形。",
+ "description.rose": "玫瑰线是一种具有花瓣形状的曲线。",
+ "description.spaceFiller": "空间填充曲线绘制一条连续的线,覆盖空间中的每个点,不会遗漏任何位置或自相交。",
+ "description.tessellationTwist": "镶嵌扭转形状是镶嵌的一种形式。镶嵌使用瓦片(在我们的例子中是等边三角形)覆盖表面,没有重叠或间隙。",
+ "description.v1Engineering": "这个形状代表 V1 Engineering 的标志。V1 Engineering 提供低成本、可定制的机器设计。Sandify 于 2017 年由其论坛用户创建。",
+ "description.voronoi": "沃罗诺伊图根据一组种子点将空间划分为区域。每个区域包含所有比其他种子点更接近其种子点的点。",
+ "export.wikiNote": "有关程序导出变量的详细信息,请参阅<0>wiki0>。",
+ "layer.seeMoreInfo": "查看<0>{{linkText}}0>了解更多信息。",
+ "linkText.circlePacker": "维基百科",
+ "linkText.epicycloid": "Wolfram 数学世界",
+ "linkText.fractalSpirograph": "这篇博客文章",
+ "linkText.heart": "Wolfram 数学世界",
+ "linkText.hypocycloid": "Wolfram 数学世界",
+ "linkText.lsystem": "维基百科",
+ "linkText.noise_wave": "维基百科",
+ "linkText.reuleaux": "维基百科",
+ "linkText.rose": "Wolfram 数学世界",
+ "linkText.spaceFiller": "维基百科",
+ "linkText.tessellationTwist": "维基百科",
+ "linkText.v1Engineering": "V1 Engineering",
+ "linkText.voronoi": "维基百科",
+
+ "1: Reverse path": "1: 反转路径",
+ "2: Move start point (%)": "2: 移动起点 (%)",
+ "3: Draw path (%)": "3: 绘制路径 (%)",
+ "4: Add perimeter border": "4: 添加周长边框",
+ "5: Backtrack at end (%)": "5: 结束回退 (%)",
+ "About": "关于",
+ "Add effect": "新建效果",
+ "Alignment": "对齐方式",
+ "along path": "沿路径",
+ "along perimeter": "沿边缘",
+ "Alternate rotation direction": "交替旋转方向",
+ "Amplitude": "振幅",
+ "angle": "角度",
+ "Angle": "角度",
+ "Application": "应用方式",
+ "as-is": "原样",
+ "both": "两者",
+ "Bottom": "底部",
+ "Brightness": "亮度",
+ "Brightness filter": "亮度滤镜",
+ "Cancel": "取消",
+ "Center": "中心",
+ "center": "中心",
+ "Circle": "圆",
+ "circle": "圆形",
+ "Circle packer": "圆形包装",
+ "Circle uniformity": "圆形均匀性",
+ "circular": "圆形",
+ "clockwise": "顺时针",
+ "Clover": "三叶草",
+ "Connect rows": "连接行",
+ "Connect to next layer": "连接到下一层",
+ "constant": "常量",
+ "Contour": "轮廓",
+ "Contrast": "对比度",
+ "Copy": "复制",
+ "Copy effect": "复制特效",
+ "Copy layer": "复制图层",
+ "Copy machine": "复制机器",
+ "counterclockwise": "逆时针",
+ "Create": "创建",
+ "Create new effect": "创建新特效",
+ "Create new layer": "创建新图层",
+ "Create new machine": "创建新机器",
+ "Cursive": "手写体",
+ "custom": "自定义",
+ "Default Machine": "默认机器",
+ "Delete effect": "删除特效",
+ "Delete layer": "删除图层",
+ "Delete machine": "删除机器",
+ "Denominator": "分母",
+ "Direction": "方向",
+ "Distance (mm)": "移动距离 (毫米)",
+ "Distortion": "畸变",
+ "Download": "下载",
+ "Draw border": "绘制边框",
+ "Effects": "特效",
+ "End code": "结束代码",
+ "End point": "终点",
+ "Epicycloid": "外摆线",
+ "Erasers": "清除",
+ "Error": "错误",
+ "Export": "导出",
+ "Export as": "导出为",
+ "Export pattern as...": "导出图案为...",
+ "Export to a file": "导出到文件",
+ "facing inward": "面向内",
+ "facing outward": "面向外",
+ "Fancy text": "花式文字",
+ "File": "文件",
+ "File name": "文件名",
+ "Fine tuning": "微调",
+ "Fisheye": "鱼眼",
+ "Font": "字体",
+ "Force origin": "强制原点",
+ "Fractal line writer": "分形线绘制器",
+ "Fractal spirograph": "分形螺旋图",
+ "Frequency": "频率",
+ "function": "函数",
+ "grid": "网格",
+ "H": "高",
+ "Heart": "心形",
+ "Height": "高度",
+ "Hide effect": "隐藏特效",
+ "Hide layer": "隐藏图层",
+ "Hypocycloid": "内摆线",
+ "Import": "导入",
+ "Import image...": "导入图像...",
+ "Import layer...": "导入图层...",
+ "inside": "内部",
+ "intact": "完整",
+ "Invert": "反转",
+ "Inverted": "反相",
+ "Iterations": "迭代次数",
+ "Language": "语言",
+ "Large circle radius": "大圆半径",
+ "Layers": "图层",
+ "left": "左对齐",
+ "Left": "左对齐",
+ "line": "直线",
+ "Line count": "线条数量",
+ "Line spacing": "行间距",
+ "Linear": "线性",
+ "Lines": "线条",
+ "Loading...": "加载中...",
+ "Lock aspect ratio": "锁定宽高比",
+ "Loop": "循环",
+ "lower left": "左下",
+ "lower right": "右下",
+ "Machine Type": "机器类型",
+ "Machines": "机器",
+ "Magnification": "放大倍数",
+ "Maintain aspect ratio": "锁定宽高比",
+ "Mask": "遮罩",
+ "Mask shape": "遮罩形状",
+ "Max radius (mm)": "最大半径 (毫米)",
+ "Max x (mm)": "最大 X (毫米)",
+ "Max y (mm)": "最大 Y (毫米)",
+ "Max. distance": "最大距离",
+ "Maximum rho value (0-1)": "最大 rho 值 (0-1)",
+ "Min x (mm)": "最小 X (毫米)",
+ "Min y (mm)": "最小 Y (毫米)",
+ "Min. distance": "最小距离",
+ "Minimize Perimeter Moves": "最小化轮廓移动",
+ "Minimize perimeter moves": "最小化周长移动",
+ "Minimum radius": "最小半径",
+ "Modulation": "调制",
+ "Monospace": "等宽字体",
+ "Move and resize": "移动和调整大小",
+ "Name": "名称",
+ "New": "新建",
+ "Noise": "噪声",
+ "Noise level": "噪声级别",
+ "Noise type": "噪声类型",
+ "Noise waves": "噪声波",
+ "none": "无",
+ "Number of circles": "圆形数量",
+ "Number of lobes": "叶片数量",
+ "Number of loops": "循环次数",
+ "Number of points": "顶点数",
+ "Number of sides": "边数",
+ "Number of times to draw shape along track": "沿轨迹绘制形状的次数",
+ "Number of waves": "波浪数量",
+ "Numerator": "分子",
+ "OK": "确定",
+ "Open...": "打开...",
+ "Options": "选项",
+ "outside": "外部",
+ "Parameters": "参数",
+ "Patterns": "图案",
+ "Period": "周期",
+ "perimeter": "边缘",
+ "Placement": "放置方式",
+ "Point": "点",
+ "Points": "顶点数",
+ "poisson disk sampling": "泊松圆盘采样",
+ "Polar": "极坐标",
+ "Polygon": "多边形",
+ "Preserve shape": "保持形状",
+ "Program code": "程序代码",
+ "Program end code": "程序结束代码",
+ "Program start code": "程序开始代码",
+ "quad": "四边形",
+ "Random seed": "随机种子",
+ "Randomize effect values": "随机化数值",
+ "Randomize layer values": "随机化数值",
+ "Randomize values": "随机化数值",
+ "rectangle": "矩形",
+ "Rectangular": "矩形",
+ "Relative size (parent to child circle)": "相对大小(父圆到子圆)",
+ "Resolution": "分辨率",
+ "Restore effect defaults": "恢复特效默认值",
+ "Restore layer defaults": "恢复默认值",
+ "Reuleaux": "鲁洛多边形",
+ "Reverse path in the code": "在代码中反转路径",
+ "right": "右对齐",
+ "Right": "右对齐",
+ "Rose": "玫瑰线",
+ "Rotate": "旋转",
+ "Rotate (degrees)": "旋转(度)",
+ "Rotate and twist": "旋转和扭转",
+ "Round corners": "圆角",
+ "Round fraction": "圆角程度",
+ "Sampling": "采样",
+ "Sans Serif": "无衬线体",
+ "Save": "保存",
+ "Save as...": "另存为...",
+ "Save pattern as...": "保存图案为...",
+ "Scale": "缩放",
+ "Scale (+/-)": "缩放 (+/-)",
+ "Scale by": "缩放方式",
+ "Scale function (i)": "缩放函数 (i)",
+ "Settings": "设置",
+ "Shape orientation": "形状方向",
+ "Show effect": "显示特效",
+ "Show layer": "显示图层",
+ "Shapes": "形状",
+ "shear": "剪切",
+ "Size of points": "顶点大小",
+ "Small circle radius": "小圆半径",
+ "smear": "拖影",
+ "Source file": "源文件",
+ "Space filler": "空间填充",
+ "Spacing": "间距",
+ "Spin": "旋转",
+ "Spin (+/-)": "旋转 (+/-)",
+ "Spin by": "旋转方式",
+ "Spin function (i)": "旋转函数 (i)",
+ "spiral": "螺旋",
+ "Spiral": "螺旋",
+ "Spiral tightness": "螺旋紧密度",
+ "Star": "星形",
+ "Start code": "开始代码",
+ "Start point": "起点",
+ "Stats": "统计",
+ "Stay in bounds": "保持在边界内",
+ "Step size": "步长",
+ "Subsample points": "子采样点",
+ "Success": "成功",
+ "Switchbacks": "往返",
+ "Tessellation twist": "镶嵌扭转",
+ "Text": "文本",
+ "Top": "顶部",
+ "Track": "轨迹",
+ "Track rotation": "轨迹旋转",
+ "Track type": "轨迹类型",
+ "Transform": "变换",
+ "Type": "类型",
+ "Units per circle": "每圈单位",
+ "upper left": "左上",
+ "upper right": "右上",
+ "Velocity": "速度",
+ "Voronoi": "沃罗诺伊图",
+ "W": "宽",
+ "Warp": "扭曲",
+ "Warp type": "扭曲类型",
+ "Wave frequency": "波浪频率",
+ "Web": "网形",
+ "weighted": "加权",
+ "Weight function": "权重函数",
+ "When transforming shape": "变换形状时",
+ "Width": "宽度",
+ "Wiper": "雨刮",
+ "Wiper angle": "雨刮角度",
+ "Wiper size": "雨刮大小",
+ "Wiper type": "雨刮类型",
+ "Zoom": "缩放"
+}
diff --git a/src/index.js b/src/index.js
index c982abf6..12f6a835 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,6 +2,7 @@
import React from "react"
import { createRoot } from "react-dom/client"
+import "./i18n"
import "bootstrap/dist/css/bootstrap.min.css"
import "react-toastify/dist/ReactToastify.css"
import "./features/app/bootstrap-overrides.scss"