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" && } + + } + > + + + ); +}; 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 = ``;