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
This commit is contained in:
Jaya Krishna Namburu authored and GitHub committed 2024-06-23 13:23:56 +02:00
1 parent 9a39c24502
commit ce2dad417d
11 files changed
+847 -117

No files matched your search

@@ -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 = ({
}}
>
<TransitionProperty
/* Browser defaults for transition-property - all */
property={property ?? { type: "keyword" as const, value: "all" }}
property={property ?? defaultTransitionProperty}
onPropertySelection={handlePropertyUpdate}
/>
@@ -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 = ({
/>
<TransitionTiming
/* Browser defaults for transition-property - ease */
timing={timing ?? { type: "keyword", value: "ease" }}
timing={timing ?? defaultTransitionTimingFunction}
onTimingSelection={handlePropertyUpdate}
/>
</Grid>
@@ -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();
}
@@ -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<string>([
"all",
"opacity",
"margin",
"padding",
"border",
"transform",
"filter",
"flex",
"background-color",
]);
const commonPropertiesSet = new Set(commonTransitionProperties);
const properties = Array.from(
setUnion(commonPropertiesSet, new Set(animatableProperties))
@@ -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<string>();
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<TransitionProperties, LayersValue> = {
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"`
);
});
});
@@ -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<TransitionProperties, LayersValue> => {
const properties: Record<TransitionProperties, LayersValue> = {
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<TransitionProperties, LayersValue>
) => {
const layers: { type: "layers"; value: Array<TupleValue> } = {
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();
};
@@ -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<StyleProperty>;
const property: StyleProperty = properties[0];
const label = "Transitions";
const initialTransition = "opacity 200ms ease";
export const properties =
transitionLongHandProperties as unknown as Array<StyleProperty>;
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={
<SectionTitle
dots={getDots(currentStyle, [property])}
dots={getDots(currentStyle, properties)}
suffix={
<Tooltip
content={
@@ -63,15 +71,12 @@ export const Section = (props: SectionProps) => {
<SectionTitleButton
disabled={isStyleInLocalState === false}
prefix={<PlusIcon />}
onClick={() => {
addLayer(
property,
parseTransition(initialTransition),
onClick={() =>
addDefaultTransitionLayer({
createBatchUpdate,
currentStyle,
props.createBatchUpdate
);
setIsOpen(true);
}}
})
}
/>
</Tooltip>
}
@@ -81,23 +86,49 @@ export const Section = (props: SectionProps) => {
style={currentStyle}
description="Animate the transition between states on this instance."
properties={properties}
label={
<SectionTitleLabel color={sectionStyleSource}>
{label}
</SectionTitleLabel>
label={<SectionTitleLabel>{label}</SectionTitleLabel>}
onReset={() =>
deleteTransitionProperties({
createBatchUpdate,
})
}
onReset={() => deleteProperty(property)}
/>
</SectionTitle>
}
>
{value?.type === "layers" && value.value.length > 0 && (
{layers.value.length > 0 && (
<LayersList
{...props}
property={property}
value={value}
property={properties[0]}
value={layers}
label={label}
deleteProperty={deleteProperty}
deleteProperty={() =>
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 (
<TransitionContent
{...layerProps}
currentStyle={currentStyle}
layer={layerProps.layer}
createBatchUpdate={createBatchUpdate}
onEditLayer={(index, layers, options) =>
editTransitionLayer({
index,
layers,
options,
createBatchUpdate,
currentStyle,
})
}
tooltip={
<Tooltip
variant="wrapped"
@@ -41,6 +41,10 @@ type LayerListProps = SectionProps & {
label: string;
property: StyleProperty;
value: TupleValue | LayersValue;
deleteProperty: DeleteProperty;
deleteLayer?: (index: number) => 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 = (
+11 -1
View File
@@ -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" &&
@@ -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,
@@ -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",
}
`);
});
});
@@ -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 };
};
+2
View File
@@ -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<typeof UnitValue>;
@@ -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<typeof KeywordValue>;