mirror of
https://github.com/webstudio-is/webstudio.git
synced 2025-03-14 09:57:02 +00:00
experimental: Animate UI (#4851)
## Description 1. What is this PR about (link the issue and add a short description) ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## 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: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
@ -14,17 +14,14 @@ sudo rm -rf /tmp/corepack-cache
|
||||
sudo rm -rf /usr/local/lib/node_modules/corepack # Manually remove global corepack
|
||||
|
||||
# Reinstall corepack globally via npm
|
||||
npm install -g corepack@latest # Install latest corepack version
|
||||
npm install -g corepack@latest --force # Install latest corepack version
|
||||
sudo corepack enable # Re-enable corepack
|
||||
|
||||
# Check corepack version after reinstall
|
||||
echo "--- Corepack version after reinstall ---"
|
||||
corepack --version
|
||||
echo "--- End corepack version check ---"
|
||||
|
||||
|
||||
# Prepare pnpm (again, after corepack reinstall)
|
||||
sudo corepack prepare pnpm@9.14.4 --activate
|
||||
corepack prepare pnpm@9.14.4 --activate
|
||||
|
||||
# Go to workspace directory
|
||||
cd /workspaces/webstudio
|
||||
@ -42,7 +39,7 @@ find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' +
|
||||
|
||||
# Install dependencies, build, and migrate
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm build
|
||||
pnpm migrations migrate
|
||||
|
||||
# Add git aliases
|
||||
|
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@ -135,7 +135,7 @@ jobs:
|
||||
const results = [
|
||||
await assertSize('./fixtures/ssg/dist/client', 352),
|
||||
await assertSize('./fixtures/react-router-netlify/build/client', 360),
|
||||
await assertSize('./fixtures/webstudio-features/build/client', 926),
|
||||
await assertSize('./fixtures/webstudio-features/build/client', 932),
|
||||
]
|
||||
for (const result of results) {
|
||||
if (result.passed) {
|
||||
|
1
@types/scroll-timeline.d.ts
vendored
1
@types/scroll-timeline.d.ts
vendored
@ -12,6 +12,7 @@ declare class ScrollTimeline extends AnimationTimeline {
|
||||
interface ViewTimelineOptions {
|
||||
subject?: Element | Document | null;
|
||||
axis?: ScrollAxis;
|
||||
inset?: string;
|
||||
}
|
||||
|
||||
declare class ViewTimeline extends ScrollTimeline {
|
||||
|
@ -59,9 +59,15 @@ const $metas = computed(
|
||||
const availableComponents = new Set<string>();
|
||||
const metas: Meta[] = [];
|
||||
for (const [name, componentMeta] of componentMetas) {
|
||||
if (
|
||||
isFeatureEnabled("animation") === false &&
|
||||
name.endsWith(":AnimateChildren")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// only set available components from component meta
|
||||
availableComponents.add(name);
|
||||
|
||||
if (
|
||||
isFeatureEnabled("headSlotComponent") === false &&
|
||||
name === "HeadSlot"
|
||||
|
@ -1631,7 +1631,16 @@ export const PageSettings = ({
|
||||
onDuplicate={() => {
|
||||
const newPageId = duplicatePage(pageId);
|
||||
if (newPageId !== undefined) {
|
||||
// In `canvas.tsx`, within `subscribeStyles`, we use `requestAnimationFrame` (RAF) for style recalculation.
|
||||
// After `duplicatePage`, styles are not yet recalculated.
|
||||
// To ensure they are properly updated, we use double RAF.
|
||||
requestAnimationFrame(() => {
|
||||
// At this tick styles are updating
|
||||
requestAnimationFrame(() => {
|
||||
// At this tick styles are updated
|
||||
onDuplicate(newPageId);
|
||||
});
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -216,6 +216,12 @@ export const renderControl = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (prop.type === "animationAction") {
|
||||
throw new Error(
|
||||
`Cannot render a fallback control for prop "${rest.propName}" with type animationAction`
|
||||
);
|
||||
}
|
||||
|
||||
prop satisfies never;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,218 @@
|
||||
import { parseCss } from "@webstudio-is/css-data";
|
||||
import { StyleValue, toValue } from "@webstudio-is/css-engine";
|
||||
import {
|
||||
Text,
|
||||
Grid,
|
||||
IconButton,
|
||||
Label,
|
||||
Separator,
|
||||
Tooltip,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { MinusIcon, PlusIcon } from "@webstudio-is/icons";
|
||||
import type { AnimationKeyframe } from "@webstudio-is/sdk";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import {
|
||||
CssValueInput,
|
||||
type IntermediateStyleValue,
|
||||
} from "~/builder/features/style-panel/shared/css-value-input";
|
||||
import { toKebabCase } from "~/builder/features/style-panel/shared/keyword-utils";
|
||||
import { CodeEditor } from "~/builder/shared/code-editor";
|
||||
import { useIds } from "~/shared/form-utils";
|
||||
|
||||
const unitOptions = [
|
||||
{
|
||||
id: "%" as const,
|
||||
label: "%",
|
||||
type: "unit" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const OffsetInput = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: number | undefined;
|
||||
onChange: (value: number | undefined) => void;
|
||||
}) => {
|
||||
const [intermediateValue, setIntermediateValue] = useState<
|
||||
StyleValue | IntermediateStyleValue
|
||||
>();
|
||||
|
||||
return (
|
||||
<CssValueInput
|
||||
id={id}
|
||||
placeholder="auto"
|
||||
getOptions={() => []}
|
||||
unitOptions={unitOptions}
|
||||
intermediateValue={intermediateValue}
|
||||
styleSource="default"
|
||||
/* same as offset has 0 - 100% */
|
||||
property={"fontStretch"}
|
||||
value={
|
||||
value !== undefined
|
||||
? {
|
||||
type: "unit",
|
||||
value: Math.round(value * 1000) / 10,
|
||||
unit: "%",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={(styleValue) => {
|
||||
if (styleValue === undefined) {
|
||||
setIntermediateValue(styleValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedStyleValue = { ...styleValue };
|
||||
if (
|
||||
clampedStyleValue.type === "unit" &&
|
||||
clampedStyleValue.unit === "%"
|
||||
) {
|
||||
clampedStyleValue.value = Math.min(
|
||||
100,
|
||||
Math.max(0, clampedStyleValue.value)
|
||||
);
|
||||
}
|
||||
|
||||
setIntermediateValue(clampedStyleValue);
|
||||
}}
|
||||
onHighlight={(_styleValue) => {
|
||||
/* @todo: think about preview */
|
||||
}}
|
||||
onChangeComplete={(event) => {
|
||||
setIntermediateValue(undefined);
|
||||
|
||||
if (event.value.type === "unit" && event.value.unit === "%") {
|
||||
onChange(Math.min(100, Math.max(0, event.value.value)) / 100);
|
||||
return;
|
||||
}
|
||||
|
||||
setIntermediateValue({
|
||||
type: "invalid",
|
||||
value: toValue(event.value),
|
||||
});
|
||||
}}
|
||||
onAbort={() => {
|
||||
/* @todo: allow to change some ephemeral property to see the result in action */
|
||||
}}
|
||||
onReset={() => {
|
||||
setIntermediateValue(undefined);
|
||||
onChange(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Keyframe = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: AnimationKeyframe;
|
||||
onChange: (value: AnimationKeyframe | undefined) => void;
|
||||
}) => {
|
||||
const ids = useIds(["offset"]);
|
||||
|
||||
const cssProperties = useMemo(() => {
|
||||
let result = ``;
|
||||
for (const [property, style] of Object.entries(value.styles)) {
|
||||
result = `${result}${toKebabCase(property)}: ${toValue(style)};\n`;
|
||||
}
|
||||
return result;
|
||||
}, [value.styles]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid
|
||||
gap={1}
|
||||
align={"center"}
|
||||
css={{ gridTemplateColumns: "1fr 1fr auto" }}
|
||||
>
|
||||
<Label htmlFor={ids.offset}>Offset</Label>
|
||||
<OffsetInput
|
||||
id={ids.offset}
|
||||
value={value.offset}
|
||||
onChange={(offset) => {
|
||||
onChange({ ...value, offset });
|
||||
}}
|
||||
/>
|
||||
<Tooltip content="Remove keyframe">
|
||||
<IconButton onClick={() => onChange(undefined)}>
|
||||
<MinusIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<CodeEditor
|
||||
lang="css-properties"
|
||||
size="keyframe"
|
||||
value={cssProperties}
|
||||
onChange={() => {
|
||||
/* do nothing */
|
||||
}}
|
||||
onChangeComplete={(cssText) => {
|
||||
const parsedStyles = parseCss(`selector{${cssText}}`);
|
||||
onChange({
|
||||
...value,
|
||||
styles: parsedStyles.reduce(
|
||||
(r, { property, value }) => ({ ...r, [property]: value }),
|
||||
{}
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Keyframes = ({
|
||||
value: keyframes,
|
||||
onChange,
|
||||
}: {
|
||||
value: AnimationKeyframe[];
|
||||
onChange: (value: AnimationKeyframe[]) => void;
|
||||
}) => {
|
||||
const ids = useIds(["addKeyframe"]);
|
||||
|
||||
return (
|
||||
<Grid gap={2}>
|
||||
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr auto" }}>
|
||||
<Label htmlFor={ids.addKeyframe}>
|
||||
<Text variant={"titles"}>Keyframes</Text>
|
||||
</Label>
|
||||
<IconButton
|
||||
id={ids.addKeyframe}
|
||||
onClick={() =>
|
||||
onChange([...keyframes, { offset: undefined, styles: {} }])
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
|
||||
{keyframes.map((value, index) => (
|
||||
<Fragment key={index}>
|
||||
<Separator />
|
||||
<Keyframe
|
||||
key={index}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
if (newValue === undefined) {
|
||||
const newValues = [...keyframes];
|
||||
newValues.splice(index, 1);
|
||||
onChange(newValues);
|
||||
return;
|
||||
}
|
||||
|
||||
const newValues = [...keyframes];
|
||||
newValues[index] = newValue;
|
||||
onChange(newValues);
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
@ -0,0 +1,99 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { AnimationPanelContent } from "./animation-panel-content";
|
||||
import { theme } from "@webstudio-is/design-system";
|
||||
import { useState } from "react";
|
||||
import type { ScrollAnimation, ViewAnimation } from "@webstudio-is/sdk";
|
||||
|
||||
const meta = {
|
||||
title: "Builder/Settings Panel/Animation Panel Content",
|
||||
component: AnimationPanelContent,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ background: theme.colors.backgroundPanel, padding: 16 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof AnimationPanelContent>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const ScrollAnimationTemplate: Story["render"] = ({ value: initialValue }) => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
return (
|
||||
<AnimationPanelContent
|
||||
type="scroll"
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as ScrollAnimation);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ViewAnimationTemplate: Story["render"] = ({ value: initialValue }) => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
return (
|
||||
<AnimationPanelContent
|
||||
type="view"
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as ViewAnimation);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScrollAnimationStory: Story = {
|
||||
render: ScrollAnimationTemplate,
|
||||
args: {
|
||||
type: "scroll",
|
||||
value: {
|
||||
name: "scroll-animation",
|
||||
timing: {
|
||||
rangeStart: ["start", { type: "unit", value: 0, unit: "%" }],
|
||||
rangeEnd: ["end", { type: "unit", value: 100, unit: "%" }],
|
||||
},
|
||||
keyframes: [
|
||||
{
|
||||
offset: 0,
|
||||
styles: {
|
||||
opacity: { type: "unit", value: 0, unit: "%" },
|
||||
color: { type: "rgb", r: 255, g: 0, b: 0, alpha: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const ViewAnimationStory: Story = {
|
||||
render: ViewAnimationTemplate,
|
||||
args: {
|
||||
type: "view",
|
||||
value: {
|
||||
name: "view-animation",
|
||||
timing: {
|
||||
rangeStart: ["entry", { type: "unit", value: 0, unit: "%" }],
|
||||
rangeEnd: ["exit", { type: "unit", value: 100, unit: "%" }],
|
||||
},
|
||||
keyframes: [
|
||||
{
|
||||
offset: 0,
|
||||
styles: {
|
||||
opacity: { type: "unit", value: 0, unit: "%" },
|
||||
color: { type: "rgb", r: 255, g: 0, b: 0, alpha: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
@ -0,0 +1,410 @@
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Label,
|
||||
Select,
|
||||
theme,
|
||||
toast,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { toPascalCase } from "~/builder/features/style-panel/shared/keyword-utils";
|
||||
import { useIds } from "~/shared/form-utils";
|
||||
|
||||
import type {
|
||||
RangeUnitValue,
|
||||
ScrollAnimation,
|
||||
ViewAnimation,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
RANGE_UNITS,
|
||||
rangeUnitValueSchema,
|
||||
scrollAnimationSchema,
|
||||
viewAnimationSchema,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
CssValueInput,
|
||||
type IntermediateStyleValue,
|
||||
} from "~/builder/features/style-panel/shared/css-value-input/css-value-input";
|
||||
import { toValue, type StyleValue } from "@webstudio-is/css-engine";
|
||||
import { useState } from "react";
|
||||
import { Keyframes } from "./animation-keyframes";
|
||||
import { styleConfigByName } from "~/builder/features/style-panel/shared/configs";
|
||||
import { titleCase } from "title-case";
|
||||
|
||||
type Props = {
|
||||
type: "scroll" | "view";
|
||||
value: ScrollAnimation | ViewAnimation;
|
||||
onChange: (value: ScrollAnimation | ViewAnimation) => void;
|
||||
};
|
||||
|
||||
const fillModeDescriptions: Record<
|
||||
NonNullable<ViewAnimation["timing"]["fill"]>,
|
||||
string
|
||||
> = {
|
||||
none: "No animation is applied before or after the active period",
|
||||
forwards:
|
||||
"The animation state is applied after the active period. Prefered for Out Animations",
|
||||
backwards:
|
||||
"The animation state is applied before the active period. Prefered for In Animations",
|
||||
both: "The animation state is applied before and after the active period",
|
||||
};
|
||||
|
||||
const fillModeNames = Object.keys(fillModeDescriptions) as NonNullable<
|
||||
ViewAnimation["timing"]["fill"]
|
||||
>[];
|
||||
|
||||
/**
|
||||
* https://developer.mozilla.org/en-US/docs/Web/CSS/animation-range-start
|
||||
*
|
||||
* <timeline-range-name>
|
||||
**/
|
||||
const viewTimelineRangeName = {
|
||||
entry:
|
||||
"Animates during the subject element entry (starts entering → fully visible)",
|
||||
exit: "Animates during the subject element exit (starts exiting → fully hidden)",
|
||||
contain:
|
||||
"Animates only while the subject element is fully in view (fullly visible after entering → starts exiting)",
|
||||
cover:
|
||||
"Animates entire time the subject element is visible (starts entering → ends after exiting)",
|
||||
"entry-crossing":
|
||||
"Animates as the subject element enters (leading edge → trailing edge enters view)",
|
||||
"exit-crossing":
|
||||
"Animates as the subject element exits (leading edge → trailing edge leaves view)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll does not support https://drafts.csswg.org/scroll-animations/#named-ranges
|
||||
* However, for simplicity and type unification with the view, we will use the names "start" and "end,"
|
||||
* which will be transformed as follows:
|
||||
* - "start" → `calc(0% + range)`
|
||||
* - "end" → `calc(100% - range)`
|
||||
*/
|
||||
const scrollTimelineRangeName = {
|
||||
start: "Distance from the top of the scroll container where animation begins",
|
||||
end: "Distance from the bottom of the scroll container where animation ends",
|
||||
};
|
||||
|
||||
const unitOptions = RANGE_UNITS.map((unit) => ({
|
||||
id: unit,
|
||||
label: unit,
|
||||
type: "unit" as const,
|
||||
}));
|
||||
|
||||
const RangeValueInput = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: RangeUnitValue;
|
||||
onChange: (value: RangeUnitValue) => void;
|
||||
}) => {
|
||||
const [intermediateValue, setIntermediateValue] = useState<
|
||||
StyleValue | IntermediateStyleValue
|
||||
>();
|
||||
|
||||
return (
|
||||
<CssValueInput
|
||||
id={id}
|
||||
styleSource="default"
|
||||
value={value}
|
||||
/* marginLeft to allow negative values */
|
||||
property={"marginLeft"}
|
||||
unitOptions={unitOptions}
|
||||
intermediateValue={intermediateValue}
|
||||
onChange={(styleValue) => {
|
||||
setIntermediateValue(styleValue);
|
||||
/* @todo: allow to change some ephemeral property to see the result in action */
|
||||
}}
|
||||
getOptions={() => []}
|
||||
onHighlight={() => {
|
||||
/* Nothing to Highlight */
|
||||
}}
|
||||
onChangeComplete={(event) => {
|
||||
const parsedValue = rangeUnitValueSchema.safeParse(event.value);
|
||||
if (parsedValue.success) {
|
||||
onChange(parsedValue.data);
|
||||
setIntermediateValue(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setIntermediateValue({
|
||||
type: "invalid",
|
||||
value: toValue(event.value),
|
||||
});
|
||||
}}
|
||||
onAbort={() => {
|
||||
/* @todo: allow to change some ephemeral property to see the result in action */
|
||||
}}
|
||||
onReset={() => {
|
||||
setIntermediateValue(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EasingInput = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: string | undefined;
|
||||
onChange: (value: string | undefined) => void;
|
||||
}) => {
|
||||
const [intermediateValue, setIntermediateValue] = useState<
|
||||
StyleValue | IntermediateStyleValue
|
||||
>();
|
||||
|
||||
const property = "animationTimingFunction";
|
||||
|
||||
return (
|
||||
<CssValueInput
|
||||
id={id}
|
||||
styleSource="default"
|
||||
value={
|
||||
value === undefined
|
||||
? { type: "keyword", value: "ease" }
|
||||
: { type: "unparsed", value }
|
||||
}
|
||||
getOptions={() => [
|
||||
...styleConfigByName(property).items.map((item) => ({
|
||||
type: "keyword" as const,
|
||||
value: item.name,
|
||||
})),
|
||||
]}
|
||||
property={property}
|
||||
intermediateValue={intermediateValue}
|
||||
onChange={(styleValue) => {
|
||||
setIntermediateValue(styleValue);
|
||||
/* @todo: allow to change some ephemeral property to see the result in action */
|
||||
}}
|
||||
onHighlight={() => {
|
||||
/* Nothing to Highlight */
|
||||
}}
|
||||
onChangeComplete={(event) => {
|
||||
onChange(toValue(event.value));
|
||||
setIntermediateValue(undefined);
|
||||
}}
|
||||
onAbort={() => {
|
||||
/* @todo: allow to change some ephemeral property to see the result in action */
|
||||
}}
|
||||
onReset={() => {
|
||||
setIntermediateValue(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnimationPanelContent = ({ onChange, value, type }: Props) => {
|
||||
const fieldIds = useIds([
|
||||
"rangeStartName",
|
||||
"rangeStartValue",
|
||||
"rangeEndName",
|
||||
"rangeEndValue",
|
||||
"fill",
|
||||
"easing",
|
||||
] as const);
|
||||
|
||||
const timelineRangeDescriptions =
|
||||
type === "scroll" ? scrollTimelineRangeName : viewTimelineRangeName;
|
||||
|
||||
const timelineRangeNames = Object.keys(timelineRangeDescriptions);
|
||||
|
||||
const animationSchema =
|
||||
type === "scroll" ? scrollAnimationSchema : viewAnimationSchema;
|
||||
|
||||
const handleChange = (rawValue: unknown) => {
|
||||
const parsedValue = animationSchema.safeParse(rawValue);
|
||||
if (parsedValue.success) {
|
||||
onChange(parsedValue.data);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Animation schema is incompatible, try fix");
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid gap="2" css={{ padding: theme.panel.padding }}>
|
||||
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr 1fr" }}>
|
||||
<Label htmlFor={fieldIds.fill}>Fill Mode</Label>
|
||||
<Label htmlFor={fieldIds.easing}>Easing</Label>
|
||||
<Select
|
||||
id={fieldIds.fill}
|
||||
options={fillModeNames}
|
||||
getLabel={(fillModeName: string) => titleCase(fillModeName)}
|
||||
value={value.timing.fill ?? fillModeNames[0]}
|
||||
getDescription={(fillModeName: string) => (
|
||||
<Box
|
||||
css={{
|
||||
width: theme.spacing[28],
|
||||
}}
|
||||
>
|
||||
{
|
||||
fillModeDescriptions[
|
||||
fillModeName as keyof typeof fillModeDescriptions
|
||||
]
|
||||
}
|
||||
</Box>
|
||||
)}
|
||||
onChange={(fillModeName) => {
|
||||
handleChange({
|
||||
...value,
|
||||
timing: {
|
||||
...value.timing,
|
||||
fill: fillModeName,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<EasingInput
|
||||
id={fieldIds.easing}
|
||||
value={value.timing.easing}
|
||||
onChange={(easing) => {
|
||||
handleChange({
|
||||
...value,
|
||||
timing: {
|
||||
...value.timing,
|
||||
easing,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "2fr 1fr" }}>
|
||||
<Label htmlFor={fieldIds.rangeStartName}>Range Start</Label>
|
||||
<Label htmlFor={fieldIds.rangeStartValue}>Value</Label>
|
||||
|
||||
<Select
|
||||
id={fieldIds.rangeStartName}
|
||||
options={timelineRangeNames}
|
||||
getLabel={(timelineRangeName: string) =>
|
||||
toPascalCase(timelineRangeName)
|
||||
}
|
||||
value={value.timing.rangeStart?.[0] ?? timelineRangeNames[0]!}
|
||||
getDescription={(timelineRangeName: string) => (
|
||||
<Box
|
||||
css={{
|
||||
width: theme.spacing[28],
|
||||
}}
|
||||
>
|
||||
{
|
||||
timelineRangeDescriptions[
|
||||
timelineRangeName as keyof typeof timelineRangeDescriptions
|
||||
]
|
||||
}
|
||||
</Box>
|
||||
)}
|
||||
onChange={(timelineRangeName) => {
|
||||
handleChange({
|
||||
...value,
|
||||
timing: {
|
||||
...value.timing,
|
||||
rangeStart: [
|
||||
timelineRangeName,
|
||||
value.timing.rangeStart?.[1] ?? {
|
||||
type: "unit",
|
||||
value: 0,
|
||||
unit: "%",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<RangeValueInput
|
||||
id={fieldIds.rangeStartValue}
|
||||
value={
|
||||
value.timing.rangeStart?.[1] ?? {
|
||||
type: "unit",
|
||||
value: 0,
|
||||
unit: "%",
|
||||
}
|
||||
}
|
||||
onChange={(rangeStart) => {
|
||||
const defaultTimelineRangeName = timelineRangeNames[0]!;
|
||||
|
||||
handleChange({
|
||||
...value,
|
||||
timing: {
|
||||
...value.timing,
|
||||
rangeStart: [
|
||||
value.timing.rangeStart?.[0] ?? defaultTimelineRangeName,
|
||||
rangeStart,
|
||||
],
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Label htmlFor={fieldIds.rangeEndName}>Range End</Label>
|
||||
<Label htmlFor={fieldIds.rangeEndValue}>Value</Label>
|
||||
<Select
|
||||
id={fieldIds.rangeEndName}
|
||||
options={timelineRangeNames}
|
||||
getLabel={(timelineRangeName: string) =>
|
||||
toPascalCase(timelineRangeName)
|
||||
}
|
||||
value={value.timing.rangeEnd?.[0] ?? timelineRangeNames[0]!}
|
||||
getDescription={(timelineRangeName: string) => (
|
||||
<Box
|
||||
css={{
|
||||
width: theme.spacing[28],
|
||||
}}
|
||||
>
|
||||
{
|
||||
timelineRangeDescriptions[
|
||||
timelineRangeName as keyof typeof timelineRangeDescriptions
|
||||
]
|
||||
}
|
||||
</Box>
|
||||
)}
|
||||
onChange={(timelineRangeName) => {
|
||||
handleChange({
|
||||
...value,
|
||||
timing: {
|
||||
...value.timing,
|
||||
rangeEnd: [
|
||||
timelineRangeName,
|
||||
value.timing.rangeEnd?.[1] ?? {
|
||||
type: "unit",
|
||||
value: 0,
|
||||
unit: "%",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<RangeValueInput
|
||||
id={fieldIds.rangeEndValue}
|
||||
value={
|
||||
value.timing.rangeEnd?.[1] ?? {
|
||||
type: "unit",
|
||||
value: 0,
|
||||
unit: "%",
|
||||
}
|
||||
}
|
||||
onChange={(rangeEnd) => {
|
||||
const defaultTimelineRangeName = timelineRangeNames[0]!;
|
||||
|
||||
handleChange({
|
||||
...value,
|
||||
timing: {
|
||||
...value.timing,
|
||||
rangeEnd: [
|
||||
value.timing.rangeEnd?.[0] ?? defaultTimelineRangeName,
|
||||
rangeEnd,
|
||||
],
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Keyframes
|
||||
value={value.keyframes}
|
||||
onChange={(keyframes) => handleChange({ ...value, keyframes })}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
@ -0,0 +1,362 @@
|
||||
import {
|
||||
Grid,
|
||||
theme,
|
||||
Select,
|
||||
Label,
|
||||
Separator,
|
||||
Box,
|
||||
toast,
|
||||
ToggleGroup,
|
||||
Tooltip,
|
||||
ToggleGroupButton,
|
||||
Text,
|
||||
Switch,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { useIds } from "~/shared/form-utils";
|
||||
import type { PropAndMeta } from "../use-props-logic";
|
||||
import type {
|
||||
AnimationAction,
|
||||
AnimationActionScroll,
|
||||
InsetUnitValue,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { toPascalCase } from "~/builder/features/style-panel/shared/keyword-utils";
|
||||
import {
|
||||
animationActionSchema,
|
||||
insetUnitValueSchema,
|
||||
RANGE_UNITS,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { RepeatColumnIcon, RepeatRowIcon } from "@webstudio-is/icons";
|
||||
import { AnimationsSelect } from "./animations-select";
|
||||
import { SubjectSelect } from "./subject-select";
|
||||
import { toValue, type StyleValue } from "@webstudio-is/css-engine";
|
||||
import {
|
||||
CssValueInput,
|
||||
type IntermediateStyleValue,
|
||||
} from "~/builder/features/style-panel/shared/css-value-input";
|
||||
import { useState } from "react";
|
||||
|
||||
const animationTypeDescription: Record<AnimationAction["type"], string> = {
|
||||
scroll:
|
||||
"Scroll-based animations are triggered and controlled by the user’s scroll position.",
|
||||
view: "View-based animations occur when an element enters or exits the viewport. They rely on the element’s visibility rather than the scroll position.",
|
||||
};
|
||||
|
||||
const animationTypes: AnimationAction["type"][] = Object.keys(
|
||||
animationTypeDescription
|
||||
) as AnimationAction["type"][];
|
||||
|
||||
const defaultActionValue: AnimationAction = {
|
||||
type: "scroll",
|
||||
animations: [],
|
||||
};
|
||||
|
||||
const animationAxisDescription: Record<
|
||||
NonNullable<AnimationAction["axis"]>,
|
||||
{ icon: React.ReactNode; label: string; description: React.ReactNode }
|
||||
> = {
|
||||
block: {
|
||||
icon: <RepeatColumnIcon />,
|
||||
label: "Block axis",
|
||||
description:
|
||||
"Uses the scroll progress along the block axis (depends on writing mode, usually vertical in English).",
|
||||
},
|
||||
inline: {
|
||||
icon: <RepeatRowIcon />,
|
||||
label: "Inline axis",
|
||||
description:
|
||||
"Uses the scroll progress along the inline axis (depends on writing mode, usually horizontal in English).",
|
||||
},
|
||||
y: {
|
||||
label: "Y axis",
|
||||
icon: <RepeatColumnIcon />,
|
||||
description:
|
||||
"Always maps to the vertical scroll direction, regardless of writing mode.",
|
||||
},
|
||||
x: {
|
||||
label: "X axis",
|
||||
icon: <RepeatRowIcon />,
|
||||
description:
|
||||
"Always maps to the horizontal scroll direction, regardless of writing mode.",
|
||||
},
|
||||
};
|
||||
|
||||
const animationSourceDescriptions: Record<
|
||||
NonNullable<AnimationActionScroll["source"]>,
|
||||
string
|
||||
> = {
|
||||
nearest: "Selects the scrolling container that affects the current element.",
|
||||
root: "Selects the scrolling element of the document.",
|
||||
closest: "Selects the nearest ancestor element that is scrollable.",
|
||||
};
|
||||
|
||||
const unitOptions = RANGE_UNITS.map((unit) => ({
|
||||
id: unit,
|
||||
label: unit,
|
||||
type: "unit" as const,
|
||||
}));
|
||||
|
||||
const InsetValueInput = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: InsetUnitValue;
|
||||
onChange: (value: InsetUnitValue) => void;
|
||||
}) => {
|
||||
const [intermediateValue, setIntermediateValue] = useState<
|
||||
StyleValue | IntermediateStyleValue
|
||||
>();
|
||||
|
||||
return (
|
||||
<CssValueInput
|
||||
id={id}
|
||||
styleSource="default"
|
||||
value={value}
|
||||
/* marginLeft to allow negative values */
|
||||
property={"marginLeft"}
|
||||
unitOptions={unitOptions}
|
||||
intermediateValue={intermediateValue}
|
||||
onChange={(styleValue) => {
|
||||
setIntermediateValue(styleValue);
|
||||
/* @todo: allow to change some ephemeral property to see the result in action */
|
||||
}}
|
||||
getOptions={() => [
|
||||
{
|
||||
value: "auto",
|
||||
type: "keyword",
|
||||
description:
|
||||
"Pick the child element’s viewTimelineInset property or use the scrolling element’s scroll-padding, depending on the selected axis.",
|
||||
},
|
||||
]}
|
||||
onHighlight={() => {
|
||||
/* Nothing to Highlight */
|
||||
}}
|
||||
onChangeComplete={(event) => {
|
||||
const parsedValue = insetUnitValueSchema.safeParse(event.value);
|
||||
if (parsedValue.success) {
|
||||
onChange(parsedValue.data);
|
||||
setIntermediateValue(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setIntermediateValue({
|
||||
type: "invalid",
|
||||
value: toValue(event.value),
|
||||
});
|
||||
}}
|
||||
onAbort={() => {
|
||||
/* @todo: allow to change some ephemeral property to see the result in action */
|
||||
}}
|
||||
onReset={() => {
|
||||
setIntermediateValue(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const animationSources = Object.keys(
|
||||
animationSourceDescriptions
|
||||
) as NonNullable<AnimationActionScroll["source"]>[];
|
||||
|
||||
export const AnimateSection = ({
|
||||
animationAction,
|
||||
onChange,
|
||||
}: {
|
||||
animationAction: PropAndMeta;
|
||||
onChange: (value: AnimationAction) => void;
|
||||
}) => {
|
||||
const fieldIds = useIds([
|
||||
"type",
|
||||
"subject",
|
||||
"source",
|
||||
"insetStart",
|
||||
"insetEnd",
|
||||
] as const);
|
||||
|
||||
const { prop } = animationAction;
|
||||
|
||||
const value: AnimationAction =
|
||||
prop?.type === "animationAction" ? prop.value : defaultActionValue;
|
||||
|
||||
const handleChange = (value: unknown) => {
|
||||
const parsedValue = animationActionSchema.safeParse(value);
|
||||
if (parsedValue.success) {
|
||||
onChange(parsedValue.data);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Schemas are incompatible, try fix");
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid
|
||||
css={{
|
||||
paddingBottom: theme.panel.paddingBlock,
|
||||
}}
|
||||
>
|
||||
<Box css={{ height: theme.panel.paddingBlock }} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<Grid
|
||||
gap={1}
|
||||
align={"center"}
|
||||
css={{
|
||||
gridTemplateColumns: "1fr auto",
|
||||
padding: theme.panel.paddingInline,
|
||||
}}
|
||||
>
|
||||
<Text variant={"titles"}>Animation</Text>
|
||||
|
||||
<Tooltip content={value.isPinned ? "Unpin Animation" : "Pin Animation"}>
|
||||
<Switch
|
||||
checked={value.isPinned ?? false}
|
||||
onCheckedChange={(isPinned) => {
|
||||
handleChange({ ...value, isPinned });
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Box css={{ height: theme.panel.paddingBlock }} />
|
||||
<Grid gap={2} css={{ paddingInline: theme.panel.paddingInline }}>
|
||||
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr 1fr" }}>
|
||||
<Label htmlFor={fieldIds.type}>Action</Label>
|
||||
<Select
|
||||
id={fieldIds.type}
|
||||
options={animationTypes}
|
||||
getLabel={(animationType: AnimationAction["type"]) =>
|
||||
toPascalCase(animationType)
|
||||
}
|
||||
value={value.type}
|
||||
getDescription={(animationType: AnimationAction["type"]) => (
|
||||
<Box
|
||||
css={{
|
||||
width: theme.spacing[28],
|
||||
}}
|
||||
>
|
||||
{animationTypeDescription[animationType]}
|
||||
</Box>
|
||||
)}
|
||||
onChange={(typeValue) => {
|
||||
handleChange({ ...value, type: typeValue, animations: [] });
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr 1fr" }}>
|
||||
<Label>Axis</Label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={value.axis ?? ("block" as const)}
|
||||
onValueChange={(axis) => {
|
||||
handleChange({ ...value, axis });
|
||||
}}
|
||||
>
|
||||
{Object.entries(animationAxisDescription).map(
|
||||
([key, { icon, label, description }]) => (
|
||||
<Tooltip
|
||||
key={key}
|
||||
content={
|
||||
<Grid gap={1}>
|
||||
<Text variant={"titles"}>{label}</Text>
|
||||
<Text>{description}</Text>
|
||||
</Grid>
|
||||
}
|
||||
>
|
||||
<ToggleGroupButton value={key}>{icon}</ToggleGroupButton>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</ToggleGroup>
|
||||
</Grid>
|
||||
|
||||
{value.type === "scroll" && (
|
||||
<Grid
|
||||
gap={1}
|
||||
align={"center"}
|
||||
css={{ gridTemplateColumns: "1fr 1fr" }}
|
||||
>
|
||||
<Label htmlFor={fieldIds.source}>Scroll Source</Label>
|
||||
|
||||
<Select
|
||||
id={fieldIds.source}
|
||||
options={animationSources}
|
||||
getLabel={(
|
||||
animationSource: NonNullable<AnimationActionScroll["source"]>
|
||||
) => toPascalCase(animationSource)}
|
||||
value={value.source ?? "nearest"}
|
||||
getDescription={(
|
||||
animationSource: NonNullable<AnimationActionScroll["source"]>
|
||||
) => (
|
||||
<Box
|
||||
css={{
|
||||
width: theme.spacing[28],
|
||||
}}
|
||||
>
|
||||
{animationSourceDescriptions[animationSource]}
|
||||
</Box>
|
||||
)}
|
||||
onChange={(source) => {
|
||||
handleChange({ ...value, source });
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{value.type === "view" && (
|
||||
<Grid
|
||||
gap={1}
|
||||
align={"center"}
|
||||
css={{ gridTemplateColumns: "1fr 1fr" }}
|
||||
>
|
||||
<Label htmlFor={fieldIds.subject}>Subject</Label>
|
||||
<SubjectSelect
|
||||
id={fieldIds.subject}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{value.type === "view" && (
|
||||
<Grid
|
||||
gap={1}
|
||||
align={"center"}
|
||||
css={{ gridTemplateColumns: "1fr 1fr" }}
|
||||
>
|
||||
<Label htmlFor={fieldIds.insetStart}>
|
||||
{value.axis === "inline" || value.axis === "x"
|
||||
? "Left Inset"
|
||||
: "Top Inset"}
|
||||
</Label>
|
||||
<Label htmlFor={fieldIds.insetEnd}>
|
||||
{value.axis === "inline" || value.axis === "x"
|
||||
? "Right Inset"
|
||||
: "Bottom Inset"}
|
||||
</Label>
|
||||
<InsetValueInput
|
||||
id={fieldIds.insetStart}
|
||||
value={value.insetStart ?? { type: "keyword", value: "auto" }}
|
||||
onChange={(insetStart) => {
|
||||
handleChange({ ...value, insetStart });
|
||||
}}
|
||||
/>
|
||||
<InsetValueInput
|
||||
id={fieldIds.insetEnd}
|
||||
value={value.insetEnd ?? { type: "keyword", value: "auto" }}
|
||||
onChange={(insetEnd) => {
|
||||
handleChange({ ...value, insetEnd });
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<AnimationsSelect value={value} onChange={onChange} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
@ -0,0 +1,270 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
theme,
|
||||
IconButton,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
rawTheme,
|
||||
CssValueListItem,
|
||||
SmallToggleButton,
|
||||
SmallIconButton,
|
||||
Box,
|
||||
Text,
|
||||
Label,
|
||||
Grid,
|
||||
useSortable,
|
||||
CssValueListArrowFocus,
|
||||
toast,
|
||||
FloatingPanel,
|
||||
InputField,
|
||||
DialogTitle,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
EyeClosedIcon,
|
||||
EyeOpenIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "@webstudio-is/icons";
|
||||
import type {
|
||||
AnimationAction,
|
||||
ScrollAnimation,
|
||||
ViewAnimation,
|
||||
} from "@webstudio-is/sdk";
|
||||
|
||||
import { animationActionSchema } from "@webstudio-is/sdk";
|
||||
import {
|
||||
newFadeInScrollAnimation,
|
||||
newFadeOutScrollAnimation,
|
||||
newScrollAnimation,
|
||||
} from "./new-scroll-animations";
|
||||
import {
|
||||
newFadeInViewAnimation,
|
||||
newFadeOutViewAnimation,
|
||||
newViewAnimation,
|
||||
} from "./new-view-animations";
|
||||
import { useIds } from "~/shared/form-utils";
|
||||
import { AnimationPanelContent } from "./animation-panel-content";
|
||||
|
||||
const newAnimationsPerType: {
|
||||
scroll: ScrollAnimation[];
|
||||
view: ViewAnimation[];
|
||||
} = {
|
||||
scroll: [
|
||||
newScrollAnimation,
|
||||
newFadeInScrollAnimation,
|
||||
newFadeOutScrollAnimation,
|
||||
],
|
||||
view: [newViewAnimation, newFadeInViewAnimation, newFadeOutViewAnimation],
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: AnimationAction;
|
||||
onChange: (value: AnimationAction) => void;
|
||||
};
|
||||
|
||||
const floatingPanelOffset = { alignmentAxis: -100 };
|
||||
|
||||
export const AnimationsSelect = ({ value, onChange }: Props) => {
|
||||
const fieldIds = useIds(["addAnimation"] as const);
|
||||
|
||||
const [newAnimationHint, setNewAnimationHint] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const newAnimations = newAnimationsPerType[value.type];
|
||||
|
||||
const sortableItems = useMemo(
|
||||
() => value.animations.map((_, index) => ({ id: `${index}`, index })),
|
||||
[value.animations]
|
||||
);
|
||||
|
||||
const { dragItemId, placementIndicator, sortableRefCallback } = useSortable({
|
||||
items: sortableItems,
|
||||
onSort: (newIndex, oldIndex) => {
|
||||
const newAnimations = [...value.animations];
|
||||
const [movedItem] = newAnimations.splice(oldIndex, 1);
|
||||
newAnimations.splice(newIndex, 0, movedItem);
|
||||
const newValue = { ...value, animations: newAnimations };
|
||||
const parsedValue = animationActionSchema.safeParse(newValue);
|
||||
|
||||
if (parsedValue.success) {
|
||||
onChange(parsedValue.data);
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to sort animation");
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (newValue: unknown) => {
|
||||
const parsedValue = animationActionSchema.safeParse(newValue);
|
||||
|
||||
if (parsedValue.success) {
|
||||
onChange(parsedValue.data);
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to add animation");
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr auto" }}>
|
||||
<Label htmlFor={fieldIds.addAnimation}>
|
||||
<Text variant={"titles"}>Animations</Text>
|
||||
</Label>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton id={fieldIds.addAnimation}>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
sideOffset={Number.parseFloat(rawTheme.spacing[5])}
|
||||
css={{ width: theme.spacing[25] }}
|
||||
>
|
||||
{newAnimations.map((animation, index) => (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
onSelect={() => {
|
||||
handleChange({
|
||||
...value,
|
||||
animations: value.animations.concat(animation),
|
||||
});
|
||||
}}
|
||||
onFocus={() => setNewAnimationHint(animation.description)}
|
||||
onBlur={() => setNewAnimationHint(undefined)}
|
||||
>
|
||||
{animation.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem css={{ display: "grid" }} hint>
|
||||
{newAnimations.map(({ description }, index) => (
|
||||
<Box
|
||||
css={{
|
||||
gridColumn: "1",
|
||||
gridRow: "1",
|
||||
visibility: "hidden",
|
||||
}}
|
||||
key={index}
|
||||
>
|
||||
{description}
|
||||
</Box>
|
||||
))}
|
||||
<Box
|
||||
css={{
|
||||
gridColumn: "1",
|
||||
gridRow: "1",
|
||||
}}
|
||||
>
|
||||
{newAnimationHint ?? "Add new or select existing animation"}
|
||||
</Box>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<CssValueListArrowFocus dragItemId={dragItemId}>
|
||||
<Grid gap={1} css={{ gridColumn: "span 2" }} ref={sortableRefCallback}>
|
||||
{value.animations.map((animation, index) => (
|
||||
<FloatingPanel
|
||||
key={index}
|
||||
title={
|
||||
<DialogTitle css={{ paddingLeft: theme.spacing[6] }}>
|
||||
<InputField
|
||||
css={{
|
||||
width: "100%",
|
||||
fontWeight: `inherit`,
|
||||
}}
|
||||
variant="chromeless"
|
||||
value={animation.name}
|
||||
autoFocus={true}
|
||||
placeholder="Enter animation name"
|
||||
onChange={(event) => {
|
||||
const name = event.currentTarget.value;
|
||||
const newAnimations = [...value.animations];
|
||||
newAnimations[index] = { ...animation, name };
|
||||
|
||||
const newValue = {
|
||||
...value,
|
||||
animations: newAnimations,
|
||||
};
|
||||
|
||||
handleChange(newValue);
|
||||
}}
|
||||
/>
|
||||
</DialogTitle>
|
||||
}
|
||||
content={
|
||||
<AnimationPanelContent
|
||||
type={value.type}
|
||||
value={animation}
|
||||
onChange={(animation) => {
|
||||
const newAnimations = [...value.animations];
|
||||
newAnimations[index] = animation;
|
||||
const newValue = {
|
||||
...value,
|
||||
animations: newAnimations,
|
||||
};
|
||||
handleChange(newValue);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
offset={floatingPanelOffset}
|
||||
>
|
||||
<CssValueListItem
|
||||
key={index}
|
||||
label={
|
||||
<Label disabled={false} truncate>
|
||||
{animation.name ?? "Unnamed"}
|
||||
</Label>
|
||||
}
|
||||
hidden={false}
|
||||
draggable
|
||||
active={dragItemId === String(index)}
|
||||
state={undefined}
|
||||
index={index}
|
||||
id={String(index)}
|
||||
buttons={
|
||||
<>
|
||||
<SmallToggleButton
|
||||
pressed={false}
|
||||
onPressedChange={() => {
|
||||
alert("Not implemented");
|
||||
}}
|
||||
variant="normal"
|
||||
tabIndex={-1}
|
||||
icon={
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
false ? <EyeClosedIcon /> : <EyeOpenIcon />
|
||||
}
|
||||
/>
|
||||
|
||||
<SmallIconButton
|
||||
variant="destructive"
|
||||
tabIndex={-1}
|
||||
icon={<MinusIcon />}
|
||||
onClick={() => {
|
||||
const newAnimations = [...value.animations];
|
||||
newAnimations.splice(index, 1);
|
||||
|
||||
const newValue = {
|
||||
...value,
|
||||
animations: newAnimations,
|
||||
};
|
||||
handleChange(newValue);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</FloatingPanel>
|
||||
))}
|
||||
{placementIndicator}
|
||||
</Grid>
|
||||
</CssValueListArrowFocus>
|
||||
</Grid>
|
||||
);
|
||||
};
|
@ -0,0 +1,61 @@
|
||||
import { parseCssValue } from "@webstudio-is/css-data";
|
||||
import type { ScrollAnimation } from "@webstudio-is/sdk";
|
||||
|
||||
export const newScrollAnimation: ScrollAnimation = {
|
||||
name: "New Animation",
|
||||
description: "Create a new animation.",
|
||||
|
||||
timing: {
|
||||
rangeStart: ["start", { type: "unit", value: 0, unit: "px" }],
|
||||
rangeEnd: ["end", { type: "unit", value: 0, unit: "px" }],
|
||||
fill: "backwards",
|
||||
easing: "linear",
|
||||
},
|
||||
keyframes: [
|
||||
{
|
||||
offset: 0,
|
||||
styles: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// @todo: visit https://github.com/argyleink/open-props/blob/main/src/props.animations.css
|
||||
export const newFadeInScrollAnimation: ScrollAnimation = {
|
||||
name: "Fade In",
|
||||
description: "Fade in the element as it scrolls into view.",
|
||||
|
||||
timing: {
|
||||
rangeStart: ["start", { type: "unit", value: 0, unit: "%" }],
|
||||
rangeEnd: ["start", { type: "unit", value: 50, unit: "dvh" }],
|
||||
fill: "backwards",
|
||||
easing: "linear",
|
||||
},
|
||||
keyframes: [
|
||||
{
|
||||
offset: 0,
|
||||
styles: {
|
||||
opacity: parseCssValue("opacity", "0"),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const newFadeOutScrollAnimation: ScrollAnimation = {
|
||||
name: "Fade Out",
|
||||
description: "Fade out the element as it scrolls out of view.",
|
||||
|
||||
timing: {
|
||||
rangeStart: ["end", { type: "unit", value: 50, unit: "dvh" }],
|
||||
rangeEnd: ["end", { type: "unit", value: 0, unit: "%" }],
|
||||
fill: "backwards",
|
||||
easing: "linear",
|
||||
},
|
||||
keyframes: [
|
||||
{
|
||||
offset: 1,
|
||||
styles: {
|
||||
opacity: parseCssValue("opacity", "0"),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
@ -0,0 +1,61 @@
|
||||
import { parseCssValue } from "@webstudio-is/css-data";
|
||||
import type { ViewAnimation } from "@webstudio-is/sdk";
|
||||
|
||||
export const newViewAnimation: ViewAnimation = {
|
||||
name: "New Animation",
|
||||
description: "Create a new animation.",
|
||||
|
||||
timing: {
|
||||
rangeStart: ["entry", { type: "unit", value: 0, unit: "%" }],
|
||||
rangeEnd: ["entry", { type: "unit", value: 100, unit: "%" }],
|
||||
fill: "backwards",
|
||||
easing: "linear",
|
||||
},
|
||||
keyframes: [
|
||||
{
|
||||
offset: 0,
|
||||
styles: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// @todo: visit https://github.com/argyleink/open-props/blob/main/src/props.animations.css
|
||||
export const newFadeInViewAnimation: ViewAnimation = {
|
||||
name: "Fade In",
|
||||
description: "Fade in the element as it scrolls into view.",
|
||||
|
||||
timing: {
|
||||
rangeStart: ["entry", { type: "unit", value: 0, unit: "%" }],
|
||||
rangeEnd: ["entry", { type: "unit", value: 100, unit: "%" }],
|
||||
fill: "backwards",
|
||||
easing: "linear",
|
||||
},
|
||||
keyframes: [
|
||||
{
|
||||
offset: 0,
|
||||
styles: {
|
||||
opacity: parseCssValue("opacity", "0"),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const newFadeOutViewAnimation: ViewAnimation = {
|
||||
name: "Fade Out",
|
||||
description: "Fade out the element as it scrolls out of view.",
|
||||
|
||||
timing: {
|
||||
rangeStart: ["exit", { type: "unit", value: 0, unit: "%" }],
|
||||
rangeEnd: ["exit", { type: "unit", value: 100, unit: "%" }],
|
||||
fill: "forwards",
|
||||
easing: "linear",
|
||||
},
|
||||
keyframes: [
|
||||
{
|
||||
offset: 1,
|
||||
styles: {
|
||||
opacity: parseCssValue("opacity", "0"),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
@ -0,0 +1,87 @@
|
||||
import { createRegularStyleSheet } from "@webstudio-is/css-engine";
|
||||
import { ROOT_INSTANCE_ID, type WebstudioData } from "@webstudio-is/sdk";
|
||||
import { $, ws, css, renderData } from "@webstudio-is/template";
|
||||
import { expect, test } from "vitest";
|
||||
import { setListedCssProperty } from "./set-css-property";
|
||||
|
||||
const toCss = (data: Omit<WebstudioData, "pages">) => {
|
||||
const sheet = createRegularStyleSheet();
|
||||
sheet.addMediaRule("base");
|
||||
for (const { instanceId, values } of data.styleSourceSelections.values()) {
|
||||
for (const styleSourceId of values) {
|
||||
const styleSource = data.styleSources.get(styleSourceId);
|
||||
let name;
|
||||
if (styleSource?.type === "local") {
|
||||
name = `${instanceId}:local`;
|
||||
}
|
||||
if (styleSource?.type === "token") {
|
||||
name = `${instanceId}:token(${styleSource.name})`;
|
||||
}
|
||||
if (name) {
|
||||
const rule = sheet.addNestingRule(name);
|
||||
for (const styleDecl of data.styles.values()) {
|
||||
if (styleDecl.styleSourceId === styleSourceId) {
|
||||
rule.setDeclaration({
|
||||
breakpoint: styleDecl.breakpointId,
|
||||
selector: styleDecl.state ?? "",
|
||||
property: styleDecl.property,
|
||||
value: styleDecl.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sheet.cssText;
|
||||
};
|
||||
|
||||
test("Add Css Property styles", () => {
|
||||
const data = renderData(
|
||||
<ws.root ws:id={ROOT_INSTANCE_ID}>
|
||||
<$.Body>
|
||||
<$.Box ws:id="boxId">
|
||||
<$.Box
|
||||
ws:id="boxChildId"
|
||||
ws:style={css`
|
||||
color: red;
|
||||
`}
|
||||
></$.Box>
|
||||
</$.Box>
|
||||
</$.Body>
|
||||
</ws.root>
|
||||
);
|
||||
|
||||
setListedCssProperty(
|
||||
data.breakpoints,
|
||||
data.styleSources,
|
||||
data.styleSourceSelections,
|
||||
data.styles
|
||||
)("boxChildId", "viewTimelineName", {
|
||||
type: "unparsed",
|
||||
value: "--view-timeline-name-child",
|
||||
});
|
||||
|
||||
setListedCssProperty(
|
||||
data.breakpoints,
|
||||
data.styleSources,
|
||||
data.styleSourceSelections,
|
||||
data.styles
|
||||
)("boxId", "viewTimelineName", {
|
||||
type: "unparsed",
|
||||
value: "--view-timeline-name",
|
||||
});
|
||||
|
||||
expect(toCss(data)).toMatchInlineSnapshot(`
|
||||
"@media all {
|
||||
boxChildId:local {
|
||||
color: red;
|
||||
--view-timeline-name: --view-timeline-name-child;
|
||||
view-timeline-name: --view-timeline-name-child
|
||||
}
|
||||
boxId:local {
|
||||
--view-timeline-name: --view-timeline-name;
|
||||
view-timeline-name: --view-timeline-name
|
||||
}
|
||||
}"
|
||||
`);
|
||||
});
|
@ -0,0 +1,64 @@
|
||||
import type { StyleProperty, StyleValue } from "@webstudio-is/css-engine";
|
||||
import {
|
||||
getStyleDeclKey,
|
||||
type Breakpoints,
|
||||
type Instance,
|
||||
type Styles,
|
||||
type StyleSources,
|
||||
type StyleSourceSelections,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { isBaseBreakpoint } from "~/shared/nano-states";
|
||||
|
||||
export const setListedCssProperty =
|
||||
(
|
||||
breakpoints: Breakpoints,
|
||||
// Mutated
|
||||
styleSources: StyleSources,
|
||||
styleSourceSelections: StyleSourceSelections,
|
||||
styles: Styles
|
||||
) =>
|
||||
(instanceId: Instance["id"], property: StyleProperty, value: StyleValue) => {
|
||||
if (!styleSourceSelections.has(instanceId)) {
|
||||
const styleSourceId = nanoid();
|
||||
styleSources.set(styleSourceId, { type: "local", id: styleSourceId });
|
||||
|
||||
styleSourceSelections.set(instanceId, {
|
||||
instanceId,
|
||||
values: [styleSourceId],
|
||||
});
|
||||
}
|
||||
|
||||
const styleSourceSelection = styleSourceSelections.get(instanceId)!;
|
||||
|
||||
const localStyleSorceId = styleSourceSelection.values.find(
|
||||
(styleSourceId) => styleSources.get(styleSourceId)?.type === "local"
|
||||
);
|
||||
|
||||
if (localStyleSorceId === undefined) {
|
||||
throw new Error("Local style source not found");
|
||||
}
|
||||
|
||||
const baseBreakpoint = Array.from(breakpoints.values()).find(
|
||||
isBaseBreakpoint
|
||||
);
|
||||
|
||||
if (baseBreakpoint === undefined) {
|
||||
throw new Error("Base breakpoint not found");
|
||||
}
|
||||
|
||||
const styleKey = getStyleDeclKey({
|
||||
breakpointId: baseBreakpoint.id,
|
||||
property,
|
||||
styleSourceId: localStyleSorceId,
|
||||
});
|
||||
|
||||
styles.set(styleKey, {
|
||||
breakpointId: baseBreakpoint.id,
|
||||
property,
|
||||
styleSourceId: localStyleSorceId,
|
||||
value,
|
||||
listed: true,
|
||||
});
|
||||
};
|
@ -0,0 +1,158 @@
|
||||
import { Select, toast } from "@webstudio-is/design-system";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
$hoveredInstanceSelector,
|
||||
$instances,
|
||||
$registeredComponentMetas,
|
||||
$selectedInstanceSelector,
|
||||
} from "~/shared/nano-states";
|
||||
import { getInstanceStyleDecl } from "~/builder/features/style-panel/shared/model";
|
||||
import { getInstanceLabel } from "~/shared/instance-utils";
|
||||
import { toValue } from "@webstudio-is/css-engine";
|
||||
import type { AnimationAction } from "@webstudio-is/sdk";
|
||||
import { animationActionSchema } from "@webstudio-is/sdk";
|
||||
import { setListedCssProperty } from "./set-css-property";
|
||||
import { serverSyncStore } from "~/shared/sync";
|
||||
|
||||
import {
|
||||
$breakpoints,
|
||||
$styles,
|
||||
$styleSources,
|
||||
$styleSourceSelections,
|
||||
} from "~/shared/nano-states";
|
||||
|
||||
const initSubjects = () => {
|
||||
const selectedInstanceSelector = $selectedInstanceSelector.get();
|
||||
const instances = $instances.get();
|
||||
const metas = $registeredComponentMetas.get();
|
||||
|
||||
if (
|
||||
selectedInstanceSelector === undefined ||
|
||||
selectedInstanceSelector.length === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const subjects = [
|
||||
{
|
||||
value: "self",
|
||||
label: "Self",
|
||||
isTimelineExists: true,
|
||||
instanceId: selectedInstanceSelector.at(0)!,
|
||||
selector: selectedInstanceSelector,
|
||||
},
|
||||
];
|
||||
|
||||
for (
|
||||
let selector = selectedInstanceSelector.slice(1);
|
||||
selector.length !== 0;
|
||||
selector = selector.slice(1)
|
||||
) {
|
||||
const styleDecl = getInstanceStyleDecl("viewTimelineName", selector);
|
||||
const instanceId = selector.at(0)!;
|
||||
|
||||
const instance = instances.get(selector[0]);
|
||||
if (instance === undefined) {
|
||||
continue;
|
||||
}
|
||||
const meta = metas.get(instance.component);
|
||||
|
||||
if (meta === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const viewTimelineName = toValue(styleDecl.computedValue);
|
||||
const isTimelineExists = viewTimelineName.startsWith("--");
|
||||
const value = isTimelineExists
|
||||
? viewTimelineName
|
||||
: `--generated-timeline-${nanoid()}`;
|
||||
|
||||
subjects.push({
|
||||
value,
|
||||
label: getInstanceLabel(instance, meta),
|
||||
isTimelineExists,
|
||||
instanceId,
|
||||
selector,
|
||||
});
|
||||
}
|
||||
|
||||
return subjects;
|
||||
};
|
||||
|
||||
export const SubjectSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
}: {
|
||||
value: AnimationAction;
|
||||
onChange: (value: AnimationAction) => void;
|
||||
id: string;
|
||||
}) => {
|
||||
const [subjects] = useState(() => initSubjects());
|
||||
|
||||
if (value.type !== "view") {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
options={subjects.map((subject) => subject.value)}
|
||||
value={value.subject ?? "self"}
|
||||
getLabel={(subject) =>
|
||||
subjects.find((s) => s.value === subject)?.label ?? "-"
|
||||
}
|
||||
onItemHighlight={(subject) => {
|
||||
const selector =
|
||||
subjects.find((s) => s.value === subject)?.selector ?? undefined;
|
||||
$hoveredInstanceSelector.set(selector);
|
||||
}}
|
||||
onChange={(subject) => {
|
||||
const newValue = {
|
||||
...value,
|
||||
subject: subject === "self" ? undefined : subject,
|
||||
};
|
||||
const parsedValue = animationActionSchema.safeParse(newValue);
|
||||
|
||||
if (parsedValue.success) {
|
||||
const subjectItem = subjects.find((s) => s.value === subject);
|
||||
|
||||
if (subjectItem === undefined) {
|
||||
toast.error(`Subject "${newValue.subject}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
subjectItem.isTimelineExists === false &&
|
||||
newValue.subject !== undefined
|
||||
) {
|
||||
serverSyncStore.createTransaction(
|
||||
[$breakpoints, $styles, $styleSources, $styleSourceSelections],
|
||||
(breakpoints, styles, styleSources, styleSourceSelections) => {
|
||||
if (newValue.subject === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setListedCssProperty(
|
||||
breakpoints,
|
||||
styleSources,
|
||||
styleSourceSelections,
|
||||
styles
|
||||
)(subjectItem.instanceId, "viewTimelineName", {
|
||||
type: "unparsed",
|
||||
value: newValue.subject,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onChange(parsedValue.data);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Schemas are incompatible, try fix");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
@ -24,6 +24,7 @@ import { renderControl } from "../controls/combined";
|
||||
import { usePropsLogic, type PropAndMeta } from "./use-props-logic";
|
||||
import { serverSyncStore } from "~/shared/sync";
|
||||
import { $selectedInstanceKey } from "~/shared/awareness";
|
||||
import { AnimateSection } from "./animation/animation-section";
|
||||
|
||||
type Item = {
|
||||
name: string;
|
||||
@ -165,10 +166,29 @@ export const PropsSection = (props: PropsSectionProps) => {
|
||||
const hasItems =
|
||||
logic.addedProps.length > 0 || addingProp || logic.initialProps.length > 0;
|
||||
|
||||
const animationAction = logic.initialProps.find(
|
||||
(prop) => prop.meta.type === "animationAction"
|
||||
);
|
||||
|
||||
const hasAnimation = animationAction !== undefined;
|
||||
|
||||
const showPropertiesSection =
|
||||
isDesignMode || (isContentMode && logic.initialProps.length > 0);
|
||||
|
||||
return (
|
||||
return hasAnimation ? (
|
||||
<>
|
||||
<AnimateSection
|
||||
animationAction={animationAction}
|
||||
onChange={(value) =>
|
||||
logic.handleChangeByPropName(animationAction.propName, {
|
||||
type: "animationAction",
|
||||
value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Grid
|
||||
css={{
|
||||
|
@ -92,6 +92,13 @@ const getDefaultMetaForType = (type: Prop["type"]): PropMeta => {
|
||||
throw new Error(
|
||||
"A prop with type string[] must have a meta, we can't provide a default one because we need a list of options"
|
||||
);
|
||||
|
||||
case "animationAction":
|
||||
return {
|
||||
type: "animationAction",
|
||||
control: "animationAction",
|
||||
required: false,
|
||||
};
|
||||
case "json":
|
||||
throw new Error(
|
||||
"A prop with type json must have a meta, we can't provide a default one because we need a list of options"
|
||||
|
@ -49,7 +49,11 @@ export type PropValue =
|
||||
| { type: "expression"; value: string }
|
||||
| { type: "asset"; value: Asset["id"] }
|
||||
| { type: "page"; value: Extract<Prop, { type: "page" }>["value"] }
|
||||
| { type: "action"; value: Extract<Prop, { type: "action" }>["value"] };
|
||||
| { type: "action"; value: Extract<Prop, { type: "action" }>["value"] }
|
||||
| {
|
||||
type: "animationAction";
|
||||
value: Extract<Prop, { type: "animationAction" }>["value"];
|
||||
};
|
||||
|
||||
// Weird code is to make type distributive
|
||||
// https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
|
||||
|
@ -36,7 +36,7 @@ import {
|
||||
useMemo,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
import { useUnitSelect } from "./unit-select";
|
||||
import { useUnitSelect, type UnitOption } from "./unit-select";
|
||||
import { parseIntermediateOrInvalidValue } from "./parse-intermediate-or-invalid-value";
|
||||
import { toValue } from "@webstudio-is/css-engine";
|
||||
import {
|
||||
@ -58,6 +58,7 @@ import {
|
||||
isComplexValue,
|
||||
ValueEditorDialog,
|
||||
} from "./value-editor-dialog";
|
||||
import { useEffectEvent } from "~/shared/hook-utils/effect-event";
|
||||
|
||||
// We need to enable scrub on properties that can have numeric value.
|
||||
const canBeNumber = (property: StyleProperty, value: CssValueInputValue) => {
|
||||
@ -81,15 +82,21 @@ const scrubUnitAcceleration = new Map<Unit, number>([
|
||||
|
||||
const useScrub = ({
|
||||
value,
|
||||
intermediateValue,
|
||||
defaultUnit,
|
||||
property,
|
||||
onChange,
|
||||
onChangeComplete,
|
||||
onAbort,
|
||||
shouldHandleEvent,
|
||||
}: {
|
||||
defaultUnit: Unit | undefined;
|
||||
value: CssValueInputValue;
|
||||
intermediateValue: CssValueInputValue | undefined;
|
||||
property: StyleProperty;
|
||||
onChange: (value: CssValueInputValue) => void;
|
||||
onChange: (value: CssValueInputValue | undefined) => void;
|
||||
onChangeComplete: (value: StyleValue) => void;
|
||||
onAbort: () => void;
|
||||
shouldHandleEvent?: (node: Node) => boolean;
|
||||
}): [
|
||||
React.RefObject<HTMLDivElement | null>,
|
||||
@ -102,11 +109,19 @@ const useScrub = ({
|
||||
const onChangeCompleteRef = useRef(onChangeComplete);
|
||||
const valueRef = useRef(value);
|
||||
|
||||
const intermediateValueRef = useRef(intermediateValue);
|
||||
|
||||
onChangeCompleteRef.current = onChangeComplete;
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
valueRef.current = value;
|
||||
|
||||
const updateIntermediateValue = useEffectEvent(() => {
|
||||
intermediateValueRef.current = intermediateValue;
|
||||
});
|
||||
|
||||
const onAbortStable = useEffectEvent(onAbort);
|
||||
|
||||
// const type = valueRef.current.type;
|
||||
|
||||
// Since scrub is going to call onChange and onChangeComplete callbacks, it will result in a new value and potentially new callback refs.
|
||||
@ -124,7 +139,7 @@ const useScrub = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let unit: Unit = "number";
|
||||
let unit: Unit = defaultUnit ?? "number";
|
||||
|
||||
const validateValue = (numericValue: number) => {
|
||||
let value: CssValueInputValue = {
|
||||
@ -193,6 +208,20 @@ const useScrub = ({
|
||||
if (valueRef.current.type === "unit") {
|
||||
unit = valueRef.current.unit;
|
||||
}
|
||||
|
||||
updateIntermediateValue();
|
||||
},
|
||||
onAbort() {
|
||||
onAbortStable();
|
||||
// Returning focus that we've moved above
|
||||
scrubRef.current?.removeAttribute("tabindex");
|
||||
onChangeRef.current(intermediateValueRef.current);
|
||||
|
||||
// Otherwise selectionchange event can be triggered after 300-1000ms after focus
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
},
|
||||
onValueInput(event) {
|
||||
// Moving focus to container of the input to hide the caret
|
||||
@ -221,7 +250,13 @@ const useScrub = ({
|
||||
},
|
||||
shouldHandleEvent,
|
||||
});
|
||||
}, [shouldHandleEvent, property]);
|
||||
}, [
|
||||
shouldHandleEvent,
|
||||
property,
|
||||
updateIntermediateValue,
|
||||
onAbortStable,
|
||||
defaultUnit,
|
||||
]);
|
||||
|
||||
return [scrubRef, inputRef];
|
||||
};
|
||||
@ -273,7 +308,9 @@ type CssValueInputProps = Pick<
|
||||
/**
|
||||
* Selected item in the dropdown
|
||||
*/
|
||||
getOptions?: () => Array<KeywordValue | VarValue>;
|
||||
getOptions?: () => Array<
|
||||
KeywordValue | VarValue | (KeywordValue & { description?: string })
|
||||
>;
|
||||
onChange: (value: CssValueInputValue | undefined) => void;
|
||||
onChangeComplete: (event: ChangeCompleteEvent) => void;
|
||||
onHighlight: (value: StyleValue | undefined) => void;
|
||||
@ -283,6 +320,9 @@ type CssValueInputProps = Pick<
|
||||
onReset: () => void;
|
||||
icon?: ReactNode;
|
||||
showSuffix?: boolean;
|
||||
unitOptions?: UnitOption[];
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const initialValue: IntermediateStyleValue = {
|
||||
@ -429,6 +469,7 @@ const Description = styled(Box, { width: theme.spacing[27] });
|
||||
* - Evaluated math expression: "2px + 3em" (like CSS calc())
|
||||
*/
|
||||
export const CssValueInput = ({
|
||||
id,
|
||||
autoFocus,
|
||||
icon,
|
||||
prefix,
|
||||
@ -444,6 +485,8 @@ export const CssValueInput = ({
|
||||
fieldSizing,
|
||||
variant,
|
||||
text,
|
||||
unitOptions,
|
||||
placeholder,
|
||||
...props
|
||||
}: CssValueInputProps) => {
|
||||
const value = props.intermediateValue ?? props.value ?? initialValue;
|
||||
@ -455,6 +498,9 @@ export const CssValueInput = ({
|
||||
StyleValue | undefined
|
||||
>();
|
||||
|
||||
const defaultUnit =
|
||||
unitOptions?.[0]?.type === "unit" ? unitOptions[0].id : undefined;
|
||||
|
||||
const onChange = (input: string | undefined) => {
|
||||
if (input === undefined) {
|
||||
props.onChange(undefined);
|
||||
@ -500,7 +546,11 @@ export const CssValueInput = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedValue = parseIntermediateOrInvalidValue(property, value);
|
||||
const parsedValue = parseIntermediateOrInvalidValue(
|
||||
property,
|
||||
value,
|
||||
defaultUnit
|
||||
);
|
||||
|
||||
if (parsedValue.type === "invalid") {
|
||||
props.onChange(parsedValue);
|
||||
@ -527,6 +577,7 @@ export const CssValueInput = ({
|
||||
highlightedIndex,
|
||||
closeMenu,
|
||||
} = useCombobox<CssValueInputValue>({
|
||||
inputId: id,
|
||||
// Used for description to match the item when nothing is highlighted yet and value is still in non keyword mode
|
||||
getItems: getOptions,
|
||||
value,
|
||||
@ -555,6 +606,7 @@ export const CssValueInput = ({
|
||||
const inputProps = getInputProps();
|
||||
|
||||
const [isUnitsOpen, unitSelectElement] = useUnitSelect({
|
||||
options: unitOptions,
|
||||
property,
|
||||
value,
|
||||
onChange: (unitOrKeyword) => {
|
||||
@ -656,11 +708,14 @@ export const CssValueInput = ({
|
||||
}, []);
|
||||
|
||||
const [scrubRef, inputRef] = useScrub({
|
||||
defaultUnit,
|
||||
value,
|
||||
property,
|
||||
intermediateValue: props.intermediateValue,
|
||||
onChange: props.onChange,
|
||||
onChangeComplete: (value) => onChangeComplete({ value, type: "scrub-end" }),
|
||||
shouldHandleEvent,
|
||||
onAbort,
|
||||
});
|
||||
|
||||
const menuProps = getMenuProps();
|
||||
@ -734,10 +789,22 @@ export const CssValueInput = ({
|
||||
: undefined;
|
||||
|
||||
if (valueForDescription) {
|
||||
const option = getOptions().find(
|
||||
(item) =>
|
||||
item.type === "keyword" && item.value === valueForDescription.value
|
||||
);
|
||||
if (
|
||||
option !== undefined &&
|
||||
"description" in option &&
|
||||
option?.description
|
||||
) {
|
||||
description = option.description;
|
||||
} else {
|
||||
const key = `${property}:${toValue(
|
||||
valueForDescription
|
||||
)}` as keyof typeof declarationDescriptions;
|
||||
description = declarationDescriptions[key];
|
||||
}
|
||||
} else if (highlightedValue?.type === "var") {
|
||||
description = "CSS custom property (variable)";
|
||||
} else if (highlightedValue === undefined) {
|
||||
@ -905,10 +972,12 @@ export const CssValueInput = ({
|
||||
<Box {...getComboboxProps()}>
|
||||
<ComboboxAnchor asChild>
|
||||
<InputField
|
||||
id={id}
|
||||
variant={variant}
|
||||
disabled={disabled}
|
||||
aria-disabled={ariaDisabled}
|
||||
fieldSizing={fieldSizing}
|
||||
placeholder={placeholder}
|
||||
{...inputProps}
|
||||
{...autoScrollProps}
|
||||
value={getInputValue()}
|
||||
|
@ -2,17 +2,47 @@ import type {
|
||||
StyleProperty,
|
||||
StyleValue,
|
||||
InvalidValue,
|
||||
Unit,
|
||||
} from "@webstudio-is/css-engine";
|
||||
import { units, parseCssValue, cssTryParseValue } from "@webstudio-is/css-data";
|
||||
import {
|
||||
units,
|
||||
parseCssValue,
|
||||
cssTryParseValue,
|
||||
properties,
|
||||
} from "@webstudio-is/css-data";
|
||||
import type { IntermediateStyleValue } from "./css-value-input";
|
||||
import { evaluateMath } from "./evaluate-math";
|
||||
import { toKebabCase } from "../keyword-utils";
|
||||
|
||||
const unitsList = Object.values(units).flat();
|
||||
|
||||
const getDefaultUnit = (property: StyleProperty): Unit => {
|
||||
const unitGroups =
|
||||
properties[property as keyof typeof properties]?.unitGroups ?? [];
|
||||
|
||||
for (const unitGroup of unitGroups) {
|
||||
if (unitGroup === "number") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (unitGroup === "length") {
|
||||
return "px";
|
||||
}
|
||||
|
||||
return units[unitGroup][0]!;
|
||||
}
|
||||
|
||||
if (unitGroups.includes("number" as never)) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
return "px";
|
||||
};
|
||||
|
||||
export const parseIntermediateOrInvalidValue = (
|
||||
property: StyleProperty,
|
||||
styleValue: IntermediateStyleValue | InvalidValue,
|
||||
defaultUnit: Unit = getDefaultUnit(property),
|
||||
originalValue?: string
|
||||
): StyleValue => {
|
||||
let value = styleValue.value.trim();
|
||||
@ -40,7 +70,11 @@ export const parseIntermediateOrInvalidValue = (
|
||||
const unit = "unit" in styleValue ? styleValue.unit : undefined;
|
||||
|
||||
// Use number as a fallback for custom properties
|
||||
const fallbackUnitAsString = property.startsWith("--") ? "" : "px";
|
||||
const fallbackUnitAsString = property.startsWith("--")
|
||||
? ""
|
||||
: defaultUnit === "number"
|
||||
? ""
|
||||
: defaultUnit;
|
||||
|
||||
const testUnit = unit === "number" ? "" : (unit ?? fallbackUnitAsString);
|
||||
|
||||
@ -133,6 +167,7 @@ export const parseIntermediateOrInvalidValue = (
|
||||
...styleValue,
|
||||
value: value.replace(/,/g, "."),
|
||||
},
|
||||
defaultUnit,
|
||||
originalValue ?? value
|
||||
);
|
||||
}
|
||||
|
@ -41,8 +41,7 @@ describe("Parse intermediate or invalid value without math evaluation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("fallback to px", () => {
|
||||
for (const propery of properties) {
|
||||
test.each(properties)(`fallback to px for property = "%s"`, (propery) => {
|
||||
const result = parseIntermediateOrInvalidValue(propery, {
|
||||
type: "intermediate",
|
||||
value: "10",
|
||||
@ -53,7 +52,19 @@ describe("Parse intermediate or invalid value without math evaluation", () => {
|
||||
value: 10,
|
||||
unit: "px",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("fallback to % if px is not supported", () => {
|
||||
const result = parseIntermediateOrInvalidValue("fontStretch", {
|
||||
type: "intermediate",
|
||||
value: "10",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "unit",
|
||||
value: 10,
|
||||
unit: "%",
|
||||
});
|
||||
});
|
||||
|
||||
test("switch on new unit if previous not known", () => {
|
||||
|
@ -29,6 +29,7 @@ type UseUnitSelectType = {
|
||||
value: { type: "unit"; value: Unit } | { type: "keyword"; value: string }
|
||||
) => void;
|
||||
onCloseAutoFocus: (event: Event) => void;
|
||||
options?: UnitOption[];
|
||||
};
|
||||
|
||||
export const useUnitSelect = ({
|
||||
@ -36,12 +37,14 @@ export const useUnitSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
onCloseAutoFocus,
|
||||
options: unitOptions,
|
||||
}: UseUnitSelectType): [boolean, JSX.Element | undefined] => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() => buildOptions(property, value, nestedSelectButtonUnitless),
|
||||
[property, value]
|
||||
() =>
|
||||
unitOptions ?? buildOptions(property, value, nestedSelectButtonUnitless),
|
||||
[property, value, unitOptions]
|
||||
);
|
||||
|
||||
const unit =
|
||||
|
@ -40,6 +40,7 @@ import {
|
||||
$selectedInstancePathWithRoot,
|
||||
type InstancePath,
|
||||
} from "~/shared/awareness";
|
||||
import type { InstanceSelector } from "~/shared/tree-utils";
|
||||
|
||||
const $presetStyles = computed($registeredComponentMetas, (metas) => {
|
||||
const presetStyles = new Map<string, StyleValue>();
|
||||
@ -376,6 +377,17 @@ export const useParentComputedStyleDecl = (property: StyleProperty) => {
|
||||
return useStore($store);
|
||||
};
|
||||
|
||||
export const getInstanceStyleDecl = (
|
||||
property: StyleProperty,
|
||||
instanceSelector: InstanceSelector
|
||||
) => {
|
||||
return getComputedStyleDecl({
|
||||
model: $model.get(),
|
||||
instanceSelector,
|
||||
property,
|
||||
});
|
||||
};
|
||||
|
||||
export const useComputedStyles = (properties: StyleProperty[]) => {
|
||||
// cache each computed style store
|
||||
const cachedStores = useRef(
|
||||
|
@ -12,7 +12,12 @@ import {
|
||||
highlightSpecialChars,
|
||||
highlightActiveLine,
|
||||
} from "@codemirror/view";
|
||||
import { bracketMatching, indentOnInput } from "@codemirror/language";
|
||||
import {
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
LanguageSupport,
|
||||
LRLanguage,
|
||||
} from "@codemirror/language";
|
||||
import {
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
@ -30,12 +35,20 @@ import {
|
||||
foldGutterExtension,
|
||||
getMinMaxHeightVars,
|
||||
} from "./code-editor-base";
|
||||
import { cssCompletionSource, cssLanguage } from "@codemirror/lang-css";
|
||||
|
||||
const wrapperStyle = css({
|
||||
position: "relative",
|
||||
// 1 line is 16px
|
||||
// set min 10 lines and max 20 lines
|
||||
...getMinMaxHeightVars({ minHeight: "160px", maxHeight: "320px" }),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
default: getMinMaxHeightVars({ minHeight: "160px", maxHeight: "320px" }),
|
||||
keyframe: getMinMaxHeightVars({ minHeight: "60px", maxHeight: "120px" }),
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const getHtmlExtensions = () => [
|
||||
@ -80,20 +93,55 @@ const getMarkdownExtensions = () => [
|
||||
keymap.of(closeBracketsKeymap),
|
||||
];
|
||||
|
||||
const cssPropertiesLanguage = LRLanguage.define({
|
||||
name: "css",
|
||||
parser: cssLanguage.configure({ top: "Styles" }).parser,
|
||||
});
|
||||
const cssProperties = new LanguageSupport(
|
||||
cssPropertiesLanguage,
|
||||
cssPropertiesLanguage.data.of({
|
||||
autocomplete: cssCompletionSource,
|
||||
})
|
||||
);
|
||||
|
||||
const getCssPropertiesExtensions = () => [
|
||||
highlightActiveLine(),
|
||||
highlightSpecialChars(),
|
||||
indentOnInput(),
|
||||
cssProperties,
|
||||
// render autocomplete in body
|
||||
// to prevent popover scroll overflow
|
||||
tooltips({ parent: document.body }),
|
||||
autocompletion({ icons: false }),
|
||||
];
|
||||
|
||||
export const CodeEditor = forwardRef<
|
||||
HTMLDivElement,
|
||||
Omit<ComponentProps<typeof EditorContent>, "extensions"> & {
|
||||
lang?: "html" | "markdown";
|
||||
lang?: "html" | "markdown" | "css-properties";
|
||||
title?: ReactNode;
|
||||
size?: "default" | "keyframe";
|
||||
}
|
||||
>(({ lang, title, ...editorContentProps }, ref) => {
|
||||
>(({ lang, title, size, ...editorContentProps }, ref) => {
|
||||
const extensions = useMemo(() => {
|
||||
if (lang === "html") {
|
||||
return getHtmlExtensions();
|
||||
}
|
||||
|
||||
if (lang === "markdown") {
|
||||
return getMarkdownExtensions();
|
||||
}
|
||||
|
||||
if (lang === "css-properties") {
|
||||
return getCssPropertiesExtensions();
|
||||
}
|
||||
|
||||
if (lang === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
lang satisfies never;
|
||||
|
||||
return [];
|
||||
}, [lang]);
|
||||
|
||||
@ -120,7 +168,7 @@ export const CodeEditor = forwardRef<
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div className={wrapperStyle()} ref={ref}>
|
||||
<div className={wrapperStyle({ size })} ref={ref}>
|
||||
<EditorDialogControl>
|
||||
<EditorContent {...editorContentProps} extensions={extensions} />
|
||||
<EditorDialog
|
||||
|
@ -1,92 +0,0 @@
|
||||
import { Scroll } from "@webstudio-is/sdk-components-animation";
|
||||
import { parseCssValue } from "@webstudio-is/css-data";
|
||||
import { Box, styled } from "@webstudio-is/design-system";
|
||||
|
||||
const H1 = styled("h1");
|
||||
|
||||
const DEBUG = false;
|
||||
|
||||
const Playground = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Scroll
|
||||
debug={DEBUG}
|
||||
action={{
|
||||
type: "scroll",
|
||||
animations: [
|
||||
{
|
||||
timing: {
|
||||
fill: "backwards",
|
||||
rangeStart: ["start", { type: "unit", value: 0, unit: "%" }],
|
||||
rangeEnd: ["start", { type: "unit", value: 200, unit: "px" }],
|
||||
},
|
||||
keyframes: [
|
||||
{
|
||||
offset: 0,
|
||||
styles: {
|
||||
transform: parseCssValue(
|
||||
"transform",
|
||||
"translate(0, -120px)"
|
||||
),
|
||||
opacity: parseCssValue("opacity", "0.2"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timing: {
|
||||
fill: "forwards",
|
||||
rangeStart: ["end", { type: "unit", value: 200, unit: "px" }],
|
||||
rangeEnd: ["end", { type: "unit", value: 0, unit: "%" }],
|
||||
},
|
||||
keyframes: [
|
||||
{
|
||||
offset: 1,
|
||||
styles: {
|
||||
transform: parseCssValue(
|
||||
"transform",
|
||||
"translate(0, 120px)"
|
||||
),
|
||||
opacity: parseCssValue("opacity", "0.0"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<H1
|
||||
css={{
|
||||
position: "fixed",
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
top: "80px",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
"&:hover": {
|
||||
color: "red",
|
||||
},
|
||||
}}
|
||||
>
|
||||
HELLO WORLD
|
||||
</H1>
|
||||
</Scroll>
|
||||
<Box css={{ height: "200px", backgroundColor: "#eee", p: 4 }}>
|
||||
Start scrolling, and when the current box scrolls out, the “HELLO WORLD”
|
||||
header will fly in and become hoverable. (During the animation, it won’t
|
||||
be hoverable.)
|
||||
</Box>
|
||||
<Box css={{ height: "200vh" }}></Box>
|
||||
<Box css={{ height: "200px", backgroundColor: "#eee", p: 4 }}>
|
||||
When you see this box, the “HELLO WORLD” header will fly out.
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playground;
|
||||
|
||||
// Reduces Vercel function size from 29MB to 9MB for unknown reasons; effective when used in limited files.
|
||||
export const config = {
|
||||
maxDuration: 30,
|
||||
};
|
@ -62,10 +62,25 @@ export const $propsIndex = computed($props, (props) => {
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* $styles contains actual styling rules
|
||||
* (breakpointId, styleSourceId, property, value, listed), tied to styleSourceIds
|
||||
* $styles.styleSourceId -> $styleSources.id
|
||||
*/
|
||||
export const $styles = atom<Styles>(new Map());
|
||||
|
||||
/**
|
||||
* styleSources defines where styles come from (local or token).
|
||||
*
|
||||
* $styles contains actual styling rules, tied to styleSourceIds.
|
||||
* $styles.styleSourceId -> $styleSources.id
|
||||
*/
|
||||
export const $styleSources = atom<StyleSources>(new Map());
|
||||
|
||||
/**
|
||||
* This is a list of connections between instances (instanceIds) and styleSources.
|
||||
* $styleSourceSelections.values[] -> $styleSources.id[]
|
||||
*/
|
||||
export const $styleSourceSelections = atom<StyleSourceSelections>(new Map());
|
||||
|
||||
export type StyleSourceSelector = {
|
||||
|
@ -129,6 +129,7 @@
|
||||
"@types/react": "^18.2.70",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@webstudio-is/tsconfig": "workspace:*",
|
||||
"fast-glob": "^3.3.2",
|
||||
"html-tags": "^4.0.0",
|
||||
"react-router-dom": "^6.28.1",
|
||||
"react-test-renderer": "18.3.0-canary-14898b6a9-20240318",
|
||||
|
@ -9,20 +9,17 @@ import {
|
||||
getAuthorizationServerOrigin,
|
||||
isBuilderUrl,
|
||||
} from "./app/shared/router-utils/origins";
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import fg from "fast-glob";
|
||||
|
||||
const isFolderEmpty = (folderPath: string) => {
|
||||
if (!existsSync(folderPath)) {
|
||||
return true; // Folder does not exist
|
||||
}
|
||||
const contents = readdirSync(folderPath);
|
||||
const rootDir = ["..", "../..", "../../.."]
|
||||
.map((dir) => path.join(__dirname, dir))
|
||||
.find((dir) => existsSync(path.join(dir, ".git")));
|
||||
|
||||
return contents.length === 0;
|
||||
};
|
||||
|
||||
const hasPrivateFolders = !isFolderEmpty(
|
||||
path.join(__dirname, "../../packages/sdk-components-animation/private-src")
|
||||
);
|
||||
const hasPrivateFolders =
|
||||
fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], {
|
||||
ignore: ["**/node_modules/**"],
|
||||
}).length > 0;
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
if (mode === "test") {
|
||||
|
@ -8,5 +8,8 @@
|
||||
"@webstudio-is/sdk-components-react-radix": "workspace:*",
|
||||
"@webstudio-is/sdk-components-react-router": "workspace:*",
|
||||
"webstudio": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"fast-glob": "^3.3.2"
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,33 @@ import { defineConfig } from "vite";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
// @ts-ignore
|
||||
import { dedupeMeta } from "./proxy-emulator/dedupe-meta";
|
||||
import { existsSync, readdirSync } from "fs";
|
||||
// @ts-ignore
|
||||
import path from "path";
|
||||
// @ts-ignore
|
||||
import fg from "fast-glob";
|
||||
|
||||
const rootDir = ["..", "../..", "../../.."]
|
||||
.map((dir) => path.join(__dirname, dir))
|
||||
.find((dir) => existsSync(path.join(dir, ".git")));
|
||||
|
||||
const hasPrivateFolders =
|
||||
fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], {
|
||||
ignore: ["**/node_modules/**"],
|
||||
}).length > 0;
|
||||
|
||||
const conditions = hasPrivateFolders
|
||||
? ["webstudio-private", "webstudio"]
|
||||
: ["webstudio"];
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions,
|
||||
},
|
||||
ssr: {
|
||||
resolve: {
|
||||
conditions,
|
||||
},
|
||||
},
|
||||
plugins: [reactRouter(), dedupeMeta],
|
||||
});
|
||||
|
@ -34,7 +34,8 @@
|
||||
"webstudio": "workspace:*",
|
||||
"@types/react": "^18.2.70",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"typescript": "5.7.3"
|
||||
"typescript": "5.7.3",
|
||||
"fast-glob": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
@ -3,7 +3,33 @@ import { defineConfig } from "vite";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
// @ts-ignore
|
||||
import { dedupeMeta } from "./proxy-emulator/dedupe-meta";
|
||||
import { existsSync, readdirSync } from "fs";
|
||||
// @ts-ignore
|
||||
import path from "path";
|
||||
// @ts-ignore
|
||||
import fg from "fast-glob";
|
||||
|
||||
const rootDir = ["..", "../..", "../../.."]
|
||||
.map((dir) => path.join(__dirname, dir))
|
||||
.find((dir) => existsSync(path.join(dir, ".git")));
|
||||
|
||||
const hasPrivateFolders =
|
||||
fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], {
|
||||
ignore: ["**/node_modules/**"],
|
||||
}).length > 0;
|
||||
|
||||
const conditions = hasPrivateFolders
|
||||
? ["webstudio-private", "webstudio"]
|
||||
: ["webstudio"];
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions,
|
||||
},
|
||||
ssr: {
|
||||
resolve: {
|
||||
conditions,
|
||||
},
|
||||
},
|
||||
plugins: [reactRouter(), dedupeMeta],
|
||||
});
|
||||
|
18
packages/css-data/src/__generated__/properties.ts
generated
18
packages/css-data/src/__generated__/properties.ts
generated
@ -31,8 +31,8 @@ export const properties = {
|
||||
unitGroups: [],
|
||||
inherited: false,
|
||||
initial: {
|
||||
type: "unparsed",
|
||||
value: "--view-timeline",
|
||||
type: "keyword",
|
||||
value: "none",
|
||||
},
|
||||
mdnUrl:
|
||||
"https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name",
|
||||
@ -41,12 +41,22 @@ export const properties = {
|
||||
unitGroups: [],
|
||||
inherited: false,
|
||||
initial: {
|
||||
type: "unparsed",
|
||||
value: "--scroll-timeline",
|
||||
type: "keyword",
|
||||
value: "none",
|
||||
},
|
||||
mdnUrl:
|
||||
"https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-timeline-name",
|
||||
},
|
||||
viewTimelineInset: {
|
||||
unitGroups: ["length", "percentage"],
|
||||
inherited: false,
|
||||
initial: {
|
||||
type: "keyword",
|
||||
value: "auto",
|
||||
},
|
||||
mdnUrl:
|
||||
"https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-inset",
|
||||
},
|
||||
"-webkit-line-clamp": {
|
||||
unitGroups: ["number"],
|
||||
inherited: false,
|
||||
|
@ -80,8 +80,8 @@ propertiesData.viewTimelineName = {
|
||||
unitGroups: [],
|
||||
inherited: false,
|
||||
initial: {
|
||||
type: "unparsed",
|
||||
value: "--view-timeline",
|
||||
type: "keyword",
|
||||
value: "none",
|
||||
},
|
||||
mdnUrl: "https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name",
|
||||
};
|
||||
@ -89,13 +89,24 @@ propertiesData.scrollTimelineName = {
|
||||
unitGroups: [],
|
||||
inherited: false,
|
||||
initial: {
|
||||
type: "unparsed",
|
||||
value: "--scroll-timeline",
|
||||
type: "keyword",
|
||||
value: "none",
|
||||
},
|
||||
mdnUrl:
|
||||
"https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-timeline-name",
|
||||
};
|
||||
|
||||
propertiesData.viewTimelineInset = {
|
||||
unitGroups: ["length", "percentage"],
|
||||
inherited: false,
|
||||
initial: {
|
||||
type: "keyword",
|
||||
value: "auto",
|
||||
},
|
||||
mdnUrl:
|
||||
"https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-inset",
|
||||
};
|
||||
|
||||
keywordValues.listStyleType = [
|
||||
"disc",
|
||||
"circle",
|
||||
|
2
packages/css-engine/src/__generated__/types.ts
generated
2
packages/css-engine/src/__generated__/types.ts
generated
@ -4,6 +4,7 @@ export type CamelCasedProperty =
|
||||
| "-webkit-box-orient"
|
||||
| "viewTimelineName"
|
||||
| "scrollTimelineName"
|
||||
| "viewTimelineInset"
|
||||
| "-webkit-line-clamp"
|
||||
| "-webkit-overflow-scrolling"
|
||||
| "-webkit-tap-highlight-color"
|
||||
@ -343,6 +344,7 @@ export type HyphenatedProperty =
|
||||
| "-webkit-box-orient"
|
||||
| "view-timeline-name"
|
||||
| "scroll-timeline-name"
|
||||
| "view-timeline-inset"
|
||||
| "-webkit-line-clamp"
|
||||
| "-webkit-overflow-scrolling"
|
||||
| "-webkit-tap-highlight-color"
|
||||
|
@ -31,7 +31,8 @@ export const prefixStyles = (styleMap: StyleMap) => {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name
|
||||
if (
|
||||
property === "view-timeline-name" ||
|
||||
property === "scroll-timeline-name"
|
||||
property === "scroll-timeline-name" ||
|
||||
property === "view-timeline-inset"
|
||||
) {
|
||||
newStyleMap.set(`--${property}`, value);
|
||||
}
|
||||
|
@ -281,7 +281,6 @@ export const useCombobox = <Item,>({
|
||||
defaultHighlightedIndex,
|
||||
selectedItem: selectedItem ?? null, // Prevent downshift warning about switching controlled mode
|
||||
isOpen,
|
||||
|
||||
onIsOpenChange(state) {
|
||||
const { type, isOpen, inputValue } = state;
|
||||
|
||||
|
@ -167,6 +167,13 @@ export const FloatingPanel = ({
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
}}
|
||||
ref={setContentElement}
|
||||
>
|
||||
{content}
|
||||
|
@ -247,8 +247,17 @@ export const InputField = forwardRef(
|
||||
});
|
||||
const unfocusContainerRef = useRef<HTMLDivElement>(null);
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||
// If Radix is preventing the Escape key from closing the dialog,
|
||||
// it intercepts the key event at the document level.
|
||||
// However, we still want to allow the user to unfocus the input field.
|
||||
// This means we should not check `defaultPrevented`, but only verify
|
||||
// if our event handler explicitly prevented it.
|
||||
const isPreventedBefore = event.defaultPrevented;
|
||||
onKeyDown?.(event);
|
||||
if (event.key === "Escape" && event.defaultPrevented === false) {
|
||||
const isPreventedAfter = event.defaultPrevented;
|
||||
const isEventPrevented = !isPreventedBefore && isPreventedAfter;
|
||||
|
||||
if (event.key === "Escape" && !isEventPrevented) {
|
||||
event.preventDefault();
|
||||
unfocusContainerRef.current?.focus();
|
||||
}
|
||||
|
@ -72,6 +72,7 @@ export type NumericScrubOptions = {
|
||||
direction?: NumericScrubDirection;
|
||||
onValueInput?: NumericScrubCallback;
|
||||
onValueChange?: NumericScrubCallback;
|
||||
onAbort?: () => void;
|
||||
onStatusChange?: (status: "idle" | "scrubbing") => void;
|
||||
shouldHandleEvent?: (node: Node) => boolean;
|
||||
};
|
||||
@ -168,6 +169,7 @@ export const numericScrubControl = (
|
||||
distanceThreshold = 0,
|
||||
onValueInput,
|
||||
onValueChange,
|
||||
onAbort,
|
||||
onStatusChange,
|
||||
shouldHandleEvent,
|
||||
} = options;
|
||||
@ -209,7 +211,9 @@ export const numericScrubControl = (
|
||||
// Called on ESC key press or in cases of third-party pointer lock exit.
|
||||
const handlePointerLockChange = () => {
|
||||
if (document.pointerLockElement !== targetNode) {
|
||||
// Reset the value to the initial value
|
||||
cleanup();
|
||||
onAbort?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -396,7 +400,7 @@ export const numericScrubControl = (
|
||||
const eventNames = [
|
||||
"pointerup",
|
||||
"pointerdown",
|
||||
"pontercancel",
|
||||
"pointercancel",
|
||||
"lostpointercapture",
|
||||
] as const;
|
||||
eventNames.forEach((eventName) =>
|
||||
|
@ -30,7 +30,8 @@ const switchStyle = css({
|
||||
backgroundColor: theme.colors.backgroundNeutralDark,
|
||||
},
|
||||
|
||||
"&[data-state=checked]:not([data-disabled]):before": {
|
||||
"&[data-state=checked]:not([data-disabled]):before, &[aria-checked=true]:not([data-disabled]):before":
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
},
|
||||
|
||||
|
@ -9,3 +9,4 @@ export const contentEditableMode = false;
|
||||
export const command = false;
|
||||
export const headSlotComponent = false;
|
||||
export const stylePanelAdvancedMode = false;
|
||||
export const animation = false;
|
||||
|
@ -104,7 +104,8 @@ const generatePropValue = ({
|
||||
prop.type === "number" ||
|
||||
prop.type === "boolean" ||
|
||||
prop.type === "string[]" ||
|
||||
prop.type === "json"
|
||||
prop.type === "json" ||
|
||||
prop.type === "animationAction"
|
||||
) {
|
||||
return JSON.stringify(prop.value);
|
||||
}
|
||||
|
@ -58,6 +58,7 @@
|
||||
"@webstudio-is/icons": "workspace:*",
|
||||
"@webstudio-is/react-sdk": "workspace:*",
|
||||
"@webstudio-is/sdk": "workspace:*",
|
||||
"change-case": "^5.4.4",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"scroll-timeline-polyfill": "^1.1.0"
|
||||
},
|
||||
@ -72,10 +73,12 @@
|
||||
"@webstudio-is/sdk-components-react": "workspace:*",
|
||||
"@webstudio-is/template": "workspace:*",
|
||||
"@webstudio-is/tsconfig": "workspace:*",
|
||||
"fast-glob": "^3.3.2",
|
||||
"playwright": "^1.50.1",
|
||||
"react": "18.3.0-canary-14898b6a9-20240318",
|
||||
"react-dom": "18.3.0-canary-14898b6a9-20240318",
|
||||
"type-fest": "^4.32.0",
|
||||
"vitest": "^3.0.4"
|
||||
"vitest": "^3.0.4",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
|
Submodule packages/sdk-components-animation/private-src updated: b977781462...4ea51f4ca0
48
packages/sdk-components-animation/src/animate-children.tsx
Normal file
48
packages/sdk-components-animation/src/animate-children.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import type { Hook } from "@webstudio-is/react-sdk";
|
||||
import type { AnimationAction } from "@webstudio-is/sdk";
|
||||
import { forwardRef, type ElementRef } from "react";
|
||||
import { animationCanPlayOnCanvasProperty } from "./shared/consts";
|
||||
|
||||
type ScrollProps = {
|
||||
debug?: boolean;
|
||||
children?: React.ReactNode;
|
||||
action: AnimationAction;
|
||||
};
|
||||
|
||||
export const AnimateChildren = forwardRef<ElementRef<"div">, ScrollProps>(
|
||||
({ debug = false, action, ...props }, ref) => {
|
||||
return <div ref={ref} style={{ display: "contents" }} {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
const displayName = "AnimateChildren";
|
||||
AnimateChildren.displayName = displayName;
|
||||
|
||||
const namespace = "@webstudio-is/sdk-components-animation";
|
||||
|
||||
export const hooksAnimateChildren: Hook = {
|
||||
onNavigatorUnselect: (context, event) => {
|
||||
if (
|
||||
event.instancePath.length > 0 &&
|
||||
event.instancePath[0].component === `${namespace}:${displayName}`
|
||||
) {
|
||||
context.setMemoryProp(
|
||||
event.instancePath[0],
|
||||
animationCanPlayOnCanvasProperty,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
},
|
||||
onNavigatorSelect: (context, event) => {
|
||||
if (
|
||||
event.instancePath.length > 0 &&
|
||||
event.instancePath[0].component === `${namespace}:${displayName}`
|
||||
) {
|
||||
context.setMemoryProp(
|
||||
event.instancePath[0],
|
||||
animationCanPlayOnCanvasProperty,
|
||||
true
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
@ -1 +1 @@
|
||||
export { Scroll } from "./scroll";
|
||||
export { AnimateChildren } from "./animate-children";
|
||||
|
@ -1,3 +1,4 @@
|
||||
import type { Hook } from "@webstudio-is/react-sdk";
|
||||
import { hooksAnimateChildren } from "./animate-children";
|
||||
|
||||
export const hooks: Hook[] = [];
|
||||
export const hooks: Hook[] = [hooksAnimateChildren];
|
||||
|
@ -1 +1 @@
|
||||
export {};
|
||||
export { meta as AnimateChildren } from "./scroll.ws";
|
||||
|
@ -1 +1 @@
|
||||
export {};
|
||||
export { propsMeta as AnimateChildren } from "./scroll.ws";
|
||||
|
@ -1,14 +0,0 @@
|
||||
import { forwardRef, type ElementRef } from "react";
|
||||
import type { AnimationAction } from "./shared/animation-types";
|
||||
|
||||
type ScrollProps = {
|
||||
debug?: boolean;
|
||||
children?: React.ReactNode;
|
||||
action: AnimationAction;
|
||||
};
|
||||
|
||||
export const Scroll = forwardRef<ElementRef<"div">, ScrollProps>(
|
||||
({ debug = false, action, ...props }, ref) => {
|
||||
return <div ref={ref} style={{ display: "contents" }} {...props} />;
|
||||
}
|
||||
);
|
23
packages/sdk-components-animation/src/scroll.ws.ts
Normal file
23
packages/sdk-components-animation/src/scroll.ws.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { SlotComponentIcon } from "@webstudio-is/icons/svg";
|
||||
import type { WsComponentMeta, WsComponentPropsMeta } from "@webstudio-is/sdk";
|
||||
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "general",
|
||||
type: "container",
|
||||
description: "Animate Children",
|
||||
icon: SlotComponentIcon,
|
||||
order: 5,
|
||||
label: "Animate Children",
|
||||
};
|
||||
|
||||
export const propsMeta: WsComponentPropsMeta = {
|
||||
props: {
|
||||
action: {
|
||||
required: false,
|
||||
control: "animationAction",
|
||||
type: "animationAction",
|
||||
description: "Animation Action",
|
||||
},
|
||||
},
|
||||
initialProps: ["action"],
|
||||
};
|
@ -1,140 +0,0 @@
|
||||
import type { StyleValue, UnitValue } from "@webstudio-is/css-engine";
|
||||
|
||||
export type KeyframeStyles = { [property: string]: StyleValue | undefined };
|
||||
|
||||
export type AnimationKeyframe = {
|
||||
offset: number | undefined;
|
||||
// We are using composite: auto as the default value for now
|
||||
// composite?: CompositeOperationOrAuto;
|
||||
styles: KeyframeStyles;
|
||||
};
|
||||
|
||||
const RANGE_UNITS = [
|
||||
"%",
|
||||
"px",
|
||||
// Does not supported by polyfill and we are converting it to px ourselfs
|
||||
"cm",
|
||||
"mm",
|
||||
"q",
|
||||
"in",
|
||||
"pt",
|
||||
"pc",
|
||||
"em",
|
||||
"rem",
|
||||
"ex",
|
||||
"rex",
|
||||
"cap",
|
||||
"rcap",
|
||||
"ch",
|
||||
"rch",
|
||||
"lh",
|
||||
"rlh",
|
||||
"vw",
|
||||
"svw",
|
||||
"lvw",
|
||||
"dvw",
|
||||
"vh",
|
||||
"svh",
|
||||
"lvh",
|
||||
"dvh",
|
||||
"vi",
|
||||
"svi",
|
||||
"lvi",
|
||||
"dvi",
|
||||
"vb",
|
||||
"svb",
|
||||
"lvb",
|
||||
"dvb",
|
||||
"vmin",
|
||||
"svmin",
|
||||
"lvmin",
|
||||
"dvmin",
|
||||
"vmax",
|
||||
"svmax",
|
||||
"lvmax",
|
||||
"dvmax",
|
||||
] as const;
|
||||
|
||||
export type RangeUnit = (typeof RANGE_UNITS)[number];
|
||||
|
||||
export const isRangeUnit = (value: unknown): value is RangeUnit =>
|
||||
RANGE_UNITS.includes(value as RangeUnit);
|
||||
|
||||
export type RangeUnitValue = { type: "unit"; value: number; unit: RangeUnit };
|
||||
|
||||
({}) as RangeUnitValue satisfies UnitValue;
|
||||
|
||||
type KeyframeEffectOptions = {
|
||||
easing?: string;
|
||||
fill?: FillMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll does not support https://drafts.csswg.org/scroll-animations/#named-ranges
|
||||
* However, for simplicity and type unification with the view, we will use the names "start" and "end,"
|
||||
* which will be transformed as follows:
|
||||
* - "start" → `calc(0% + range)`
|
||||
* - "end" → `calc(100% - range)`
|
||||
*/
|
||||
export type ScrollNamedRange = "start" | "end";
|
||||
|
||||
/**
|
||||
* Scroll does not support https://drafts.csswg.org/scroll-animations/#named-ranges
|
||||
* However, for simplicity and type unification with the view, we will use the names "start" and "end,"
|
||||
* See ScrollNamedRange type for more information.
|
||||
*/
|
||||
export type ScrollRangeValue = [name: ScrollNamedRange, value: RangeUnitValue];
|
||||
|
||||
type ScrollRangeOptions = {
|
||||
rangeStart?: ScrollRangeValue | undefined;
|
||||
rangeEnd?: ScrollRangeValue | undefined;
|
||||
};
|
||||
|
||||
/*
|
||||
type AnimationTiming = {
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
easing?: string;
|
||||
fill?: FillMode;
|
||||
};
|
||||
*/
|
||||
|
||||
type AnimationAxis = "block" | "inline" | "x" | "y";
|
||||
|
||||
type ScrollAction = {
|
||||
type: "scroll";
|
||||
source?: "closest" | "nearest" | "root";
|
||||
axis?: AnimationAxis;
|
||||
animations: {
|
||||
timing: KeyframeEffectOptions & ScrollRangeOptions;
|
||||
keyframes: AnimationKeyframe[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ViewNamedRange =
|
||||
| "contain"
|
||||
| "cover"
|
||||
| "entry"
|
||||
| "exit"
|
||||
| "entry-crossing"
|
||||
| "exit-crossing";
|
||||
|
||||
export type ViewRangeValue = [name: ViewNamedRange, value: RangeUnitValue];
|
||||
|
||||
type ViewRangeOptions = {
|
||||
rangeStart?: ViewRangeValue | undefined;
|
||||
rangeEnd?: ViewRangeValue | undefined;
|
||||
};
|
||||
|
||||
type ViewAction = {
|
||||
type: "view";
|
||||
subject?: string;
|
||||
|
||||
axis?: AnimationAxis;
|
||||
animations: {
|
||||
timing: KeyframeEffectOptions & ViewRangeOptions;
|
||||
keyframes: AnimationKeyframe[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type AnimationAction = ScrollAction | ViewAction;
|
2
packages/sdk-components-animation/src/shared/consts.ts
Normal file
2
packages/sdk-components-animation/src/shared/consts.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const animationCanPlayOnCanvasProperty =
|
||||
"data-ws-animation-can-play-on-canvas";
|
@ -1,6 +1,11 @@
|
||||
{
|
||||
"extends": "@webstudio-is/tsconfig/base.json",
|
||||
"include": ["src", "../../@types/**/scroll-timeline.d.ts", "private-src"],
|
||||
"include": [
|
||||
"src",
|
||||
"../../@types/**/scroll-timeline.d.ts",
|
||||
"private-src",
|
||||
"../sdk/src/schema/animation-schema.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"types": ["react/experimental", "react-dom/experimental", "@types/node"]
|
||||
}
|
||||
|
@ -1,6 +1,30 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import fg from "fast-glob";
|
||||
|
||||
const rootDir = ["..", "../..", "../../.."]
|
||||
.map((dir) => path.join(__dirname, dir))
|
||||
.find((dir) => existsSync(path.join(dir, ".git")));
|
||||
|
||||
const hasPrivateFolders =
|
||||
fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], {
|
||||
ignore: ["**/node_modules/**"],
|
||||
}).length > 0;
|
||||
|
||||
const conditions = hasPrivateFolders
|
||||
? ["webstudio-private", "webstudio"]
|
||||
: ["webstudio"];
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions,
|
||||
},
|
||||
ssr: {
|
||||
resolve: {
|
||||
conditions,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
workspace: [
|
||||
|
@ -23,3 +23,30 @@ export * from "./resources-generator";
|
||||
export * from "./page-meta-generator";
|
||||
export * from "./url-pattern";
|
||||
export * from "./css";
|
||||
|
||||
export type {
|
||||
AnimationAction,
|
||||
AnimationActionScroll,
|
||||
AnimationActionView,
|
||||
AnimationKeyframe,
|
||||
KeyframeStyles,
|
||||
RangeUnit,
|
||||
RangeUnitValue,
|
||||
ScrollNamedRange,
|
||||
ScrollRangeValue,
|
||||
ViewNamedRange,
|
||||
ViewRangeValue,
|
||||
ScrollAnimation,
|
||||
ViewAnimation,
|
||||
InsetUnitValue,
|
||||
} from "./schema/animation-schema";
|
||||
|
||||
export {
|
||||
animationActionSchema,
|
||||
scrollAnimationSchema,
|
||||
viewAnimationSchema,
|
||||
rangeUnitValueSchema,
|
||||
animationKeyframeSchema,
|
||||
insetUnitValueSchema,
|
||||
RANGE_UNITS,
|
||||
} from "./schema/animation-schema";
|
||||
|
222
packages/sdk/src/schema/animation-schema.ts
Normal file
222
packages/sdk/src/schema/animation-schema.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { StyleValue } from "@webstudio-is/css-engine";
|
||||
import { z } from "zod";
|
||||
|
||||
// Helper for creating union of string literals from array
|
||||
const literalUnion = <T extends readonly string[]>(arr: T) =>
|
||||
z.union(
|
||||
arr.map((val) => z.literal(val)) as [
|
||||
z.ZodLiteral<T[0]>,
|
||||
z.ZodLiteral<T[1]>,
|
||||
...z.ZodLiteral<T[number]>[],
|
||||
]
|
||||
);
|
||||
|
||||
// Range Units
|
||||
export const RANGE_UNITS = [
|
||||
"%",
|
||||
"px",
|
||||
"cm",
|
||||
"mm",
|
||||
"q",
|
||||
"in",
|
||||
"pt",
|
||||
"pc",
|
||||
"em",
|
||||
"rem",
|
||||
"ex",
|
||||
"rex",
|
||||
"cap",
|
||||
"rcap",
|
||||
"ch",
|
||||
"rch",
|
||||
"lh",
|
||||
"rlh",
|
||||
"vw",
|
||||
"svw",
|
||||
"lvw",
|
||||
"dvw",
|
||||
"vh",
|
||||
"svh",
|
||||
"lvh",
|
||||
"dvh",
|
||||
"vi",
|
||||
"svi",
|
||||
"lvi",
|
||||
"dvi",
|
||||
"vb",
|
||||
"svb",
|
||||
"lvb",
|
||||
"dvb",
|
||||
"vmin",
|
||||
"svmin",
|
||||
"lvmin",
|
||||
"dvmin",
|
||||
"vmax",
|
||||
"svmax",
|
||||
"lvmax",
|
||||
"dvmax",
|
||||
] as const;
|
||||
|
||||
export const rangeUnitSchema = literalUnion(RANGE_UNITS);
|
||||
|
||||
export const rangeUnitValueSchema = z.union([
|
||||
z.object({
|
||||
type: z.literal("unit"),
|
||||
value: z.number(),
|
||||
unit: rangeUnitSchema,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("unparsed"),
|
||||
value: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
// view-timeline-inset
|
||||
export const insetUnitValueSchema = z.union([
|
||||
rangeUnitValueSchema,
|
||||
z.object({
|
||||
type: z.literal("keyword"),
|
||||
value: z.literal("auto"),
|
||||
}),
|
||||
]);
|
||||
|
||||
// @todo: Fix Keyframe Styles
|
||||
export const keyframeStylesSchema = z.record(StyleValue);
|
||||
|
||||
// Animation Keyframe
|
||||
export const animationKeyframeSchema = z.object({
|
||||
offset: z.number().optional(),
|
||||
styles: keyframeStylesSchema,
|
||||
});
|
||||
|
||||
// Keyframe Effect Options
|
||||
export const keyframeEffectOptionsSchema = z.object({
|
||||
easing: z.string().optional(),
|
||||
fill: z
|
||||
.union([
|
||||
z.literal("none"),
|
||||
z.literal("forwards"),
|
||||
z.literal("backwards"),
|
||||
z.literal("both"),
|
||||
])
|
||||
.optional(), // FillMode
|
||||
});
|
||||
|
||||
// Scroll Named Range
|
||||
export const scrollNamedRangeSchema = z.union([
|
||||
z.literal("start"),
|
||||
z.literal("end"),
|
||||
]);
|
||||
|
||||
// Scroll Range Value
|
||||
export const scrollRangeValueSchema = z.tuple([
|
||||
scrollNamedRangeSchema,
|
||||
rangeUnitValueSchema,
|
||||
]);
|
||||
|
||||
// Scroll Range Options
|
||||
export const scrollRangeOptionsSchema = z.object({
|
||||
rangeStart: scrollRangeValueSchema.optional(),
|
||||
rangeEnd: scrollRangeValueSchema.optional(),
|
||||
});
|
||||
|
||||
// Animation Axis
|
||||
export const animationAxisSchema = z.union([
|
||||
z.literal("block"),
|
||||
z.literal("inline"),
|
||||
z.literal("x"),
|
||||
z.literal("y"),
|
||||
]);
|
||||
|
||||
// View Named Range
|
||||
export const viewNamedRangeSchema = z.union([
|
||||
z.literal("contain"),
|
||||
z.literal("cover"),
|
||||
z.literal("entry"),
|
||||
z.literal("exit"),
|
||||
z.literal("entry-crossing"),
|
||||
z.literal("exit-crossing"),
|
||||
]);
|
||||
|
||||
// View Range Value
|
||||
export const viewRangeValueSchema = z.tuple([
|
||||
viewNamedRangeSchema,
|
||||
rangeUnitValueSchema,
|
||||
]);
|
||||
|
||||
// View Range Options
|
||||
export const viewRangeOptionsSchema = z.object({
|
||||
rangeStart: viewRangeValueSchema.optional(),
|
||||
rangeEnd: viewRangeValueSchema.optional(),
|
||||
});
|
||||
|
||||
const baseAnimation = z.object({
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
keyframes: z.array(animationKeyframeSchema),
|
||||
});
|
||||
|
||||
export const scrollAnimationSchema = baseAnimation.merge(
|
||||
z.object({
|
||||
timing: keyframeEffectOptionsSchema.merge(scrollRangeOptionsSchema),
|
||||
})
|
||||
);
|
||||
|
||||
// Scroll Action
|
||||
export const scrollActionSchema = z.object({
|
||||
type: z.literal("scroll"),
|
||||
source: z
|
||||
.union([z.literal("closest"), z.literal("nearest"), z.literal("root")])
|
||||
.optional(),
|
||||
axis: animationAxisSchema.optional(),
|
||||
animations: z.array(scrollAnimationSchema),
|
||||
isPinned: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const viewAnimationSchema = baseAnimation.merge(
|
||||
z.object({
|
||||
timing: keyframeEffectOptionsSchema.merge(viewRangeOptionsSchema),
|
||||
})
|
||||
);
|
||||
|
||||
// View Action
|
||||
export const viewActionSchema = z.object({
|
||||
type: z.literal("view"),
|
||||
subject: z.string().optional(),
|
||||
axis: animationAxisSchema.optional(),
|
||||
animations: z.array(viewAnimationSchema),
|
||||
|
||||
insetStart: insetUnitValueSchema.optional(),
|
||||
|
||||
insetEnd: insetUnitValueSchema.optional(),
|
||||
|
||||
isPinned: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Animation Action
|
||||
export const animationActionSchema = z.discriminatedUnion("type", [
|
||||
scrollActionSchema,
|
||||
viewActionSchema,
|
||||
]);
|
||||
|
||||
// Helper function to check if a value is a valid range unit
|
||||
export const isRangeUnit = (
|
||||
value: unknown
|
||||
): value is z.infer<typeof rangeUnitSchema> =>
|
||||
rangeUnitSchema.safeParse(value).success;
|
||||
|
||||
// Type exports
|
||||
export type RangeUnit = z.infer<typeof rangeUnitSchema>;
|
||||
export type RangeUnitValue = z.infer<typeof rangeUnitValueSchema>;
|
||||
export type KeyframeStyles = z.infer<typeof keyframeStylesSchema>;
|
||||
export type AnimationKeyframe = z.infer<typeof animationKeyframeSchema>;
|
||||
export type ScrollNamedRange = z.infer<typeof scrollNamedRangeSchema>;
|
||||
export type ScrollRangeValue = z.infer<typeof scrollRangeValueSchema>;
|
||||
export type ViewNamedRange = z.infer<typeof viewNamedRangeSchema>;
|
||||
export type ViewRangeValue = z.infer<typeof viewRangeValueSchema>;
|
||||
export type AnimationActionScroll = z.infer<typeof scrollActionSchema>;
|
||||
export type AnimationActionView = z.infer<typeof viewActionSchema>;
|
||||
export type AnimationAction = z.infer<typeof animationActionSchema>;
|
||||
export type ScrollAnimation = z.infer<typeof scrollAnimationSchema>;
|
||||
export type ViewAnimation = z.infer<typeof viewAnimationSchema>;
|
||||
export type InsetUnitValue = z.infer<typeof insetUnitValueSchema>;
|
@ -167,6 +167,13 @@ const TextContent = z.object({
|
||||
defaultValue: z.string().optional(),
|
||||
});
|
||||
|
||||
const AnimationAction = z.object({
|
||||
...common,
|
||||
control: z.literal("animationAction"),
|
||||
type: z.literal("animationAction"),
|
||||
defaultValue: z.undefined().optional(),
|
||||
});
|
||||
|
||||
export const PropMeta = z.union([
|
||||
Number,
|
||||
Range,
|
||||
@ -187,6 +194,7 @@ export const PropMeta = z.union([
|
||||
Date,
|
||||
Action,
|
||||
TextContent,
|
||||
AnimationAction,
|
||||
]);
|
||||
|
||||
export type PropMeta = z.infer<typeof PropMeta>;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { animationActionSchema } from "./animation-schema";
|
||||
|
||||
const PropId = z.string();
|
||||
|
||||
@ -80,6 +81,11 @@ export const Prop = z.union([
|
||||
})
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
...baseProp,
|
||||
type: z.literal("animationAction"),
|
||||
value: animationActionSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type Prop = z.infer<typeof Prop>;
|
||||
|
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@ -452,6 +452,9 @@ importers:
|
||||
'@webstudio-is/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/tsconfig
|
||||
fast-glob:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
html-tags:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
@ -921,6 +924,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^18.2.25
|
||||
version: 18.2.25
|
||||
fast-glob:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
typescript:
|
||||
specifier: 5.7.3
|
||||
version: 5.7.3
|
||||
@ -1856,6 +1862,9 @@ importers:
|
||||
'@webstudio-is/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
change-case:
|
||||
specifier: ^5.4.4
|
||||
version: 5.4.4
|
||||
react-error-boundary:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(react@18.3.0-canary-14898b6a9-20240318)
|
||||
@ -1893,6 +1902,9 @@ importers:
|
||||
'@webstudio-is/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../tsconfig
|
||||
fast-glob:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
playwright:
|
||||
specifier: ^1.50.1
|
||||
version: 1.50.1
|
||||
@ -1908,6 +1920,9 @@ importers:
|
||||
vitest:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.5(@types/debug@4.1.12)(@types/node@22.10.7)(@vitest/browser@3.0.5)(jsdom@20.0.3)(msw@2.7.0(@types/node@22.10.7)(typescript@5.7.3))
|
||||
zod:
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4
|
||||
|
||||
packages/sdk-components-react:
|
||||
dependencies:
|
||||
|
Reference in New Issue
Block a user