From ce2dad417dbe7d8d3c1901bb08d192d71a42529d Mon Sep 17 00:00:00 2001 From: Jaya Krishna Namburu Date: Sun, 23 Jun 2024 16:53:56 +0530 Subject: [PATCH] refactor: use longhand properties to save transition values in style-panel (#3584) ## Description fixes #3567 Use long-hand properties to save `transition` values in the style-panel. ## Steps for reproduction - Add some layers in transition panel in UI. - Delete some layers - Swap some layers ## TODO - [ ] Allow `show/hide` for each layer. - [ ] Add tests for all the layer handlers for transition. ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview @TrySound i made some refactor to use individual properties as long-hands instead of short-hand property. I am still yet to add tests for the new handlers, since they need to be handled in a different way now for transitions. But you can still play around with the current setup. ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 5de6) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests --- .../transitions/transition-content.tsx | 46 ++- .../transitions/transition-property.tsx | 13 +- .../transitions/transition-utils.test.ts | 256 ++++++++++++++ .../sections/transitions/transition-utils.ts | 324 ++++++++++++++++++ .../sections/transitions/transitions.tsx | 118 +++++-- .../style-panel/style-layers-list.tsx | 44 ++- packages/css-data/src/parse-css-value.ts | 12 +- .../transition-property-extractor.ts | 21 +- .../src/property-parsers/transition.test.ts | 39 +-- .../src/property-parsers/transition.ts | 89 ++++- packages/css-engine/src/schema.ts | 2 + 11 files changed, 847 insertions(+), 117 deletions(-) create mode 100644 apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.test.ts diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-content.tsx b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-content.tsx index 372c741656..ccd6333133 100644 --- a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-content.tsx +++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-content.tsx @@ -25,6 +25,7 @@ import { type ExtractedTransitionProperties, } from "@webstudio-is/css-data"; import type { + CreateBatchUpdate, DeleteProperty, StyleUpdateOptions, } from "../../shared/use-style-data"; @@ -32,6 +33,14 @@ import { type IntermediateStyleValue } from "../../shared/css-value-input"; import { TransitionProperty } from "./transition-property"; import { TransitionTiming } from "./transition-timing"; import { CssValueInputContainer } from "../../shared/css-value-input"; +import { + defaultTransitionProperty, + defaultTransitionDuration, + defaultTransitionTimingFunction, + defaultTransitionDelay, + deleteTransitionLayer, +} from "./transition-utils"; +import type { StyleInfo } from "../../shared/style-info"; type TransitionContentProps = { index: number; @@ -45,16 +54,18 @@ type TransitionContentProps = { options: StyleUpdateOptions ) => void; deleteProperty: DeleteProperty; + currentStyle: StyleInfo; + createBatchUpdate: CreateBatchUpdate; }; export const TransitionContent = ({ layer, index, tooltip, - property: transitionPropertyKey, onEditLayer, propertyValue, - deleteProperty, + createBatchUpdate, + currentStyle, }: TransitionContentProps) => { const [intermediateValue, setIntermediateValue] = useState< IntermediateStyleValue | InvalidValue | undefined @@ -73,7 +84,7 @@ export const TransitionContent = ({ }); }; - const handleComplete = () => { + const handleComplete = (options: StyleUpdateOptions) => { if (intermediateValue === undefined) { return; } @@ -87,7 +98,7 @@ export const TransitionContent = ({ return; } - onEditLayer(index, layers, { isEphemeral: false }); + onEditLayer(index, layers, options); }; const handlePropertyUpdate = ( @@ -129,8 +140,7 @@ export const TransitionContent = ({ }} > @@ -157,14 +167,13 @@ export const TransitionContent = ({ key={"transitionDuration"} property={"transitionDuration"} styleSource="local" - /* Browser default for transition-duration */ - value={duration ?? { type: "unit", value: 0, unit: "ms" }} + value={duration ?? defaultTransitionDuration} keywords={[]} deleteProperty={() => { handlePropertyUpdate({ duration }); }} setValue={(value, options) => { - if (value === undefined) { + if (value === undefined || value.type !== "unit") { return; } handlePropertyUpdate({ duration: value }, options); @@ -193,12 +202,11 @@ export const TransitionContent = ({ property={"transitionDelay"} key={"transitionDelay"} styleSource="local" - /* Browser default for transition-delay */ - value={delay ?? { type: "unit", value: 0, unit: "ms" }} + value={delay ?? defaultTransitionDelay} keywords={[]} deleteProperty={() => handlePropertyUpdate({ delay })} setValue={(value, options) => { - if (value === undefined) { + if (value === undefined || value.type !== "unit") { return; } handlePropertyUpdate({ delay: value }, options); @@ -206,8 +214,7 @@ export const TransitionContent = ({ /> @@ -235,12 +242,12 @@ export const TransitionContent = ({ color={intermediateValue?.type === "invalid" ? "error" : undefined} value={intermediateValue?.value ?? ""} onChange={handleChange} - onBlur={handleComplete} + onBlur={() => handleComplete({ isEphemeral: true })} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") { - handleComplete(); + handleComplete({ isEphemeral: false }); event.preventDefault(); } @@ -249,7 +256,12 @@ export const TransitionContent = ({ return; } - deleteProperty(transitionPropertyKey, { isEphemeral: true }); + deleteTransitionLayer({ + currentStyle, + createBatchUpdate, + index, + options: { isEphemeral: true }, + }); setIntermediateValue(undefined); event.preventDefault(); } diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-property.tsx b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-property.tsx index 1e2ba95ff6..7198831b43 100644 --- a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-property.tsx +++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-property.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { animatableProperties, + commonTransitionProperties, isAnimatableProperty, } from "@webstudio-is/css-data"; import { @@ -31,17 +32,7 @@ type TransitionPropertyProps = { onPropertySelection: (params: { property: KeywordValue }) => void; }; -const commonPropertiesSet = new Set([ - "all", - "opacity", - "margin", - "padding", - "border", - "transform", - "filter", - "flex", - "background-color", -]); +const commonPropertiesSet = new Set(commonTransitionProperties); const properties = Array.from( setUnion(commonPropertiesSet, new Set(animatableProperties)) diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.test.ts b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.test.ts new file mode 100644 index 0000000000..ad0be47f8c --- /dev/null +++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, test, expect } from "@jest/globals"; +import type { CreateBatchUpdate } from "../../shared/use-style-data"; +import { + toValue, + type StyleProperty, + type StyleValue, +} from "@webstudio-is/css-engine"; +import type { StyleInfo } from "../../shared/style-info"; +import { + addDefaultTransitionLayer, + convertIndividualTransitionToLayers, + deleteTransitionLayer, + editTransitionLayer, + getTransitionProperties, + hideTransitionLayer, + swapTransitionLayers, +} from "./transition-utils"; +import type { TransitionProperties } from "./transition-utils"; +import type { LayersValue } from "@webstudio-is/css-engine"; + +let styleInfo: StyleInfo = {}; +let published = false; +const deletedProperties = new Set(); + +afterEach(() => { + styleInfo = {}; + published = false; + deletedProperties.clear(); +}); + +const createBatchUpdate: CreateBatchUpdate = () => ({ + setProperty: (propertyName: StyleProperty) => (newValue: StyleValue) => { + styleInfo[propertyName] = { value: newValue, local: newValue }; + }, + deleteProperty: (propertyName: string) => { + deletedProperties.add(propertyName); + }, + publish: (options?: unknown) => { + published = true; + }, +}); + +describe("transition-utils", () => { + test("convert individual transition properties into short-hand transition layers", () => { + const properties: Record = { + transitionProperty: { + type: "layers", + value: [ + { type: "keyword", value: "opacity" }, + { type: "keyword", value: "width" }, + { type: "keyword", value: "all" }, + { type: "keyword", value: "height" }, + ], + }, + transitionDuration: { + type: "layers", + value: [ + { type: "unit", value: 50, unit: "ms" }, + { type: "unit", value: 0, unit: "ms" }, + ], + }, + transitionTimingFunction: { + type: "layers", + value: [ + { type: "keyword", value: "ease" }, + { type: "keyword", value: "ease-in-out" }, + { type: "keyword", value: "cubic-bezier(0.25,1,0.5,1)" }, + ], + }, + transitionDelay: { + type: "layers", + value: [ + { type: "unit", value: 100, unit: "ms" }, + { type: "unit", value: 0, unit: "ms" }, + ], + }, + }; + + const layers = convertIndividualTransitionToLayers(properties); + expect(toValue(layers)).toMatchInlineSnapshot( + `"opacity 50ms ease 100ms, width 0ms ease-in-out 0ms, all 50ms cubic-bezier(0.25,1,0.5,1) 100ms, height 0ms ease 0ms"` + ); + }); + + test("add a layer to transition long hand proeprties", () => { + addDefaultTransitionLayer({ + createBatchUpdate, + currentStyle: styleInfo, + }); + + const properties = getTransitionProperties(styleInfo); + const layers = convertIndividualTransitionToLayers(properties); + + expect(published).toBe(true); + expect(toValue(layers)).toMatchInlineSnapshot(`"opacity 200ms ease 0s"`); + }); + + test("update individual transition properties", () => { + addDefaultTransitionLayer({ + createBatchUpdate, + currentStyle: styleInfo, + }); + + addDefaultTransitionLayer({ + createBatchUpdate, + currentStyle: styleInfo, + }); + + addDefaultTransitionLayer({ + createBatchUpdate, + currentStyle: styleInfo, + }); + + editTransitionLayer({ + currentStyle: styleInfo, + index: 1, + createBatchUpdate, + layers: { + type: "layers", + value: [ + { type: "tuple", value: [{ type: "keyword", value: "width" }] }, + ], + }, + options: { isEphemeral: false }, + }); + + const layers = convertIndividualTransitionToLayers( + getTransitionProperties(styleInfo) + ); + + expect(published).toBe(true); + expect(layers.value.length).toBe(3); + expect(toValue(layers.value[1])).toMatchInlineSnapshot( + `"width 0ms ease 0ms"` + ); + expect(toValue(layers)).toMatchInlineSnapshot( + `"opacity 200ms ease 0s, width 0ms ease 0ms, opacity 200ms ease 0s"` + ); + }); + + test("delete a layer from transition properties", () => { + addDefaultTransitionLayer({ + createBatchUpdate, + currentStyle: styleInfo, + }); + + addDefaultTransitionLayer({ + createBatchUpdate, + currentStyle: styleInfo, + }); + + addDefaultTransitionLayer({ + createBatchUpdate, + currentStyle: styleInfo, + }); + + editTransitionLayer({ + currentStyle: styleInfo, + index: 2, + createBatchUpdate, + layers: { + type: "layers", + value: [ + { + type: "tuple", + value: [ + { type: "keyword", value: "width" }, + { type: "keyword", value: "ease-in-out" }, + ], + }, + ], + }, + options: { isEphemeral: false }, + }); + + deleteTransitionLayer({ + currentStyle: styleInfo, + createBatchUpdate, + index: 1, + }); + + const layers = convertIndividualTransitionToLayers( + getTransitionProperties(styleInfo) + ); + + expect(published).toBe(true); + expect(toValue(layers)).toMatchInlineSnapshot( + `"opacity 200ms ease 0s, width 0ms ease-in-out 0ms"` + ); + }); + + test("hide a layer from transition properties", () => { + addDefaultTransitionLayer({ + createBatchUpdate, + currentStyle: styleInfo, + }); + + addDefaultTransitionLayer({ + createBatchUpdate, + currentStyle: styleInfo, + }); + + hideTransitionLayer({ + index: 1, + createBatchUpdate, + currentStyle: styleInfo, + }); + + const layers = convertIndividualTransitionToLayers( + getTransitionProperties(styleInfo) + ); + + expect(published).toBe(true); + expect(layers.value.length).toBe(2); + expect(toValue(layers)).toMatchInlineSnapshot(`"opacity 200ms ease 0s"`); + }); + + test("swap layer positions in transitions", () => { + addDefaultTransitionLayer({ currentStyle: styleInfo, createBatchUpdate }); + addDefaultTransitionLayer({ currentStyle: styleInfo, createBatchUpdate }); + editTransitionLayer({ + currentStyle: styleInfo, + index: 1, + createBatchUpdate, + layers: { + type: "layers", + value: [ + { + type: "tuple", + value: [ + { type: "keyword", value: "width" }, + { type: "keyword", value: "ease-in-out" }, + ], + }, + ], + }, + options: { isEphemeral: false }, + }); + swapTransitionLayers({ + oldIndex: 1, + newIndex: 0, + currentStyle: styleInfo, + createBatchUpdate, + }); + + const layers = convertIndividualTransitionToLayers( + getTransitionProperties(styleInfo) + ); + + expect(published).toBe(true); + expect(layers.value.length).toBe(2); + expect(toValue(layers)).toMatchInlineSnapshot( + `"width 0ms ease-in-out 0ms, opacity 200ms ease 0s"` + ); + }); +}); diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.ts b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.ts index 4a2a91d2a0..233f3dea97 100644 --- a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.ts +++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.ts @@ -1,3 +1,41 @@ +import { + type KeywordValue, + type LayersValue, + type TupleValue, + type UnitValue, +} from "@webstudio-is/css-engine"; +import type { StyleInfo } from "../../shared/style-info"; +import type { + CreateBatchUpdate, + StyleUpdateOptions, +} from "../../shared/use-style-data"; +import { + extractTransitionProperties, + isValidTransitionValue, + parseTransition, + transitionLongHandProperties, +} from "@webstudio-is/css-data"; + +export const initialTransition = "opacity 200ms ease 0s"; +export const defaultTransitionProperty: KeywordValue = { + type: "keyword", + value: "opacity", +}; +export const defaultTransitionDuration: UnitValue = { + type: "unit", + value: 0, + unit: "ms", +}; +export const defaultTransitionDelay: UnitValue = { + type: "unit", + value: 0, + unit: "ms", +}; +export const defaultTransitionTimingFunction: KeywordValue = { + type: "keyword", + value: "ease", +}; + export const defaultFunctions = { linear: "linear", ease: "ease", @@ -63,3 +101,289 @@ export const findTimingFunctionFromValue = ( (key: TimingFunctions) => timingFunctions[key] === timingFunction ); }; + +export type TransitionProperties = + (typeof transitionLongHandProperties)[number]; + +export const getTransitionProperties = ( + currentyStyle: StyleInfo +): Record => { + const properties: Record = { + transitionProperty: { type: "layers", value: [] }, + transitionTimingFunction: { type: "layers", value: [] }, + transitionDelay: { type: "layers", value: [] }, + transitionDuration: { type: "layers", value: [] }, + }; + for (const property of transitionLongHandProperties) { + const value = currentyStyle[property]; + + if (value !== undefined && value.value.type === "layers") { + properties[property] = value.value; + } + } + + return properties; +}; + +const getValueOrRepeatLast = (arr: LayersValue["value"], index: number) => { + return arr[index % arr.length]; +}; + +export const convertIndividualTransitionToLayers = ( + properties: Record +) => { + const layers: { type: "layers"; value: Array } = { + type: "layers", + value: [], + }; + + const { + transitionProperty, + transitionDuration, + transitionDelay, + transitionTimingFunction, + } = properties; + + // https://developer.mozilla.org/en-US/docs/Web/CSS/transition-timing-function + // You may specify multiple easing, duration and delay values; + // each one will be applied to the corresponding property as specified by the transition-property property, + // which acts as a transition-property list. If there are fewer easing, duration and transition values specified than in the transition-property list, + // the user agent must calculate which value is used by repeating the list of values until there is one for each transition property. + // If there are more values, the list is truncated to the right size. In both cases, the CSS declaration stays valid. + // And so, we take the lenght of the transiton-property as the base length of the layers + + for (let index = 0; index < transitionProperty.value.length; index++) { + const property = getValueOrRepeatLast(transitionProperty.value, index); + const duration = + getValueOrRepeatLast(transitionDuration.value, index) ?? + defaultTransitionDuration; + const timingFunction = + getValueOrRepeatLast(transitionTimingFunction.value, index) ?? + defaultTransitionTimingFunction; + const delay = + getValueOrRepeatLast(transitionDelay.value, index) ?? + defaultTransitionDelay; + + if ( + isValidTransitionValue(property) && + isValidTransitionValue(duration) && + isValidTransitionValue(timingFunction) && + isValidTransitionValue(delay) + ) { + const layer: TupleValue = { + type: "tuple", + value: [property, duration, timingFunction, delay], + hidden: property.hidden ?? false, + }; + layers.value.push(layer); + } + } + + return layers; +}; + +export const deleteTransitionProperties = (props: { + createBatchUpdate: CreateBatchUpdate; +}) => { + const batch = props.createBatchUpdate(); + transitionLongHandProperties.forEach((property) => { + batch.deleteProperty(property); + }); + batch.publish(); +}; + +export const addDefaultTransitionLayer = (props: { + createBatchUpdate: CreateBatchUpdate; + currentStyle: StyleInfo; +}) => { + const { createBatchUpdate, currentStyle } = props; + const properties = getTransitionProperties(currentStyle); + const { timing, property, delay, duration } = extractTransitionProperties( + parseTransition(initialTransition).value[0] as TupleValue + ); + const batch = createBatchUpdate(); + + if (property) { + batch.setProperty("transitionProperty")({ + type: "layers", + value: [...properties.transitionProperty.value, property], + }); + } + + if (timing) { + batch.setProperty("transitionTimingFunction")({ + type: "layers", + value: [...properties.transitionTimingFunction.value, timing], + }); + } + + if (duration) { + batch.setProperty("transitionDuration")({ + type: "layers", + value: [...properties.transitionDuration.value, duration], + }); + } + + if (delay) { + batch.setProperty("transitionDelay")({ + type: "layers", + value: [...properties.transitionDelay.value, delay], + }); + } + + batch.publish(); +}; + +export const deleteTransitionLayer = (props: { + index: number; + createBatchUpdate: CreateBatchUpdate; + currentStyle: StyleInfo; + options?: StyleUpdateOptions; +}) => { + const { + index, + createBatchUpdate, + currentStyle, + options = { isEphemeral: false }, + } = props; + + const properties = getTransitionProperties(currentStyle); + const batch = createBatchUpdate(); + transitionLongHandProperties.forEach((property) => { + const value = properties[property].value; + batch.setProperty(property)({ + type: "layers", + value: value.filter((_, i) => i !== index), + }); + }); + batch.publish(options); +}; + +export const editTransitionLayer = (props: { + index: number; + layers: LayersValue; + options: StyleUpdateOptions; + currentStyle: StyleInfo; + createBatchUpdate: CreateBatchUpdate; +}) => { + const { index, layers, createBatchUpdate, options, currentStyle } = props; + const batch = createBatchUpdate(); + + const properties = getTransitionProperties(currentStyle); + const { + transitionProperty, + transitionDelay, + transitionDuration, + transitionTimingFunction, + } = properties; + + const newTransitionProperties: KeywordValue[] = []; + const newTransitionDurations: UnitValue[] = []; + const newTransitionDelays: UnitValue[] = []; + const newTransitionTimingFunctions: KeywordValue[] = []; + + for (const layer of layers.value) { + if (layer.type !== "tuple") { + continue; + } + + const { + property, + duration = defaultTransitionDuration, + timing = defaultTransitionTimingFunction, + delay = defaultTransitionDelay, + } = extractTransitionProperties(layer); + + // transition-property can't be undefined + if (property === undefined) { + return; + } + + newTransitionProperties.push(property); + newTransitionDurations.push(duration); + newTransitionDelays.push(delay); + newTransitionTimingFunctions.push(timing); + } + + const newProperty = [...transitionProperty.value]; + newProperty.splice(index, 1, ...newTransitionProperties); + batch.setProperty("transitionProperty")({ + type: "layers", + value: newProperty, + }); + + const newDuration = [...transitionDuration.value]; + newDuration.splice(index, 1, ...newTransitionDurations); + batch.setProperty("transitionDuration")({ + type: "layers", + value: newDuration, + }); + + const newTiming = [...transitionTimingFunction.value]; + newTiming.splice(index, 1, ...newTransitionTimingFunctions); + batch.setProperty("transitionTimingFunction")({ + type: "layers", + value: newTiming, + }); + + const newDelay = [...transitionDelay.value]; + newDelay.splice(index, 1, ...newTransitionDelays); + batch.setProperty("transitionDelay")({ + type: "layers", + value: newDelay, + }); + + batch.publish(options); +}; + +export const swapTransitionLayers = (props: { + oldIndex: number; + newIndex: number; + createBatchUpdate: CreateBatchUpdate; + currentStyle: StyleInfo; +}) => { + const { oldIndex, newIndex, createBatchUpdate, currentStyle } = props; + const properties = getTransitionProperties(currentStyle); + const batch = createBatchUpdate(); + + for (const property of transitionLongHandProperties) { + const layer = properties[property]; + const layervalue = [...layer.value]; + layervalue.splice(oldIndex, 1); + layervalue.splice(newIndex, 0, layer.value[oldIndex]); + batch.setProperty(property)({ + ...layer, + value: layervalue, + }); + } + + batch.publish(); +}; + +export const hideTransitionLayer = (props: { + index: number; + createBatchUpdate: CreateBatchUpdate; + currentStyle: StyleInfo; +}) => { + const { createBatchUpdate, index, currentStyle } = props; + const properties = getTransitionProperties(currentStyle); + const batch = createBatchUpdate(); + + for (const property of transitionLongHandProperties) { + const propertyLayer = properties[property]; + batch.setProperty(property)({ + type: "layers", + value: propertyLayer.value.map((layer, i) => { + if (layer.type !== "keyword" && layer.type !== "unit") { + return layer; + } + + return i === index + ? { ...layer, hidden: layer.hidden ? false : true } + : layer; + }), + }); + } + + batch.publish(); +}; diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx b/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx index 0fd4cec926..32957f0148 100644 --- a/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx +++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx @@ -1,39 +1,47 @@ -import type { StyleProperty } from "@webstudio-is/css-engine"; -import { CollapsibleSectionRoot } from "~/builder/shared/collapsible-section"; -import type { SectionProps } from "../shared/section"; +import { useState, useMemo } from "react"; +import { useStore } from "@nanostores/react"; +import { InfoCircleIcon, PlusIcon } from "@webstudio-is/icons"; import { SectionTitle, SectionTitleButton, SectionTitleLabel, - Tooltip, Text, + Tooltip, } from "@webstudio-is/design-system"; -import { useState } from "react"; +import { transitionLongHandProperties } from "@webstudio-is/css-data"; + +import { CollapsibleSectionRoot } from "~/builder/shared/collapsible-section"; +import type { SectionProps } from "../shared/section"; import { getDots } from "../../shared/collapsible-section"; -import { getStyleSource } from "../../shared/style-info"; -import { InfoCircleIcon, PlusIcon } from "@webstudio-is/icons"; import { PropertyName } from "../../shared/property-name"; -import { addLayer } from "../../style-layer-utils"; -import { parseTransition } from "@webstudio-is/css-data"; import { LayersList } from "../../style-layers-list"; import { $selectedOrLastStyleSourceSelector } from "~/shared/nano-states"; -import { useStore } from "@nanostores/react"; import { TransitionContent } from "./transition-content"; +import { + initialTransition, + getTransitionProperties, + deleteTransitionProperties, + addDefaultTransitionLayer, + deleteTransitionLayer, + editTransitionLayer, + swapTransitionLayers, + hideTransitionLayer, + convertIndividualTransitionToLayers, +} from "./transition-utils"; +import type { StyleProperty } from "@webstudio-is/css-engine"; -export const properties = ["transition"] satisfies Array; - -const property: StyleProperty = properties[0]; const label = "Transitions"; -const initialTransition = "opacity 200ms ease"; +export const properties = + transitionLongHandProperties as unknown as Array; export const Section = (props: SectionProps) => { - const { currentStyle, deleteProperty } = props; + const { currentStyle, createBatchUpdate } = props; const [isOpen, setIsOpen] = useState(true); - const value = currentStyle[property]?.value; - const sectionStyleSource = - value?.type === "unparsed" || value?.type === "guaranteedInvalid" - ? undefined - : getStyleSource(currentStyle[property]); + const extractedProperties = getTransitionProperties(currentStyle); + const layers = useMemo( + () => convertIndividualTransitionToLayers(extractedProperties), + [extractedProperties] + ); const selectedOrLastStyleSourceSelector = useStore( $selectedOrLastStyleSourceSelector @@ -51,7 +59,7 @@ export const Section = (props: SectionProps) => { onOpenChange={setIsOpen} trigger={ { } - onClick={() => { - addLayer( - property, - parseTransition(initialTransition), + onClick={() => + addDefaultTransitionLayer({ + createBatchUpdate, currentStyle, - props.createBatchUpdate - ); - setIsOpen(true); - }} + }) + } /> } @@ -81,23 +86,49 @@ export const Section = (props: SectionProps) => { style={currentStyle} description="Animate the transition between states on this instance." properties={properties} - label={ - - {label} - + label={{label}} + onReset={() => + deleteTransitionProperties({ + createBatchUpdate, + }) } - onReset={() => deleteProperty(property)} /> } > - {value?.type === "layers" && value.value.length > 0 && ( + {layers.value.length > 0 && ( + deleteTransitionProperties({ + createBatchUpdate, + }) + } + deleteLayer={(index) => + deleteTransitionLayer({ + index, + createBatchUpdate, + currentStyle, + }) + } + swapLayers={(oldIndex, newIndex) => + swapTransitionLayers({ + oldIndex, + newIndex, + createBatchUpdate, + currentStyle, + }) + } + hideLayer={(index) => { + hideTransitionLayer({ + index, + createBatchUpdate, + currentStyle, + }); + }} renderContent={(layerProps) => { if (layerProps.layer.type !== "tuple") { return <>; @@ -106,7 +137,18 @@ export const Section = (props: SectionProps) => { return ( + editTransitionLayer({ + index, + layers, + options, + createBatchUpdate, + currentStyle, + }) + } tooltip={ boolean | void; + swapLayers?: (oldIndex: number, newIndex: number) => void; + hideLayer?: (index: number) => void; renderContent: (props: { index: number; layer: TupleValue | FunctionValue; @@ -90,15 +94,16 @@ const extractPropertiesFromLayer = (layer: TupleValue | FunctionValue) => { return { name: name.join(" "), value: shadow.join(" "), color }; }; -export const LayersList = ({ - label, - property, - value, - currentStyle, - createBatchUpdate, - renderContent, - deleteProperty, -}: LayerListProps) => { +export const LayersList = (props: LayerListProps) => { + const { + label, + property, + value, + currentStyle, + createBatchUpdate, + renderContent, + deleteProperty, + } = props; const layersCount = getLayerCount(property, currentStyle); const sortableItems = useMemo( @@ -112,17 +117,28 @@ export const LayersList = ({ const { dragItemId, placementIndicator, sortableRefCallback } = useSortable({ items: sortableItems, - onSort: (newIndex, oldIndex) => { - swapLayers(property, newIndex, oldIndex, currentStyle, createBatchUpdate); - }, + onSort: (newIndex, oldIndex) => + props.swapLayers + ? props.swapLayers(oldIndex, newIndex) + : swapLayers( + property, + newIndex, + oldIndex, + currentStyle, + createBatchUpdate + ), }); const handleDeleteLayer = (index: number) => { - return deleteLayer(property, index, value, createBatchUpdate); + return props?.deleteLayer + ? props.deleteLayer(index) + : deleteLayer(property, index, value, createBatchUpdate); }; const handleHideLayer = (index: number) => { - return hideLayer(property, index, value, createBatchUpdate); + return props?.hideLayer + ? props.hideLayer(index) + : hideLayer(property, index, value, createBatchUpdate); }; const onEditLayer = ( diff --git a/packages/css-data/src/parse-css-value.ts b/packages/css-data/src/parse-css-value.ts index 97354b41fc..5eedb7aaa5 100644 --- a/packages/css-data/src/parse-css-value.ts +++ b/packages/css-data/src/parse-css-value.ts @@ -10,7 +10,13 @@ import { } from "@webstudio-is/css-engine"; import { keywordValues } from "./__generated__/keyword-values"; import { units } from "./__generated__/units"; -import { parseFilter, parseShadow, parseTransition } from "./property-parsers"; +import { + isTransitionLongHandProperty, + parseFilter, + parseShadow, + parseTransition, + parseTransitionLonghands, +} from "./property-parsers"; import { camelCase } from "change-case"; export const cssTryParseValue = (input: string) => { @@ -92,6 +98,10 @@ export const parseCssValue = ( return parseTransition(input); } + if (isTransitionLongHandProperty(property)) { + return parseTransitionLonghands(property, input); + } + if ( ast != null && ast.type === "Value" && diff --git a/packages/css-data/src/property-parsers/transition-property-extractor.ts b/packages/css-data/src/property-parsers/transition-property-extractor.ts index 309a422265..ffbbba8f6e 100644 --- a/packages/css-data/src/property-parsers/transition-property-extractor.ts +++ b/packages/css-data/src/property-parsers/transition-property-extractor.ts @@ -1,6 +1,5 @@ import type { KeywordValue, - StyleValue, TupleValue, UnitValue, } from "@webstudio-is/css-engine"; @@ -12,19 +11,17 @@ export const isTimingFunction = (timing: string) => { }; export type ExtractedTransitionProperties = { - property?: KeywordValue | null; - timing?: KeywordValue | null; - delay?: StyleValue | null; - duration?: StyleValue | null; + property?: KeywordValue; + timing?: KeywordValue; + delay?: UnitValue; + duration?: UnitValue; }; export const extractTransitionProperties = ( transition: TupleValue ): ExtractedTransitionProperties => { - let property: KeywordValue | null = null; - let timing: KeywordValue | null = null; - let duration: UnitValue | null = null; - let delay: UnitValue | null = null; + let property: KeywordValue | undefined; + let timing: KeywordValue | undefined; const keywordValues: KeywordValue[] = []; const unitValues: UnitValue[] = []; @@ -43,11 +40,11 @@ export const extractTransitionProperties = ( timing = keywordValues[0]; } else { property = keywordValues[0]; - timing = keywordValues[1] ?? null; + timing = keywordValues[1] ?? undefined; } - duration = unitValues[0] ?? null; - delay = unitValues[1] ?? null; + const duration: UnitValue | undefined = unitValues[0] ?? undefined; + const delay: UnitValue | undefined = unitValues[1] ?? undefined; return { property, diff --git a/packages/css-data/src/property-parsers/transition.test.ts b/packages/css-data/src/property-parsers/transition.test.ts index 0b8af3dd45..aefdba6a43 100644 --- a/packages/css-data/src/property-parsers/transition.test.ts +++ b/packages/css-data/src/property-parsers/transition.test.ts @@ -1,20 +1,18 @@ -import { describe, expect, test } from "@jest/globals"; +import { expect, test } from "@jest/globals"; import { parseTransition } from "./transition"; -describe("parseTransition", () => { - test("parses value and returns invalid when used a invalid color", () => { - expect(parseTransition(`10px 10px 5px foo`)).toMatchInlineSnapshot(` +test("parses value and returns invalid when used a invalid color", () => { + expect(parseTransition(`10px 10px 5px foo`)).toMatchInlineSnapshot(` { "type": "invalid", "value": "10px 10px 5px foo", } `); - }); +}); - test("parse value and returns multiple valid layers", () => { - expect(parseTransition("color .2s, text-shadow .2s")) - .toMatchInlineSnapshot(` +test("parse value and returns multiple valid layers", () => { + expect(parseTransition("color .2s, text-shadow .2s")).toMatchInlineSnapshot(` { "type": "layers", "value": [ @@ -49,10 +47,10 @@ describe("parseTransition", () => { ], } `); - }); +}); - test("parse value and returns valid layer", () => { - expect(parseTransition("opacity 200ms ease 0s")).toMatchInlineSnapshot(` +test("parse value and returns valid layer", () => { + expect(parseTransition("opacity 200ms ease 0s")).toMatchInlineSnapshot(` { "type": "layers", "value": [ @@ -82,27 +80,24 @@ describe("parseTransition", () => { ], } `); - }); +}); - test("throws error if any custom transition proeprty is used", () => { - const transition = parseTransition( - "all 200ms ease 0s, --foo 200ms ease 0s" - ); - expect(transition).toMatchInlineSnapshot(` +test("throws error if any custom transition proeprty is used", () => { + const transition = parseTransition("all 200ms ease 0s, --foo 200ms ease 0s"); + expect(transition).toMatchInlineSnapshot(` { "type": "invalid", "value": "all 200ms ease 0s, --foo 200ms ease 0s", } `); - }); +}); - test("throws error if any custom transition timing function is used", () => { - const transition = parseTransition("all 200ms custom-function 0s"); - expect(transition).toMatchInlineSnapshot(` +test("throws error if any custom transition timing function is used", () => { + const transition = parseTransition("all 200ms custom-function 0s"); + expect(transition).toMatchInlineSnapshot(` { "type": "invalid", "value": "all 200ms custom-function 0s", } `); - }); }); diff --git a/packages/css-data/src/property-parsers/transition.ts b/packages/css-data/src/property-parsers/transition.ts index ddc38320a9..3da05efe90 100644 --- a/packages/css-data/src/property-parsers/transition.ts +++ b/packages/css-data/src/property-parsers/transition.ts @@ -1,20 +1,63 @@ import * as csstree from "css-tree"; import type { InvalidValue, + KeywordValue, + LayerValueItem, LayersValue, TupleValue, TupleValueItem, Unit, + UnparsedValue, + UnitValue, } from "@webstudio-is/css-engine"; import { animatableProperties } from "../"; import { isTimingFunction } from "./transition-property-extractor"; import { cssTryParseValue } from "../parse-css-value"; +import { kebabCase } from "change-case"; type AnimatableProperty = (typeof animatableProperties)[number]; + +export const transitionLongHandProperties = [ + "transitionProperty", + "transitionTimingFunction", + "transitionDelay", + "transitionDuration", +] as const; + +export const commonTransitionProperties = [ + "all", + "opacity", + "margin", + "padding", + "border", + "transform", + "filter", + "flex", + "background-color", +]; + export const isAnimatableProperty = ( property: string ): property is AnimatableProperty => { - return animatableProperties.some((item) => item === property); + if (property === "all") { + return true; + } + + return [...commonTransitionProperties, ...animatableProperties].some( + (item) => item === property + ); +}; + +export const isTransitionLongHandProperty = ( + property: string +): property is (typeof transitionLongHandProperties)[number] => { + return transitionLongHandProperties.some((item) => item === property); +}; + +export const isValidTransitionValue = ( + value: LayerValueItem +): value is KeywordValue | UnitValue => { + return value.type === "keyword" || value.type === "unit"; }; export const parseTransition = ( @@ -34,7 +77,6 @@ export const parseTransition = ( } const cssAst = cssTryParseValue(tokenStream); - if (cssAst === undefined) { return { type: "invalid", @@ -119,3 +161,46 @@ export const parseTransition = ( }; } }; + +export const parseTransitionLonghands = ( + property: (typeof transitionLongHandProperties)[number], + input: string +): LayersValue | InvalidValue | UnparsedValue => { + const cssAst = cssTryParseValue(input); + if (cssAst === undefined) { + return { + type: "invalid", + value: input, + }; + } + + const parsed = csstree.lexer.matchProperty(kebabCase(property), cssAst); + if (parsed.error) { + return { + type: "invalid", + value: input, + }; + } + + const layers: LayersValue = { type: "layers", value: [] }; + csstree.walk(cssAst, (node) => { + if (node.type === "Identifier") { + if ( + isAnimatableProperty(node.name) === true || + isTimingFunction(node.name) === true + ) { + layers.value.push({ type: "keyword", value: node.name }); + } + } + + if (node.type === "Dimension") { + layers.value.push({ + type: "unit", + value: Number(node.value), + unit: node.unit as Unit, + }); + } + }); + + return layers.value.length > 0 ? layers : { type: "invalid", value: input }; +}; diff --git a/packages/css-engine/src/schema.ts b/packages/css-engine/src/schema.ts index c257848fe8..76dfbccff4 100644 --- a/packages/css-engine/src/schema.ts +++ b/packages/css-engine/src/schema.ts @@ -16,6 +16,7 @@ export const UnitValue = z.object({ type: z.literal("unit"), unit: Unit, value: z.number(), + hidden: z.boolean().optional(), }); export type UnitValue = z.infer; @@ -24,6 +25,7 @@ export const KeywordValue = z.object({ type: z.literal("keyword"), // @todo use exact type value: z.string(), + hidden: z.boolean().optional(), }); export type KeywordValue = z.infer;