diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animations-select.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animations-select.tsx
index 720d8ebf18f1..b57b7ab0ddeb 100644
--- a/apps/builder/app/builder/features/settings-panel/props-section/animation/animations-select.tsx
+++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animations-select.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo, type ReactNode } from "react";
+import { useState, useMemo, type ReactNode, useRef } from "react";
import {
theme,
DropdownMenu,
@@ -22,6 +22,11 @@ import {
SectionTitle,
SectionTitleLabel,
Flex,
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ toast,
} from "@webstudio-is/design-system";
import {
EyeClosedIcon,
@@ -29,15 +34,18 @@ import {
MinusIcon,
PlusIcon,
} from "@webstudio-is/icons";
-import type {
- AnimationAction,
- ScrollAnimation,
- ViewAnimation,
+import {
+ scrollAnimationSchema,
+ viewAnimationSchema,
+ type AnimationAction,
+ type ScrollAnimation,
+ type ViewAnimation,
} from "@webstudio-is/sdk";
import { newScrollAnimations } from "./new-scroll-animations";
import { newViewAnimations } from "./new-view-animations";
import { AnimationPanelContent } from "./animation-panel-content";
import { CollapsibleSectionRoot } from "~/builder/shared/collapsible-section";
+import { z } from "zod";
const newAnimationsPerType: {
scroll: ScrollAnimation[];
@@ -60,6 +68,118 @@ type AnimationsSelectProps = {
const floatingPanelOffset = { alignmentAxis: -100 };
+const copyAttribute = "data-animation-index";
+
+const clipboardNamespace = "@webstudio/animation/v0.1";
+
+const serialize = (animations: (ScrollAnimation | ViewAnimation)[]) => {
+ return JSON.stringify({ [clipboardNamespace]: animations });
+};
+
+const parseViewAnimations = (text: string): ViewAnimation[] => {
+ const data = JSON.parse(text);
+ const parsed = z
+ .object({ [clipboardNamespace]: z.array(viewAnimationSchema) })
+ .parse(data);
+ return parsed[clipboardNamespace];
+};
+
+const parseScrollAnimations = (text: string): ScrollAnimation[] => {
+ const data = JSON.parse(text);
+ const parsed = z
+ .object({ [clipboardNamespace]: z.array(scrollAnimationSchema) })
+ .parse(data);
+ return parsed[clipboardNamespace];
+};
+
+const AnimationContextMenu = ({
+ action,
+ onChange,
+ children,
+}: {
+ action: AnimationAction;
+ onChange: (newAction: AnimationAction) => void;
+ children: ReactNode;
+}) => {
+ const lastClickedAnimationIndex = useRef(-1);
+
+ const copyAnimation = () => {
+ const index = lastClickedAnimationIndex.current;
+ const animations =
+ index === -1 ? action.animations : [action.animations[index]];
+ navigator.clipboard.writeText(serialize(animations));
+ };
+
+ const copyAllAnimations = () => {
+ navigator.clipboard.writeText(serialize(action.animations));
+ };
+
+ const pasteAnimations = () => {
+ const index = lastClickedAnimationIndex.current;
+ navigator.clipboard
+ .readText()
+ .then((text) => {
+ if (action.type === "scroll") {
+ const animations = parseScrollAnimations(text);
+ const newAction = structuredClone(action);
+ newAction.animations.splice(index + 1, 0, ...animations);
+ onChange(newAction);
+ }
+ if (action.type === "view") {
+ const animations = parseViewAnimations(text);
+ const newAction = structuredClone(action);
+ newAction.animations.splice(index + 1, 0, ...animations);
+ onChange(newAction);
+ }
+ })
+ .catch((error) => {
+ toast.error("Pasted data is not valid animation");
+ console.error(error);
+ });
+ };
+
+ const deleteAnimation = () => {
+ const index = lastClickedAnimationIndex.current;
+ if (index === -1) {
+ return;
+ }
+ const newAction = structuredClone(action);
+ newAction.animations.splice(index, 1);
+ onChange(newAction);
+ };
+
+ return (
+
+ {
+ if (event.target instanceof HTMLElement) {
+ const animationIndex = event.target
+ .closest(`[${copyAttribute}]`)
+ ?.getAttribute(copyAttribute);
+ lastClickedAnimationIndex.current = Number(animationIndex ?? -1);
+ }
+ }}
+ >
+ {children}
+
+
+
+ Copy animation
+
+
+ Copy all animations
+
+
+ Paste animations
+
+
+ Delete animation
+
+
+
+ );
+};
+
export const AnimationsSelect = ({
action,
value,
@@ -94,151 +214,182 @@ export const AnimationsSelect = ({
};
return (
-
- {action}
-
-
- } tabIndex={0} />
-
-
- {newAnimations.map((animation, index) => (
- {
- handleChange(
- {
- ...value,
- animations: value.animations.concat(animation),
- },
- false
- );
- }}
- onFocus={() => setNewAnimationHint(animation.description)}
- onBlur={() => setNewAnimationHint(undefined)}
- >
- {animation.name}
-
- ))}
+ handleChange(newAction, false)}
+ >
+
+ {action}
+
+
+ } tabIndex={0} />
+
+
+ {newAnimations.map((animation, index) => (
+ {
+ handleChange(
+ {
+ ...value,
+ animations: value.animations.concat(animation),
+ },
+ false
+ );
+ }}
+ onFocus={() =>
+ setNewAnimationHint(animation.description)
+ }
+ onBlur={() => setNewAnimationHint(undefined)}
+ >
+ {animation.name}
+
+ ))}
-
+
-
- {newAnimations.map(({ description }, index) => (
+
+ {newAnimations.map(({ description }, index) => (
+
+ {description}
+
+ ))}
- {description}
+ {newAnimationHint ??
+ "Add new or select existing animation"}
- ))}
-
- {newAnimationHint ??
- "Add new or select existing animation"}
-
-
-
-
-
- }
- >
- Animations
-
- }
- >
-
-
- {value.animations.map((animation, index) => {
- const isEnabled = isAnimationEnabled(animation.enabled) ?? true;
-
- return (
-
- {animation.name}
-
- }
- content={
- {
- if (animation === undefined) {
- // Reset ephemeral state
- handleChange(undefined, true);
- return;
- }
+
+
+
+
+ }
+ >
+ Animations
+
+ }
+ >
+
+
+ {value.animations.map((animation, index) => {
+ const isEnabled = isAnimationEnabled(animation.enabled) ?? true;
- const newAnimations = [...value.animations];
- newAnimations[index] = animation;
- const newValue = {
- ...value,
- animations: newAnimations,
- };
- handleChange(newValue, isEphemeral);
- }}
- />
- }
- offset={floatingPanelOffset}
- >
-
- {animation.name ?? "Unnamed"}
-
+ title={
+
+ {animation.name}
+
}
- hidden={!isEnabled}
- draggable
- active={dragItemId === String(index)}
- state={undefined}
- index={index}
- id={String(index)}
- buttons={
- <>
- {
+ if (animation === undefined) {
+ // Reset ephemeral state
+ handleChange(undefined, true);
+ return;
}
- >
- {
- const enabledMap = new Map(animation.enabled);
- enabledMap.set(selectedBreakpointId, !isEnabled);
- const enabled = [...enabledMap];
+ const newAnimations = [...value.animations];
+ newAnimations[index] = animation;
+ const newValue = {
+ ...value,
+ animations: newAnimations,
+ };
+ handleChange(newValue, isEphemeral);
+ }}
+ />
+ }
+ offset={floatingPanelOffset}
+ >
+
+ {animation.name ?? "Unnamed"}
+
+ }
+ hidden={!isEnabled}
+ draggable
+ active={dragItemId === String(index)}
+ state={undefined}
+ index={index}
+ id={String(index)}
+ buttons={
+ <>
+
+ {
+ const enabledMap = new Map(animation.enabled);
+ enabledMap.set(selectedBreakpointId, !isEnabled);
- const newAnimations = [...value.animations];
- const newAnimation = {
- ...animation,
- enabled: enabled.every(([_, enabled]) => enabled)
- ? undefined
- : [...enabledMap],
- };
+ const enabled = [...enabledMap];
- newAnimations[index] = newAnimation;
+ const newAnimations = [...value.animations];
+ const newAnimation = {
+ ...animation,
+ enabled: enabled.every(
+ ([_, enabled]) => enabled
+ )
+ ? undefined
+ : [...enabledMap],
+ };
+
+ newAnimations[index] = newAnimation;
+
+ const newValue = {
+ ...value,
+ animations: newAnimations,
+ };
+ handleChange(newValue, false);
+ }}
+ variant="normal"
+ tabIndex={-1}
+ icon={
+ isEnabled ? :
+ }
+ />
+
+
+ }
+ onClick={() => {
+ const newAnimations = [...value.animations];
+ newAnimations.splice(index, 1);
const newValue = {
...value,
@@ -246,36 +397,17 @@ export const AnimationsSelect = ({
};
handleChange(newValue, false);
}}
- variant="normal"
- tabIndex={-1}
- icon={isEnabled ? : }
/>
-
-
- }
- onClick={() => {
- const newAnimations = [...value.animations];
- newAnimations.splice(index, 1);
-
- const newValue = {
- ...value,
- animations: newAnimations,
- };
- handleChange(newValue, false);
- }}
- />
- >
- }
- />
-
- );
- })}
- {placementIndicator}
-
-
-
+ >
+ }
+ />
+
+ );
+ })}
+ {placementIndicator}
+
+
+
+
);
};