diff --git a/apps/builder/app/builder/features/style-panel/sections/sections.ts b/apps/builder/app/builder/features/style-panel/sections/sections.ts
index 04b1dfbc5edb..ebfd18480d7a 100644
--- a/apps/builder/app/builder/features/style-panel/sections/sections.ts
+++ b/apps/builder/app/builder/features/style-panel/sections/sections.ts
@@ -15,6 +15,7 @@ import * as outline from "./outline/outline";
import * as advanced from "./advanced/advanced";
import * as textShadows from "./text-shadows/text-shadows";
import * as backdropFilter from "./backdrop-filter/backdrop-filter";
+import * as transforms from "./transforms/transforms";
import type { StyleProperty } from "@webstudio-is/css-engine";
import type { SectionProps } from "./shared/section";
@@ -39,6 +40,7 @@ export const sections = new Map<
["filter", filter],
["backdropFilters", backdropFilter],
["transitions", transitions],
+ ["transfrom", transforms],
["outline", outline],
["advanced", advanced],
]);
diff --git a/apps/builder/app/builder/features/style-panel/sections/transforms/transform-rotate.tsx b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-rotate.tsx
new file mode 100644
index 000000000000..d62d94e01607
--- /dev/null
+++ b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-rotate.tsx
@@ -0,0 +1,135 @@
+import { Flex, Grid, Label } from "@webstudio-is/design-system";
+import {
+ updateRotateOrSkewPropertyValue,
+ type TransformPanelProps,
+} from "./transform-utils";
+import {
+ XAxisRotateIcon,
+ YAxisRotateIcon,
+ ZAxisRotateIcon,
+} from "@webstudio-is/icons";
+import { CssValueInputContainer } from "../../shared/css-value-input";
+import {
+ toValue,
+ UnitValue,
+ type FunctionValue,
+ type StyleValue,
+} from "@webstudio-is/css-engine";
+import type { StyleUpdateOptions } from "../../shared/use-style-data";
+import { parseCssValue } from "@webstudio-is/css-data";
+import { extractRotatePropertiesFromTransform } from "./transform-utils";
+
+export const RotatePanelContent = (props: TransformPanelProps) => {
+ const { propertyValue, setProperty, currentStyle } = props;
+ const { rotateX, rotateY, rotateZ } =
+ extractRotatePropertiesFromTransform(propertyValue);
+
+ const handlePropertyUpdate = (
+ index: number,
+ prop: string,
+ value: StyleValue,
+ options?: StyleUpdateOptions
+ ) => {
+ let newValue: UnitValue = { type: "unit", value: 0, unit: "deg" };
+
+ if (value.type === "unit") {
+ newValue = value;
+ }
+
+ if (value.type === "tuple" && value.value[0].type === "unit") {
+ newValue = value.value[0];
+ }
+
+ const newFunctionValue: FunctionValue = {
+ type: "function",
+ name: prop,
+ args: { type: "layers", value: [newValue] },
+ };
+
+ const newPropertyValue = updateRotateOrSkewPropertyValue({
+ panel: "rotate",
+ index,
+ currentStyle,
+ value: newFunctionValue,
+ propertyValue,
+ });
+
+ const rotate = parseCssValue("transform", toValue(newPropertyValue));
+ if (rotate.type === "invalid") {
+ return;
+ }
+
+ setProperty("transform")(rotate, options);
+ };
+
+ return (
+
+
+
+
+ {
+ handlePropertyUpdate(0, "rotateX", value, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+
+
+ {
+ handlePropertyUpdate(1, "rotateY", value, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+
+
+ {
+ handlePropertyUpdate(2, "rotateZ", value, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+ );
+};
diff --git a/apps/builder/app/builder/features/style-panel/sections/transforms/transform-scale.tsx b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-scale.tsx
new file mode 100644
index 000000000000..e85455f16f0c
--- /dev/null
+++ b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-scale.tsx
@@ -0,0 +1,109 @@
+import { Flex, Grid, Label } from "@webstudio-is/design-system";
+import {
+ StyleValue,
+ toValue,
+ type StyleProperty,
+} from "@webstudio-is/css-engine";
+import { CssValueInputContainer } from "../../shared/css-value-input";
+import type { StyleUpdateOptions } from "../../shared/use-style-data";
+import {
+ updateTransformTuplePropertyValue,
+ type TransformPanelProps,
+} from "./transform-utils";
+import { XAxisIcon, YAxisIcon, ZAxisIcon } from "@webstudio-is/icons";
+import { parseCssValue } from "@webstudio-is/css-data";
+
+// We use fakeProperty to pass for the CssValueInputContainer.
+// As we know during parsing, the syntax for scale is wrong in the css-data package.
+// https://github.com/mdn/data/pull/746
+// https://developer.mozilla.org/en-US/docs/Web/CSS/opacity#syntax
+// number | percentage
+const fakeProperty = "opacity";
+const property: StyleProperty = "scale";
+
+export const ScalePanelContent = (props: TransformPanelProps) => {
+ const { propertyValue, setProperty } = props;
+ const [scaleX, scaleY, scaleZ] = propertyValue.value;
+
+ const handlePropertyUpdate = (
+ index: number,
+ value: StyleValue,
+ options?: StyleUpdateOptions
+ ) => {
+ if (value.type !== "unit") {
+ return;
+ }
+
+ const newValue = updateTransformTuplePropertyValue(
+ index,
+ value,
+ propertyValue
+ );
+
+ const scale = parseCssValue(property, toValue(newValue));
+ if (scale.type === "invalid") {
+ return;
+ }
+
+ setProperty(property)(scale, options);
+ };
+
+ return (
+
+
+
+
+ {
+ handlePropertyUpdate(0, newValue, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+
+
+ {
+ handlePropertyUpdate(1, newValue, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+
+
+ {
+ handlePropertyUpdate(2, newValue, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+ );
+};
diff --git a/apps/builder/app/builder/features/style-panel/sections/transforms/transform-skew.tsx b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-skew.tsx
new file mode 100644
index 000000000000..12366ad14cc0
--- /dev/null
+++ b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-skew.tsx
@@ -0,0 +1,113 @@
+import { Flex, Label, Grid } from "@webstudio-is/design-system";
+import {
+ updateRotateOrSkewPropertyValue,
+ extractSkewPropertiesFromTransform,
+ type TransformPanelProps,
+} from "./transform-utils";
+import { XAxisIcon, YAxisIcon } from "@webstudio-is/icons";
+import { CssValueInputContainer } from "../../shared/css-value-input";
+import type { StyleUpdateOptions } from "../../shared/use-style-data";
+import {
+ StyleValue,
+ toValue,
+ UnitValue,
+ type FunctionValue,
+} from "@webstudio-is/css-engine";
+import { parseCssValue } from "@webstudio-is/css-data";
+
+// We use fakeProperty to pass for the CssValueInputContainer.
+// https://developer.mozilla.org/en-US/docs/Web/CSS/rotate#formal_syntax
+// angle
+const fakeProperty = "rotate";
+
+export const SkewPanelContent = (props: TransformPanelProps) => {
+ const { propertyValue, setProperty, currentStyle } = props;
+ const { skewX, skewY } = extractSkewPropertiesFromTransform(propertyValue);
+
+ const handlePropertyUpdate = (
+ index: number,
+ prop: string,
+ value: StyleValue,
+ options?: StyleUpdateOptions
+ ) => {
+ let newValue: UnitValue = { type: "unit", value: 0, unit: "deg" };
+
+ if (value.type === "unit") {
+ newValue = value;
+ }
+
+ if (value.type === "tuple" && value.value[0].type === "unit") {
+ newValue = value.value[0];
+ }
+
+ const newFunctionValue: FunctionValue = {
+ type: "function",
+ name: prop,
+ args: { type: "layers", value: [newValue] },
+ };
+
+ const newPropertyValue = updateRotateOrSkewPropertyValue({
+ panel: "skew",
+ index,
+ currentStyle,
+ value: newFunctionValue,
+ propertyValue,
+ });
+
+ const skew = parseCssValue("transform", toValue(newPropertyValue));
+ if (skew.type === "invalid") {
+ return;
+ }
+
+ setProperty("transform")(skew, options);
+ };
+
+ return (
+
+
+
+
+ {
+ handlePropertyUpdate(0, "skewX", value, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+
+
+ {
+ handlePropertyUpdate(1, "skewY", value, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+ );
+};
diff --git a/apps/builder/app/builder/features/style-panel/sections/transforms/transform-translate.tsx b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-translate.tsx
new file mode 100644
index 000000000000..6e0f9c50b7b8
--- /dev/null
+++ b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-translate.tsx
@@ -0,0 +1,115 @@
+import { Flex, Grid, Label } from "@webstudio-is/design-system";
+import {
+ StyleValue,
+ toValue,
+ UnitValue,
+ type StyleProperty,
+} from "@webstudio-is/css-engine";
+import { CssValueInputContainer } from "../../shared/css-value-input";
+import {
+ updateTransformTuplePropertyValue,
+ type TransformPanelProps,
+} from "./transform-utils";
+import type { StyleUpdateOptions } from "../../shared/use-style-data";
+import { XAxisIcon, YAxisIcon, ZAxisIcon } from "@webstudio-is/icons";
+import { parseCssValue } from "@webstudio-is/css-data";
+
+const property: StyleProperty = "translate";
+
+export const TranslatePanelContent = (props: TransformPanelProps) => {
+ const { propertyValue, setProperty } = props;
+ const [translateX, translateY, translateZ] = propertyValue.value;
+
+ const handlePropertyUpdate = (
+ index: number,
+ newValue: StyleValue,
+ options?: StyleUpdateOptions
+ ) => {
+ if (newValue === undefined) {
+ return;
+ }
+
+ // For individual translate properties, we are passing the property as translate.
+ // This is sending back either tuple or a unit value when manually edited and when scrub is used respectively.
+ let value: UnitValue = { type: "unit", value: 0, unit: "px" };
+ if (newValue.type === "unit") {
+ value = newValue;
+ }
+
+ if (newValue.type === "tuple" && newValue.value[0].type === "unit") {
+ value = newValue.value[0];
+ }
+
+ const translateValue = updateTransformTuplePropertyValue(
+ index,
+ value,
+ propertyValue
+ );
+
+ const translate = parseCssValue(property, toValue(translateValue));
+ if (translate.type === "invalid") {
+ return;
+ }
+
+ setProperty(property)(translate, options);
+ };
+
+ return (
+
+
+
+
+ {
+ handlePropertyUpdate(0, newValue, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+
+
+ {
+ handlePropertyUpdate(1, newValue, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+
+
+ {
+ handlePropertyUpdate(2, newValue, options);
+ }}
+ deleteProperty={() => {}}
+ />
+
+
+ );
+};
diff --git a/apps/builder/app/builder/features/style-panel/sections/transforms/transform-utils.test.ts b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-utils.test.ts
new file mode 100644
index 000000000000..d7dd8ece6467
--- /dev/null
+++ b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-utils.test.ts
@@ -0,0 +1,367 @@
+import { describe, test, expect } from "@jest/globals";
+import {
+ addDefaultsForTransormSection,
+ handleDeleteTransformProperty,
+ handleHideTransformProperty,
+ isTransformPanelPropertyUsed,
+ updateRotateOrSkewPropertyValue,
+ updateTransformTuplePropertyValue,
+ extractRotatePropertiesFromTransform,
+ extractSkewPropertiesFromTransform,
+} from "./transform-utils";
+import type { StyleInfo } from "../../shared/style-info";
+import {
+ FunctionValue,
+ toValue,
+ TupleValue,
+ type StyleProperty,
+ type StyleValue,
+} from "@webstudio-is/css-engine";
+import { parseCssValue } from "@webstudio-is/css-data";
+
+const initializeStyleInfo = () => {
+ const currentStyle: StyleInfo = {};
+ const setProperty = (property: StyleProperty) => (value: StyleValue) => {
+ currentStyle[property] = { value };
+ };
+ const deleteProperty = (property: StyleProperty) => {
+ delete currentStyle[property];
+ };
+
+ return { currentStyle, setProperty, deleteProperty };
+};
+
+describe("Transform utils CRUD operations", () => {
+ test("adds a default translate property", () => {
+ const { currentStyle, setProperty } = initializeStyleInfo();
+
+ addDefaultsForTransormSection({
+ panel: "translate",
+ currentStyle,
+ setProperty,
+ });
+ const translate = currentStyle["translate"]?.value;
+ expect(translate).toEqual({
+ type: "tuple",
+ value: [
+ {
+ type: "unit",
+ unit: "px",
+ value: 0,
+ },
+ {
+ type: "unit",
+ unit: "px",
+ value: 0,
+ },
+ {
+ type: "unit",
+ unit: "px",
+ value: 0,
+ },
+ ],
+ });
+ });
+
+ test("adds a default scale property", () => {
+ const { currentStyle, setProperty } = initializeStyleInfo();
+
+ addDefaultsForTransormSection({
+ panel: "scale",
+ currentStyle,
+ setProperty,
+ });
+
+ const scale = currentStyle["scale"]?.value;
+ expect(scale).toEqual({
+ type: "tuple",
+ value: [
+ {
+ type: "unit",
+ unit: "number",
+ value: 1,
+ },
+ {
+ type: "unit",
+ unit: "number",
+ value: 1,
+ },
+ {
+ type: "unit",
+ unit: "number",
+ value: 1,
+ },
+ ],
+ });
+ });
+
+ test("adds a default rotate and scale property", () => {
+ const { currentStyle, setProperty } = initializeStyleInfo();
+
+ addDefaultsForTransormSection({
+ panel: "rotate",
+ currentStyle,
+ setProperty,
+ });
+
+ expect(toValue(currentStyle["transform"]?.value)).toBe(
+ "rotateX(0deg) rotateY(0deg) rotateZ(0deg)"
+ );
+
+ addDefaultsForTransormSection({
+ panel: "skew",
+ currentStyle,
+ setProperty,
+ });
+ expect(toValue(currentStyle["transform"]?.value)).toBe(
+ "skewX(0deg) skewY(0deg) rotateX(0deg) rotateY(0deg) rotateZ(0deg)"
+ );
+ });
+
+ test("checks if any of the transform property exist", () => {
+ const { currentStyle, setProperty } = initializeStyleInfo();
+
+ expect(
+ isTransformPanelPropertyUsed({
+ currentStyle,
+ panel: "translate",
+ })
+ ).toBe(false);
+
+ addDefaultsForTransormSection({
+ panel: "rotate",
+ currentStyle,
+ setProperty,
+ });
+
+ expect(
+ isTransformPanelPropertyUsed({ currentStyle, panel: "rotate" })
+ ).toBe(true);
+ });
+
+ test("deletes rotate property values from the transform", () => {
+ const { currentStyle, setProperty, deleteProperty } = initializeStyleInfo();
+ setProperty("transform")(
+ parseCssValue(
+ "transform",
+ "rotateX(50deg) rotateY(50deg) rotateZ(50deg) scale(1, 1) translate(10px, 10px)"
+ )
+ );
+
+ handleDeleteTransformProperty({
+ panel: "rotate",
+ currentStyle,
+ setProperty,
+ deleteProperty,
+ });
+
+ expect(toValue(currentStyle["transform"]?.value)).toBe(
+ "scale(1, 1) translate(10px, 10px)"
+ );
+ });
+
+ test("delete skew property values from the transform", () => {
+ const { currentStyle, setProperty, deleteProperty } = initializeStyleInfo();
+ setProperty("transform")(
+ parseCssValue(
+ "transform",
+ "rotateX(50deg) rotateY(50deg) rotateZ(50deg) skew(10deg) skewX(10deg) skewY(10deg)"
+ )
+ );
+ handleDeleteTransformProperty({
+ panel: "skew",
+ currentStyle,
+ setProperty,
+ deleteProperty,
+ });
+ expect(toValue(currentStyle["transform"]?.value)).toBe(
+ "rotateX(50deg) rotateY(50deg) rotateZ(50deg) skew(10deg)"
+ );
+ });
+
+ test("hide translate and rotate properties", () => {
+ const { currentStyle, setProperty } = initializeStyleInfo();
+ addDefaultsForTransormSection({
+ panel: "translate",
+ currentStyle,
+ setProperty,
+ });
+ addDefaultsForTransormSection({
+ panel: "rotate",
+ currentStyle,
+ setProperty,
+ });
+
+ handleHideTransformProperty({
+ panel: "translate",
+ currentStyle,
+ setProperty,
+ });
+ expect(currentStyle["translate"]?.value?.hidden).toBe(true);
+
+ handleHideTransformProperty({
+ panel: "rotate",
+ currentStyle,
+ setProperty,
+ });
+ const rotate = extractRotatePropertiesFromTransform(
+ currentStyle["transform"]?.value as TupleValue
+ );
+
+ expect(rotate.rotateX?.hidden).toBe(true);
+ expect(rotate.rotateY?.hidden).toBe(true);
+ expect(rotate.rotateZ?.hidden).toBe(true);
+ });
+
+ test("update translate property value", () => {
+ const translate = parseCssValue("translate", "0px 0px 0px");
+ const newValue = updateTransformTuplePropertyValue(
+ 1,
+ { type: "unit", value: 50, unit: "px" },
+ translate as TupleValue
+ );
+
+ expect(toValue(newValue)).toBe("0px 50px 0px");
+ });
+
+ test("update rotate values in transform property", () => {
+ const { currentStyle, setProperty } = initializeStyleInfo();
+ const transform = parseCssValue(
+ "transform",
+ "rotateX(10deg) rotateY(10deg) rotateZ(10deg) skewX(10deg) skewY(10deg)"
+ );
+ setProperty("transform")(transform);
+
+ const updatedRotateYValue: FunctionValue = {
+ type: "function",
+ name: "rotateY",
+ args: {
+ type: "layers",
+ value: [{ type: "unit", value: 50, unit: "deg" }],
+ },
+ };
+ const { rotateX, rotateY, rotateZ } =
+ extractRotatePropertiesFromTransform(transform);
+ const newValue = updateRotateOrSkewPropertyValue({
+ index: 1,
+ panel: "rotate",
+ currentStyle,
+ value: updatedRotateYValue,
+ propertyValue: {
+ type: "tuple",
+ value: [
+ rotateX as FunctionValue,
+ rotateY as FunctionValue,
+ rotateZ as FunctionValue,
+ ],
+ },
+ });
+
+ const result = extractRotatePropertiesFromTransform(newValue);
+ expect(result.rotateY).toEqual(updatedRotateYValue);
+ expect(toValue(newValue)).toBe(
+ "rotateX(10deg) rotateY(50deg) rotateZ(10deg) skewX(10deg) skewY(10deg)"
+ );
+ });
+});
+
+describe("extractRotatePropertiesFromTransform", () => {
+ test("parses transform and returns undefined if no rotate values exists", () => {
+ expect(
+ extractRotatePropertiesFromTransform(
+ parseCssValue("transform", "scale(1.5)")
+ )
+ ).toEqual({
+ rotateX: undefined,
+ rotateY: undefined,
+ rotateZ: undefined,
+ });
+ });
+
+ test("parses transform and returns rotate values", () => {
+ expect(
+ extractRotatePropertiesFromTransform(
+ parseCssValue("transform", "rotateX(0deg) rotateY(10deg) scale(1.5)")
+ )
+ ).toEqual({
+ rotateX: {
+ type: "function",
+ args: {
+ type: "layers",
+ value: [
+ {
+ type: "unit",
+ unit: "deg",
+ value: 0,
+ },
+ ],
+ },
+ name: "rotateX",
+ },
+ rotateY: {
+ type: "function",
+ args: {
+ type: "layers",
+ value: [
+ {
+ type: "unit",
+ unit: "deg",
+ value: 10,
+ },
+ ],
+ },
+ name: "rotateY",
+ },
+ });
+ });
+});
+
+describe("extractSkewPropertiesFromTransform", () => {
+ test("parses transform and returns undefined if no skew properties exists", () => {
+ expect(
+ extractSkewPropertiesFromTransform(
+ parseCssValue("transform", "rotateX(0deg) rotateY(0deg) scale(1.5)")
+ )
+ ).toEqual({ skewX: undefined, skewY: undefined });
+ });
+
+ test("parses transform and extracts valid skew properties", () => {
+ expect(
+ extractSkewPropertiesFromTransform(
+ parseCssValue(
+ "transform",
+ "skewX(10deg) skewY(20deg) rotate(30deg) scale(1.5)"
+ )
+ )
+ ).toEqual({
+ skewX: {
+ type: "function",
+ args: {
+ type: "layers",
+ value: [
+ {
+ type: "unit",
+ unit: "deg",
+ value: 10,
+ },
+ ],
+ },
+ name: "skewX",
+ },
+ skewY: {
+ type: "function",
+ args: {
+ type: "layers",
+ value: [
+ {
+ type: "unit",
+ unit: "deg",
+ value: 20,
+ },
+ ],
+ },
+ name: "skewY",
+ },
+ });
+ });
+});
diff --git a/apps/builder/app/builder/features/style-panel/sections/transforms/transform-utils.ts b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-utils.ts
new file mode 100644
index 000000000000..77475d267b21
--- /dev/null
+++ b/apps/builder/app/builder/features/style-panel/sections/transforms/transform-utils.ts
@@ -0,0 +1,390 @@
+import { parseCssValue } from "@webstudio-is/css-data";
+import {
+ FunctionValue,
+ StyleValue,
+ toValue,
+ type TupleValue,
+ type TupleValueItem,
+} from "@webstudio-is/css-engine";
+import type { DeleteProperty, SetProperty } from "../../shared/use-style-data";
+import type { StyleInfo } from "../../shared/style-info";
+import type { TransformPanel } from "./transforms";
+
+export type TransformPanelProps = {
+ currentStyle: StyleInfo;
+ propertyValue: TupleValue;
+ setProperty: SetProperty;
+};
+
+const defaultTranslate = "0px 0px 0px";
+const defaultScale = "1 1 1";
+const defaultRotate = "rotateX(0deg) rotateY(0deg) rotateZ(0deg)";
+const defaultSkew = "skewX(0deg) skewY(0deg)";
+
+export const getHumanizedTextFromTransformLayer = (
+ panel: TransformPanel,
+ value: TupleValue
+): { label: string; value: TupleValue } | undefined => {
+ switch (panel) {
+ case "translate":
+ return {
+ label: `Translate: ${toValue({ ...value, hidden: false })}`,
+ value,
+ };
+
+ case "scale":
+ return {
+ label: `Scale: ${toValue({ ...value, hidden: false })}`,
+ value,
+ };
+
+ case "rotate": {
+ const rotate = extractRotatePropertiesFromTransform(value);
+ const { rotateX, rotateY, rotateZ } = rotate;
+ if (
+ rotateX === undefined ||
+ rotateY === undefined ||
+ rotateZ === undefined
+ ) {
+ return;
+ }
+
+ return {
+ label: `Rotate: ${toValue(rotateX.args)} ${toValue(rotateY.args)} ${toValue(rotateZ.args)}`,
+ value: {
+ type: "tuple",
+ value: [rotateX, rotateY, rotateZ],
+ hidden: rotateX.hidden || rotateY.hidden || rotateZ.hidden,
+ },
+ };
+ }
+
+ case "skew": {
+ const skew = extractSkewPropertiesFromTransform(value);
+ const { skewX, skewY } = skew;
+
+ if (skewX === undefined || skewY === undefined) {
+ return;
+ }
+
+ return {
+ label: `Skew: ${toValue(skewX.args)} ${toValue(skewY.args)}`,
+ value: {
+ type: "tuple",
+ value: [skewX, skewY],
+ hidden: skewX.hidden || skewY.hidden,
+ },
+ };
+ }
+ }
+};
+
+export const addDefaultsForTransormSection = (props: {
+ panel: TransformPanel;
+ currentStyle: StyleInfo;
+ setProperty: SetProperty;
+}) => {
+ const { setProperty, panel, currentStyle } = props;
+
+ switch (panel) {
+ case "translate": {
+ const translate = parseCssValue("translate", defaultTranslate);
+ return setProperty("translate")(translate);
+ }
+
+ case "scale": {
+ const scale = parseCssValue("scale", defaultScale);
+ return setProperty("scale")(scale);
+ }
+
+ case "skew":
+ case "rotate": {
+ const value = currentStyle["transform"]?.value;
+ const parsedValue = parseCssValue(
+ "transform",
+ panel === "rotate" ? defaultRotate : defaultSkew
+ );
+
+ // rotate and skew are maintained using tuple
+ // If the existing value is anything other than tuple.
+ // We need to update the property to use tuples
+ if (value?.type !== "tuple") {
+ return setProperty("transform")(parsedValue);
+ }
+
+ if (parsedValue.type === "tuple" && value.type === "tuple") {
+ const filteredValues = removeRotateOrSkewValues(panel, value);
+
+ return setProperty("transform")({
+ ...value,
+ value: [...parsedValue.value, ...filteredValues],
+ });
+ }
+ }
+ }
+};
+
+export const isTransformPanelPropertyUsed = (params: {
+ currentStyle: StyleInfo;
+ panel: TransformPanel;
+}): boolean => {
+ const { currentStyle, panel } = params;
+ switch (panel) {
+ case "scale":
+ case "translate":
+ return currentStyle[panel]?.value.type === "tuple";
+
+ case "rotate": {
+ const rotate = currentStyle["transform"]?.value;
+ return (
+ rotate?.type === "tuple" &&
+ extractRotatePropertiesFromTransform(rotate).rotateX !== undefined
+ );
+ }
+
+ case "skew": {
+ const skew = currentStyle["transform"]?.value;
+ return (
+ skew?.type === "tuple" &&
+ extractSkewPropertiesFromTransform(skew).skewX !== undefined
+ );
+ }
+
+ default:
+ return false;
+ }
+};
+
+export const removeRotateOrSkewValues = (
+ panel: TransformPanel,
+ value: TupleValue
+) => {
+ const propKeys =
+ panel === "rotate" ? ["rotateX", "rotateY", "rotateZ"] : ["skewX", "skewY"];
+ return value.value.filter(
+ (item) => item.type === "function" && propKeys.includes(item.name) === false
+ );
+};
+
+export const handleDeleteTransformProperty = (params: {
+ currentStyle: StyleInfo;
+ setProperty: SetProperty;
+ deleteProperty: DeleteProperty;
+ panel: TransformPanel;
+}) => {
+ const { deleteProperty, panel, currentStyle, setProperty } = params;
+ switch (panel) {
+ case "scale":
+ case "translate":
+ deleteProperty(panel);
+ break;
+
+ case "rotate": {
+ const value = currentStyle["transform"]?.value;
+ if (value?.type !== "tuple") {
+ return;
+ }
+ const filteredValues = removeRotateOrSkewValues("rotate", value);
+ if (filteredValues.length === 0) {
+ deleteProperty("transform");
+ return;
+ }
+ setProperty("transform")({
+ ...value,
+ value: filteredValues,
+ });
+ break;
+ }
+
+ case "skew": {
+ const value = currentStyle["transform"]?.value;
+ if (value?.type !== "tuple") {
+ return;
+ }
+ const filteredValues = removeRotateOrSkewValues("skew", value);
+ if (filteredValues.length === 0) {
+ deleteProperty("transform");
+ return;
+ }
+ setProperty("transform")({
+ ...value,
+ value: filteredValues,
+ });
+ }
+ }
+};
+
+export const handleHideTransformProperty = (params: {
+ setProperty: SetProperty;
+ currentStyle: StyleInfo;
+ panel: TransformPanel;
+}) => {
+ const { panel, setProperty, currentStyle } = params;
+ switch (panel) {
+ case "scale":
+ case "translate": {
+ const value = currentStyle[panel]?.value;
+ if (value?.type !== "tuple") {
+ return;
+ }
+ setProperty(panel)({
+ ...value,
+ hidden: value.hidden ? false : true,
+ });
+ break;
+ }
+
+ case "rotate": {
+ const value = currentStyle["transform"]?.value;
+ if (value?.type !== "tuple") {
+ return;
+ }
+ const newValue: TupleValue = {
+ ...value,
+ value: [...removeRotateOrSkewValues("rotate", value)],
+ };
+ const rotate = extractRotatePropertiesFromTransform(value);
+ const { rotateX, rotateY, rotateZ } = rotate;
+
+ if (rotateX) {
+ newValue.value.unshift({
+ ...rotateX,
+ hidden: rotateX.hidden ? false : true,
+ });
+ }
+
+ if (rotateY) {
+ newValue.value.unshift({
+ ...rotateY,
+ hidden: rotateY.hidden ? false : true,
+ });
+ }
+
+ if (rotateZ) {
+ newValue.value.unshift({
+ ...rotateZ,
+ hidden: rotateZ.hidden ? false : true,
+ });
+ }
+
+ setProperty("transform")(newValue);
+ break;
+ }
+
+ case "skew": {
+ const value = currentStyle["transform"]?.value;
+ if (value?.type !== "tuple") {
+ return;
+ }
+ const newValue: TupleValue = {
+ ...value,
+ value: [...removeRotateOrSkewValues("skew", value)],
+ };
+ const skew = extractSkewPropertiesFromTransform(value);
+ const { skewX, skewY } = skew;
+
+ if (skewX) {
+ newValue.value.push({
+ ...skewX,
+ hidden: skewX.hidden ? false : true,
+ });
+ }
+
+ if (skewY) {
+ newValue.value.push({
+ ...skewY,
+ hidden: skewY.hidden ? false : true,
+ });
+ }
+
+ setProperty("transform")(newValue);
+ break;
+ }
+ }
+};
+
+export const updateTransformTuplePropertyValue = (
+ index: number,
+ newValue: TupleValueItem,
+ value: TupleValue
+): TupleValue => {
+ const newArray: TupleValueItem[] = [...value.value];
+ newArray.splice(index, 1, newValue);
+ return {
+ ...value,
+ value: newArray,
+ };
+};
+
+export const updateRotateOrSkewPropertyValue = (props: {
+ index: number;
+ panel: "rotate" | "skew";
+ currentStyle: StyleInfo;
+ value: FunctionValue;
+ propertyValue: TupleValue;
+}): TupleValue => {
+ const { index, value, propertyValue, panel } = props;
+ const newPropertyValue = updateTransformTuplePropertyValue(
+ index,
+ value,
+ propertyValue
+ );
+
+ const existingTransforms = props.currentStyle["transform"]?.value;
+ if (existingTransforms?.type === "tuple") {
+ const filteredValues = removeRotateOrSkewValues(panel, existingTransforms);
+ return {
+ ...existingTransforms,
+ value: [...newPropertyValue.value, ...filteredValues],
+ };
+ }
+
+ return newPropertyValue;
+};
+
+export const extractRotatePropertiesFromTransform = (transform: StyleValue) => {
+ let rotateX: FunctionValue | undefined;
+ let rotateY: FunctionValue | undefined;
+ let rotateZ: FunctionValue | undefined;
+
+ if (transform.type !== "tuple") {
+ return { rotateX, rotateY, rotateZ };
+ }
+
+ for (const item of transform.value) {
+ if (item.type === "function" && item.name === "rotateX") {
+ rotateX = item;
+ }
+
+ if (item.type === "function" && item.name === "rotateY") {
+ rotateY = item;
+ }
+
+ if (item.type === "function" && item.name === "rotateZ") {
+ rotateZ = item;
+ }
+ }
+
+ return { rotateX, rotateY, rotateZ };
+};
+
+export const extractSkewPropertiesFromTransform = (skew: StyleValue) => {
+ let skewX: FunctionValue | undefined = undefined;
+ let skewY: FunctionValue | undefined = undefined;
+
+ if (skew.type !== "tuple") {
+ return { skewX, skewY };
+ }
+
+ for (const item of skew.value) {
+ if (item.type === "function" && item.name === "skewX") {
+ skewX = item;
+ }
+
+ if (item.type === "function" && item.name === "skewY") {
+ skewY = item;
+ }
+ }
+
+ return { skewX, skewY };
+};
diff --git a/apps/builder/app/builder/features/style-panel/sections/transforms/transforms.tsx b/apps/builder/app/builder/features/style-panel/sections/transforms/transforms.tsx
new file mode 100644
index 000000000000..8aaec74838fe
--- /dev/null
+++ b/apps/builder/app/builder/features/style-panel/sections/transforms/transforms.tsx
@@ -0,0 +1,251 @@
+import { CollapsibleSectionRoot } from "~/builder/shared/collapsible-section";
+import type { SectionProps } from "../shared/section";
+import type { StyleProperty } from "@webstudio-is/css-engine";
+import { useMemo, useState } from "react";
+import {
+ CssValueListArrowFocus,
+ CssValueListItem,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ Flex,
+ Label,
+ SectionTitle,
+ SectionTitleButton,
+ SectionTitleLabel,
+ SmallIconButton,
+ SmallToggleButton,
+ theme,
+} from "@webstudio-is/design-system";
+import {
+ EyeconClosedIcon,
+ PlusIcon,
+ SubtractIcon,
+ EyeconOpenIcon,
+} from "@webstudio-is/icons";
+import {
+ addDefaultsForTransormSection,
+ isTransformPanelPropertyUsed,
+ handleDeleteTransformProperty,
+ handleHideTransformProperty,
+ getHumanizedTextFromTransformLayer,
+ type TransformPanelProps,
+} from "./transform-utils";
+import { FloatingPanel } from "~/builder/shared/floating-panel";
+import { TranslatePanelContent } from "./transform-translate";
+import { ScalePanelContent } from "./transform-scale";
+import { RotatePanelContent } from "./transform-rotate";
+import { SkewPanelContent } from "./transform-skew";
+import { humanizeString } from "~/shared/string-utils";
+import { getStyleSource } from "../../shared/style-info";
+import { PropertyName } from "../../shared/property-name";
+import { getDots } from "../../shared/collapsible-section";
+
+export const transformPanels = [
+ "translate",
+ "scale",
+ "rotate",
+ "skew",
+] as const;
+
+export type TransformPanel = (typeof transformPanels)[number];
+
+const label = "Transforms";
+export const properties = [
+ "translate",
+ "scale",
+ "transform",
+] satisfies Array;
+
+export const Section = (props: SectionProps) => {
+ const { currentStyle, createBatchUpdate } = props;
+ const [isOpen, setIsOpen] = useState(true);
+ const translateStyleSource = getStyleSource(currentStyle["translate"]);
+ const scaleStyleSource = getStyleSource(currentStyle["scale"]);
+ const rotateAndSkewStyleSrouce = getStyleSource(currentStyle["transform"]);
+
+ const isAnyTransformPropertyAdded = transformPanels.some((panel) =>
+ isTransformPanelPropertyUsed({
+ currentStyle: props.currentStyle,
+ panel,
+ })
+ );
+
+ const handleResetForAllTransformProperties = () => {
+ const batch = createBatchUpdate();
+ batch.deleteProperty("translate");
+ batch.deleteProperty("scale");
+ batch.deleteProperty("transform");
+ batch.publish();
+ };
+
+ return (
+
+
+ }>
+
+
+
+ {transformPanels.map((panel) => {
+ return (
+ {
+ addDefaultsForTransormSection({
+ currentStyle: props.currentStyle,
+ setProperty: props.setProperty,
+ panel,
+ });
+ setIsOpen(true);
+ }}
+ >
+ {humanizeString(panel)}
+
+ );
+ })}
+
+
+
+ }
+ >
+
+ {label}
+
+ }
+ onReset={handleResetForAllTransformProperties}
+ />
+
+ }
+ >
+ {isAnyTransformPropertyAdded === true ? (
+
+ {transformPanels.map((panel, index) => (
+
+ ))}
+
+ ) : undefined}
+
+ );
+};
+
+const TransformSection = (
+ props: SectionProps & { index: number; panel: TransformPanel }
+) => {
+ const { currentStyle, setProperty, deleteProperty, panel, index } = props;
+ const properties = useMemo(() => {
+ const property =
+ panel === "rotate" || panel === "skew" ? "transform" : panel;
+ const value = currentStyle[property]?.value;
+ if (value === undefined || value.type !== "tuple") {
+ return;
+ }
+
+ return getHumanizedTextFromTransformLayer(panel, value);
+ }, [currentStyle, panel]);
+
+ if (properties === undefined) {
+ return;
+ }
+
+ const contentPanelProps: TransformPanelProps = {
+ currentStyle,
+ setProperty,
+ propertyValue: properties.value,
+ };
+
+ return (
+
+ {panel === "translate" && (
+
+ )}
+ {panel === "scale" && }
+ {panel === "rotate" && }
+ {panel === "skew" && }
+
+ }
+ >
+ {properties.label}}
+ buttons={
+ <>
+
+ handleHideTransformProperty({
+ currentStyle,
+ setProperty,
+ panel,
+ })
+ }
+ icon={
+ properties.value.hidden ? (
+
+ ) : (
+
+ )
+ }
+ />
+ }
+ onClick={() =>
+ handleDeleteTransformProperty({
+ currentStyle,
+ setProperty,
+ deleteProperty,
+ panel,
+ })
+ }
+ />
+ >
+ }
+ >
+
+ );
+};
diff --git a/packages/css-engine/src/core/to-value.ts b/packages/css-engine/src/core/to-value.ts
index fdb66bde3079..684f581573c8 100644
--- a/packages/css-engine/src/core/to-value.ts
+++ b/packages/css-engine/src/core/to-value.ts
@@ -103,6 +103,12 @@ export const toValue = (
}
if (value.type === "tuple") {
+ // Properties ike translate and scale are handled as tuples directly.
+ // When the layer is hidden, the value goes as none.
+ if (value.hidden === true) {
+ return "none";
+ }
+
return value.value
.filter((value) => value.hidden !== true)
.map((value) => toValue(value, transformValue))
diff --git a/packages/icons/icons/x-axis-rotate.svg b/packages/icons/icons/x-axis-rotate.svg
new file mode 100644
index 000000000000..6c29a8a09d71
--- /dev/null
+++ b/packages/icons/icons/x-axis-rotate.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/icons/icons/x-axis.svg b/packages/icons/icons/x-axis.svg
new file mode 100644
index 000000000000..43a4b65af1ce
--- /dev/null
+++ b/packages/icons/icons/x-axis.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/icons/icons/y-axis-rotate.svg b/packages/icons/icons/y-axis-rotate.svg
new file mode 100644
index 000000000000..5b2181587883
--- /dev/null
+++ b/packages/icons/icons/y-axis-rotate.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/icons/icons/y-axis.svg b/packages/icons/icons/y-axis.svg
new file mode 100644
index 000000000000..47420faf23f0
--- /dev/null
+++ b/packages/icons/icons/y-axis.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/icons/icons/z-axis-rotate.svg b/packages/icons/icons/z-axis-rotate.svg
new file mode 100644
index 000000000000..f054b4fcdeba
--- /dev/null
+++ b/packages/icons/icons/z-axis-rotate.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/icons/icons/z-axis.svg b/packages/icons/icons/z-axis.svg
new file mode 100644
index 000000000000..267363e16bd9
--- /dev/null
+++ b/packages/icons/icons/z-axis.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/icons/src/__generated__/components.tsx b/packages/icons/src/__generated__/components.tsx
index da9f5309ad93..718dc6ee4c0b 100644
--- a/packages/icons/src/__generated__/components.tsx
+++ b/packages/icons/src/__generated__/components.tsx
@@ -4880,6 +4880,50 @@ export const WrapIcon = forwardRef(
);
WrapIcon.displayName = "WrapIcon";
+export const XAxisRotateIcon = forwardRef(
+ ({ color = "currentColor", size = 16, ...props }, forwardedRef) => {
+ return (
+
+ );
+ }
+);
+XAxisRotateIcon.displayName = "XAxisRotateIcon";
+
+export const XAxisIcon = forwardRef(
+ ({ color = "currentColor", size = 16, ...props }, forwardedRef) => {
+ return (
+
+ );
+ }
+);
+XAxisIcon.displayName = "XAxisIcon";
+
export const XIcon = forwardRef(
({ color = "currentColor", size = 16, ...props }, forwardedRef) => {
return (
@@ -4924,6 +4968,50 @@ export const XmlIcon = forwardRef(
);
XmlIcon.displayName = "XmlIcon";
+export const YAxisRotateIcon = forwardRef(
+ ({ color = "currentColor", size = 16, ...props }, forwardedRef) => {
+ return (
+
+ );
+ }
+);
+YAxisRotateIcon.displayName = "YAxisRotateIcon";
+
+export const YAxisIcon = forwardRef(
+ ({ color = "currentColor", size = 16, ...props }, forwardedRef) => {
+ return (
+
+ );
+ }
+);
+YAxisIcon.displayName = "YAxisIcon";
+
export const Youtube1cIcon = forwardRef(
({ color = "currentColor", size = 16, ...props }, forwardedRef) => {
return (
@@ -4946,3 +5034,47 @@ export const Youtube1cIcon = forwardRef(
}
);
Youtube1cIcon.displayName = "Youtube1cIcon";
+
+export const ZAxisRotateIcon = forwardRef(
+ ({ color = "currentColor", size = 16, ...props }, forwardedRef) => {
+ return (
+
+ );
+ }
+);
+ZAxisRotateIcon.displayName = "ZAxisRotateIcon";
+
+export const ZAxisIcon = forwardRef(
+ ({ color = "currentColor", size = 16, ...props }, forwardedRef) => {
+ return (
+
+ );
+ }
+);
+ZAxisIcon.displayName = "ZAxisIcon";
diff --git a/packages/icons/src/__generated__/svg.ts b/packages/icons/src/__generated__/svg.ts
index e4379f7c750b..8be103b378ed 100644
--- a/packages/icons/src/__generated__/svg.ts
+++ b/packages/icons/src/__generated__/svg.ts
@@ -372,8 +372,20 @@ export const WebstudioIcon = ``;
+export const XAxisRotateIcon = ``;
+
+export const XAxisIcon = ``;
+
export const XIcon = ``;
export const XmlIcon = ``;
+export const YAxisRotateIcon = ``;
+
+export const YAxisIcon = ``;
+
export const Youtube1cIcon = ``;
+
+export const ZAxisRotateIcon = ``;
+
+export const ZAxisIcon = ``;