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} + + + + ); };