Skip to content

Commit

Permalink
fix: improve action button animation & add execution layer provider
Browse files Browse the repository at this point in the history
  • Loading branch information
Pkcarreno committed Jan 7, 2025
1 parent c07a1a0 commit c5a9826
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 116 deletions.
240 changes: 125 additions & 115 deletions src/features/editor/components/header/action-buttons.tsx
Original file line number Diff line number Diff line change
@@ -1,89 +1,29 @@
import { PauseIcon, PlayIcon } from '@radix-ui/react-icons';
import { PauseIcon, PlayIcon, SlashIcon } from '@radix-ui/react-icons';
import type { DynamicAnimationOptions } from 'motion';
import type { MotionStyle } from 'motion/react';
import { useAnimate } from 'motion/react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';

import { useUntrustedMode } from '@/features/editor/hooks/use-untrusted-mode';
import { useCodeStore, useLogsStore } from '@/features/editor/stores/editor';
import { useSettingsStore } from '@/features/editor/stores/settings';
import useDebounce from '@/hooks/use-debounce';
import useTimeoutFn from '@/hooks/use-timeout-fn';
import { cn } from '@/lib/utils';

import { runJs, stopJs } from '../../utils/engine/controller';
import { useExecutionLayer } from '../../hooks/use-execution-layer';
import type { ExecutionStatusType } from '../../providers/execution-layer';

export const ActionButtons = () => {
const { persist_logs, auto_run, auto_run_timeout } = useSettingsStore();
const { code } = useCodeStore();
const { clearLogs } = useLogsStore();
const [isExecuting, setIsExecuting] = useState(false);
const { isUntrustedMode, isUnedited } = useUntrustedMode();
const [showStopButton, setShowStopButton] = useState(false);

const [, cancelShowStopButtonTimeout, resetStopButtonTimeout] = useTimeoutFn(
() => {
if (!isExecuting) return;
setShowStopButton(true);
},
500,
);
const [, cancelAutoRunDebounce] = useDebounce(
() => {
if (auto_run && !isUnedited && !isUntrustedMode) {
handleRunCode();
}
},
auto_run_timeout,
[code],
);
const handleRunCode = async () => {
try {
resetStopButtonTimeout();
if (!persist_logs) clearLogs();
setIsExecuting(true);
await runJs(code);
} catch (e) {
console.error(e);
} finally {
setIsExecuting(false);
clearStopButtonState();
}
};
useEffect(() => {
if (!auto_run) {
cancelAutoRunDebounce();
}
}, [auto_run]);
const clearStopButtonState = () => {
setShowStopButton(false);
cancelShowStopButtonTimeout();
};
const stopExecution = () => {
stopJs();
setIsExecuting(false);
clearStopButtonState();
};

const currentState = useMemo(() => {
if (isExecuting && showStopButton) return 'overflow';
if (isExecuting && !showStopButton) return 'running';

return 'idle';
}, [isExecuting, showStopButton]);
const { status, runExec, stopExec } = useExecutionLayer();

return (
<AnimatedActionButton
state={currentState}
stopExec={stopExecution}
runExec={handleRunCode}
state={status}
stopExec={stopExec}
runExec={runExec}
/>
);
};

type StateType = 'idle' | 'waiting' | 'running' | 'overflow';

export type AnimatedActionButtonProps =
React.ButtonHTMLAttributes<HTMLButtonElement> & {
state: StateType;
state: ExecutionStatusType;
stopExec: () => void;
runExec: () => void;
};
Expand All @@ -93,10 +33,6 @@ export const AnimatedActionButton: React.FC<AnimatedActionButtonProps> = ({
runExec,
stopExec,
}) => {
useEffect(() => {
console.log('state', state);
}, [state]);

const scope = useButtonAnimation(state);

const isDisabled = useMemo(() => {
Expand All @@ -117,76 +53,150 @@ export const AnimatedActionButton: React.FC<AnimatedActionButtonProps> = ({
}, [state]);

return (
<div ref={scope}>
<div ref={scope} className="size-9 overflow-hidden">
<button
className={cn(
'focus-visible:ring-ring [&_svg]:shrink-0" inline-flex size-9 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4',
'focus-visible:ring-ring [&_svg]:shrink-0" box-border inline-flex size-9 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4',
classNamesByState,
)}
onClick={handleExecution}
disabled={isDisabled}
>
<PauseIcon id="icon-pause" className="size-5" />
<PlayIcon id="icon-play" className="size-5" />
<LoadingIcon id="icon-loading" />
<PauseIcon id="icon-pause" className="relative inline-block size-5" />
<SlashIcon
id="icon-running"
className="relative inline-block size-5 animate-spin"
/>
<PlayIcon id="icon-play" className="relative inline-block size-5" />
</button>
</div>
);
};

const useButtonAnimation = (state: StateType) => {
const BASE_ICON_STYLES: MotionStyle = {
display: 'none',
rotate: 45,
};
const BASE_ICON_OPTIONS: DynamicAnimationOptions = {
duration: 0.1,
};
const CURRENT_ICON_STYLES: MotionStyle = {
display: 'block',
position: 'absolute',
rotate: 0,
};
const CURRENT_ICON_OPTIONS: DynamicAnimationOptions = {
duration: 0.1,
delay: 0.1,
};
const BUTTON_PRIMARY_STYLES: MotionStyle = {
backgroundColor: 'hsl(var(--primary))',
color: 'hsl(var(--primary-foreground))',
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
};

const BUTTON_DESTRUCTIVE_STYLES: MotionStyle = {
backgroundColor: 'hsl(var(--destructive))',
color: 'hsl(var(--destructive-foreground))',
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
};
const BUTTON_OPTIONS = {
duration: 0.3,
at: '>',
};

const useButtonAnimation = (state: ExecutionStatusType) => {
const [scope, animate] = useAnimate();

useEffect(() => {
switch (state) {
case 'idle':
animate([
['#icon-pause', { display: 'none', rotate: 90 }, { duration: 0.1 }],
['#icon-play', { display: 'inline', rotate: 0 }, { duration: 0.2 }],
[
'button',
{
backgroundColor: 'hsl(var(--primary))',
color: 'hsl(var(--primary-foreground))',
boxShadow:
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
},
{ duration: 0.3, at: '>' },
],
['#icon-pause', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-running', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-loading', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-play', CURRENT_ICON_STYLES, CURRENT_ICON_OPTIONS],
['button', BUTTON_PRIMARY_STYLES, BUTTON_OPTIONS],
]);
break;
case 'waiting':
animate([
['#icon-pause', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-running', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-play', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-loading', CURRENT_ICON_STYLES, CURRENT_ICON_OPTIONS],
['button', BUTTON_PRIMARY_STYLES, BUTTON_OPTIONS],
]);
break;
case 'running':
animate([
['#icon-pause', { display: 'none', rotate: 90 }, { duration: 0.1 }],
['#icon-play', { display: 'inline', rotate: 0 }, { duration: 0.2 }],
[
'button',
{
backgroundColor: 'hsl(var(--primary))',
color: 'hsl(var(--primary-foreground))',
boxShadow:
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
},
{ duration: 0.3, at: '>' },
],
['#icon-play', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-pause', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-loading', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-running', CURRENT_ICON_STYLES, CURRENT_ICON_OPTIONS],
['button', BUTTON_PRIMARY_STYLES, BUTTON_OPTIONS],
]);
break;
case 'overflow':
animate([
['#icon-play', { display: 'none', rotate: 90 }, { duration: 0.1 }],
['#icon-pause', { display: 'inline', rotate: 0 }, { duration: 0.2 }],
[
'button',
{
backgroundColor: 'hsl(var(--destructive))',
color: 'hsl(var(--destructive-foreground))',
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
},
{ duration: 0.3, at: '>' },
],
['#icon-play', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-running', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-loading', BASE_ICON_STYLES, BASE_ICON_OPTIONS],
['#icon-pause', CURRENT_ICON_STYLES, CURRENT_ICON_OPTIONS],
['button', BUTTON_DESTRUCTIVE_STYLES, BUTTON_OPTIONS],
]);
break;
}
}, [state]);

return scope;
};

interface LoadingIconProps {
id?: string;
}

const LoadingIcon: React.FC<LoadingIconProps> = ({ id }) => {
return (
<svg
id={id}
fill="hsl(var(--primary-foreground))"
viewBox="0 0 26 26"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="4" cy="12" r="3">
<animate
id="spinner_qFRN"
begin="0;spinner_OcgL.end+0.25s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
<circle cx="12" cy="12" r="3">
<animate
begin="spinner_qFRN.begin+0.1s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
<circle cx="20" cy="12" r="3">
<animate
id="spinner_OcgL"
begin="spinner_qFRN.begin+0.2s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
</svg>
);
};
14 changes: 14 additions & 0 deletions src/features/editor/hooks/use-execution-layer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useContext } from 'react';

import { ExecutionLayerContext } from '../providers/execution-layer';

export const useExecutionLayer = () => {
const context = useContext(ExecutionLayerContext);

if (context === undefined)
throw new Error(
'useExecutionLayer must be used within a ExecutionLayerProvider',
);

return context;
};
Loading

0 comments on commit c5a9826

Please sign in to comment.