From c08ac182a5238686463d449075752f336b6e1b31 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Thu, 24 Apr 2025 23:41:46 +0700 Subject: [PATCH 1/3] feat: add context menu to animations Now user can copy and paste specific animations between animation groups. --- .../animation/animations-select.tsx | 433 +++++++++++------- 1 file changed, 269 insertions(+), 164 deletions(-) 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..9a6aff711e1a 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,10 +34,12 @@ 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"; @@ -60,6 +67,92 @@ type AnimationsSelectProps = { const floatingPanelOffset = { alignmentAxis: -100 }; +const copyAttribute = "data-animation-index"; + +const AnimationContextMenu = ({ + action, + onChange, + children, +}: { + action: AnimationAction; + onChange: (newAction: AnimationAction) => void; + children: ReactNode; +}) => { + const lastClickedAnimationIndex = useRef(-1); + return ( + + { + if (event.target instanceof HTMLElement) { + const animationIndex = event.target + .closest(`[${copyAttribute}]`) + ?.getAttribute(copyAttribute); + lastClickedAnimationIndex.current = Number(animationIndex ?? -1); + } + }} + > + {children} + + + { + const index = lastClickedAnimationIndex.current; + if (index === -1) { + return; + } + const animation = action.animations[index]; + navigator.clipboard.writeText(JSON.stringify(animation)); + }} + > + Copy animation + + { + const index = lastClickedAnimationIndex.current; + navigator.clipboard + .readText() + .then((string) => { + const data = JSON.parse(string); + if (action.type === "scroll") { + const animation = scrollAnimationSchema.parse(data); + const newAction = structuredClone(action); + newAction.animations.splice(index + 1, 0, animation); + onChange(newAction); + } + if (action.type === "view") { + const animation = viewAnimationSchema.parse(data); + const newAction = structuredClone(action); + newAction.animations.splice(index + 1, 0, animation); + onChange(newAction); + } + }) + .catch((error) => { + toast.error("Pasted data is not valid animation"); + console.error(error); + }); + }} + > + Paste animation + + { + const index = lastClickedAnimationIndex.current; + if (index === -1) { + return; + } + const newAction = structuredClone(action); + newAction.animations.splice(index, 1); + onChange(newAction); + }} + > + Delete animation + + + + ); +}; + export const AnimationsSelect = ({ action, value, @@ -94,151 +187,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]; + + const newAnimations = [...value.animations]; + const newAnimation = { + ...animation, + enabled: enabled.every( + ([_, enabled]) => enabled + ) + ? undefined + : [...enabledMap], + }; + + newAnimations[index] = newAnimation; - 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 +370,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} + + + + ); }; From 0202b0a015cf5028d020462a375bdd4df86d383c Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Fri, 25 Apr 2025 03:12:00 +0700 Subject: [PATCH 2/3] Add copy all animations --- .../animation/animations-select.tsx | 104 +++++++++--------- 1 file changed, 55 insertions(+), 49 deletions(-) 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 9a6aff711e1a..2f93319a2e88 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 @@ -45,6 +45,7 @@ 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[]; @@ -79,6 +80,53 @@ const AnimationContextMenu = ({ children: ReactNode; }) => { const lastClickedAnimationIndex = useRef(-1); + + const copyAnimation = () => { + const index = lastClickedAnimationIndex.current; + const animations = + index === -1 ? action.animations : [action.animations[index]]; + navigator.clipboard.writeText(JSON.stringify(animations)); + }; + + const copyAllAnimations = () => { + navigator.clipboard.writeText(JSON.stringify(action.animations)); + }; + + const pasteAnimations = () => { + const index = lastClickedAnimationIndex.current; + navigator.clipboard + .readText() + .then((string) => { + const data = JSON.parse(string); + if (action.type === "scroll") { + const animations = z.array(scrollAnimationSchema).parse(data); + const newAction = structuredClone(action); + newAction.animations.splice(index + 1, 0, ...animations); + onChange(newAction); + } + if (action.type === "view") { + const animations = z.array(viewAnimationSchema).parse(data); + 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 ( - { - const index = lastClickedAnimationIndex.current; - if (index === -1) { - return; - } - const animation = action.animations[index]; - navigator.clipboard.writeText(JSON.stringify(animation)); - }} - > + Copy animation - { - const index = lastClickedAnimationIndex.current; - navigator.clipboard - .readText() - .then((string) => { - const data = JSON.parse(string); - if (action.type === "scroll") { - const animation = scrollAnimationSchema.parse(data); - const newAction = structuredClone(action); - newAction.animations.splice(index + 1, 0, animation); - onChange(newAction); - } - if (action.type === "view") { - const animation = viewAnimationSchema.parse(data); - const newAction = structuredClone(action); - newAction.animations.splice(index + 1, 0, animation); - onChange(newAction); - } - }) - .catch((error) => { - toast.error("Pasted data is not valid animation"); - console.error(error); - }); - }} - > - Paste animation + + Copy all animations - { - const index = lastClickedAnimationIndex.current; - if (index === -1) { - return; - } - const newAction = structuredClone(action); - newAction.animations.splice(index, 1); - onChange(newAction); - }} - > + + Paste animations + + Delete animation From de5fe45f6d70d5bdee1c9e36629a01366156e515 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Fri, 25 Apr 2025 12:50:33 +0700 Subject: [PATCH 3/3] Add animation namespace --- .../animation/animations-select.tsx | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) 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 2f93319a2e88..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 @@ -70,6 +70,28 @@ 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, @@ -85,27 +107,26 @@ const AnimationContextMenu = ({ const index = lastClickedAnimationIndex.current; const animations = index === -1 ? action.animations : [action.animations[index]]; - navigator.clipboard.writeText(JSON.stringify(animations)); + navigator.clipboard.writeText(serialize(animations)); }; const copyAllAnimations = () => { - navigator.clipboard.writeText(JSON.stringify(action.animations)); + navigator.clipboard.writeText(serialize(action.animations)); }; const pasteAnimations = () => { const index = lastClickedAnimationIndex.current; navigator.clipboard .readText() - .then((string) => { - const data = JSON.parse(string); + .then((text) => { if (action.type === "scroll") { - const animations = z.array(scrollAnimationSchema).parse(data); + const animations = parseScrollAnimations(text); const newAction = structuredClone(action); newAction.animations.splice(index + 1, 0, ...animations); onChange(newAction); } if (action.type === "view") { - const animations = z.array(viewAnimationSchema).parse(data); + const animations = parseViewAnimations(text); const newAction = structuredClone(action); newAction.animations.splice(index + 1, 0, ...animations); onChange(newAction);