Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { JsonControl } from "./json";
import { TextContent } from "./text-content";
import { ResourceControl } from "./resource-control";
import { TagControl } from "./tag-control";
import { InvokerControl } from "./invoker";

export const renderControl = ({
meta,
Expand Down Expand Up @@ -106,6 +107,10 @@ export const renderControl = ({
return <UrlControl key={key} meta={meta} prop={prop} {...rest} />;
}

if (meta.control === "invoker") {
return <InvokerControl key={key} meta={meta} prop={prop} {...rest} />;
}

// Type in meta can be changed at some point without updating props in DB that are still using the old type
// In this case meta and prop will mismatch, but we try to guess a matching control based just on the prop type
if (prop) {
Expand Down Expand Up @@ -232,6 +237,22 @@ export const renderControl = ({
);
}

if (prop.type === "invoker") {
return (
<InvokerControl
key={key}
meta={{
...meta,
defaultValue: undefined,
control: "invoker",
type: "invoker",
}}
prop={prop}
{...rest}
/>
);
}

prop satisfies never;
}

Expand Down
145 changes: 145 additions & 0 deletions apps/builder/app/builder/features/settings-panel/controls/invoker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useMemo } from "react";
import { useStore } from "@nanostores/react";
import {
Box,
Grid,
InputField,
Select,
Text,
theme,
} from "@webstudio-is/design-system";
import { type Invoker, isCompleteInvoker } from "@webstudio-is/sdk";
import { $instances } from "~/shared/nano-states";
import { type ControlProps, ResponsiveLayout } from "../shared";
import { FieldLabel, PropertyLabel } from "../property-label";

/**
* Hook to find all AnimateChildren instances in the project
*/
const useAnimationGroups = () => {
const instances = useStore($instances);
return useMemo(() => {
const groups: Array<{ id: string; label: string }> = [];
for (const [id, instance] of instances) {
if (
instance.component ===
"@webstudio-is/sdk-components-animation:AnimateChildren"
) {
groups.push({ id, label: `Animation Group` });
}
}
return groups;
}, [instances]);
};

const defaultValue: Invoker = {
targetInstanceId: "",
command: "--",
};

/**
* Invoker Control - enables HTML Invoker Commands for triggering animations
*
* This creates the connection between a button and an Animation Group.
* When clicked, the button will dispatch a command event to the target.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API
*/
export const InvokerControl = ({
prop,
propName,
onChange,
}: ControlProps<"invoker">) => {
const animationGroups = useAnimationGroups();

const value: Invoker = prop?.type === "invoker" ? prop.value : defaultValue;

const handleChange = (updates: Partial<Invoker>) => {
const newValue = { ...value, ...updates };
// Ensure command always starts with --
if (updates.command !== undefined && !updates.command.startsWith("--")) {
newValue.command = `--${updates.command.replace(/^-+/, "")}`;
}
onChange({
type: "invoker",
value: newValue,
});
};

// Show message if no Animation Groups exist
if (animationGroups.length === 0) {
return (
<ResponsiveLayout label={<PropertyLabel name={propName} />}>
<Text color="subtle" css={{ paddingBlock: theme.spacing[3] }}>
Add an Animation Group to use Invoker
</Text>
</ResponsiveLayout>
);
}

const targetOptions = animationGroups.map((g) => g.id);

// Get display value without -- prefix for cleaner input
const commandDisplayValue = value.command.startsWith("--")
? value.command.slice(2)
: value.command;

const isValid = isCompleteInvoker(value);

return (
<Grid gap={2}>
{/* Main Property Label with Delete functionality */}
<ResponsiveLayout label={<PropertyLabel name={propName} />}>
<Text color="subtle">
{isValid ? `command="${value.command}"` : "Configure below"}
</Text>
</ResponsiveLayout>

{/* Target Animation Group */}
<ResponsiveLayout
label={
<FieldLabel description="Select which Animation Group receives this command">
Target
</FieldLabel>
}
>
<Box css={{ minWidth: 0 }}>
<Select
options={targetOptions}
getLabel={(id) => {
const index = animationGroups.findIndex((g) => g.id === id);
return index >= 0 ? `Animation Group ${index + 1}` : "Select";
}}
value={value.targetInstanceId || undefined}
onChange={(targetInstanceId) => {
handleChange({ targetInstanceId });
}}
placeholder="Select target"
/>
</Box>
</ResponsiveLayout>

{/* Command Name */}
<ResponsiveLayout
label={
<FieldLabel description="Command name that triggers the animation. Must match the command in Animation Group.">
Command
</FieldLabel>
}
>
<InputField
value={commandDisplayValue}
placeholder="play-intro"
prefix={<Text color="subtle">--</Text>}
onChange={(event) => {
const sanitized = event.target.value
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
handleChange({ command: `--${sanitized}` });
}}
/>
</ResponsiveLayout>
</Grid>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { theme } from "@webstudio-is/design-system";
import { useState } from "react";
import type { ScrollAnimation, ViewAnimation } from "@webstudio-is/sdk";

const meta = {
const meta: Meta = {
title: "Builder/Settings Panel/Animation Panel Content",
component: AnimationPanelContent,
parameters: {
Expand All @@ -17,83 +17,75 @@ const meta = {
</div>
),
],
} satisfies Meta<typeof AnimationPanelContent>;
};

export default meta;
type Story = StoryObj<typeof meta>;

const ScrollAnimationTemplate: Story["render"] = ({ value: initialValue }) => {
const [value, setValue] = useState(initialValue);
const ScrollAnimationTemplate = () => {
const [value, setValue] = useState<ScrollAnimation>({
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: "number" },
},
},
],
});

return (
<AnimationPanelContent
type="scroll"
value={value}
onChange={(newValue) => {
setValue(newValue as ScrollAnimation);
if (newValue !== undefined) {
setValue(newValue);
}
}}
/>
);
};

const ViewAnimationTemplate: Story["render"] = ({ value: initialValue }) => {
const [value, setValue] = useState(initialValue);
const ViewAnimationTemplate = () => {
const [value, setValue] = useState<ViewAnimation>({
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: "number" },
},
},
],
});

return (
<AnimationPanelContent
type="view"
value={value}
onChange={(newValue) => {
setValue(newValue as ViewAnimation);
if (newValue !== undefined) {
setValue(newValue);
}
}}
/>
);
};

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: () => {},
},
};
Loading