From c5a9826b19a13cdc5d34663cde583088b1b2f9f1 Mon Sep 17 00:00:00 2001 From: Pedro Carreno <34664891+Pkcarreno@users.noreply.github.com> Date: Mon, 6 Jan 2025 21:49:44 -0400 Subject: [PATCH] fix: improve action button animation & add execution layer provider --- .../components/header/action-buttons.tsx | 240 +++++++++--------- .../editor/hooks/use-execution-layer.tsx | 14 + .../editor/providers/execution-layer.tsx | 130 ++++++++++ src/features/editor/providers/index.tsx | 5 +- 4 files changed, 273 insertions(+), 116 deletions(-) create mode 100644 src/features/editor/hooks/use-execution-layer.tsx create mode 100644 src/features/editor/providers/execution-layer.tsx diff --git a/src/features/editor/components/header/action-buttons.tsx b/src/features/editor/components/header/action-buttons.tsx index 0e0fa4b..8980078 100644 --- a/src/features/editor/components/header/action-buttons.tsx +++ b/src/features/editor/components/header/action-buttons.tsx @@ -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 ( ); }; -type StateType = 'idle' | 'waiting' | 'running' | 'overflow'; - export type AnimatedActionButtonProps = React.ButtonHTMLAttributes & { - state: StateType; + state: ExecutionStatusType; stopExec: () => void; runExec: () => void; }; @@ -93,10 +33,6 @@ export const AnimatedActionButton: React.FC = ({ runExec, stopExec, }) => { - useEffect(() => { - console.log('state', state); - }, [state]); - const scope = useButtonAnimation(state); const isDisabled = useMemo(() => { @@ -117,72 +53,98 @@ export const AnimatedActionButton: React.FC = ({ }, [state]); return ( -
+
); }; -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; } @@ -190,3 +152,51 @@ const useButtonAnimation = (state: StateType) => { return scope; }; + +interface LoadingIconProps { + id?: string; +} + +const LoadingIcon: React.FC = ({ id }) => { + return ( + + + + + + + + + + + + ); +}; diff --git a/src/features/editor/hooks/use-execution-layer.tsx b/src/features/editor/hooks/use-execution-layer.tsx new file mode 100644 index 0000000..1650dfe --- /dev/null +++ b/src/features/editor/hooks/use-execution-layer.tsx @@ -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; +}; diff --git a/src/features/editor/providers/execution-layer.tsx b/src/features/editor/providers/execution-layer.tsx new file mode 100644 index 0000000..7499d90 --- /dev/null +++ b/src/features/editor/providers/execution-layer.tsx @@ -0,0 +1,130 @@ +import React, { createContext, useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import useDebounce from '@/hooks/use-debounce'; +import useTimeoutFn from '@/hooks/use-timeout-fn'; + +import { useUntrustedMode } from '../hooks/use-untrusted-mode'; +import { useCodeStore, useLogsStore } from '../stores/editor'; +import { useSettingsStore } from '../stores/settings'; +import { runJs, stopJs } from '../utils/engine/controller'; + +const DEFAULT_OVERFLOW_TIMEOUT = 4000; + +export type ExecutionStatusType = 'idle' | 'waiting' | 'running' | 'overflow'; + +type ExecutionLayerState = { + status: ExecutionStatusType; + runExec: () => void; + stopExec: () => void; +}; + +const initialState: ExecutionLayerState = { + status: 'idle', + runExec: () => null, + stopExec: () => null, +}; + +export const ExecutionLayerContext = + createContext(initialState); + +interface Props { + children: React.ReactNode; +} + +// eslint-disable-next-line max-lines-per-function +export const ExecutionLayerProvider: React.FC = ({ children }) => { + const { persist_logs, auto_run, auto_run_timeout } = useSettingsStore(); + const { code } = useCodeStore(); + const { clearLogs } = useLogsStore(); + const { isUntrustedMode, isUnedited } = useUntrustedMode(); + + const [status, setStatus] = useState( + initialState.status, + ); + + const [_1, cancelOveflowTimeout, resetOverflowTimeout] = useTimeoutFn(() => { + if (!(status === 'running')) return; + setStatus('overflow'); + }, DEFAULT_OVERFLOW_TIMEOUT); + + const [isReadyAutoRunDebounce, cancelAutoRunDebounce] = useDebounce( + () => { + if ( + status !== 'running' && + status !== 'overflow' && + auto_run && + !isUnedited && + !isUntrustedMode + ) { + runExec(); + } + }, + auto_run_timeout, + [code], + ); + + useEffect(() => { + const isReady = isReadyAutoRunDebounce(); + if ( + typeof isReady === 'boolean' && + !isReady && + auto_run && + !isUnedited && + !isUntrustedMode + ) { + setStatus('waiting'); + } + }, [isReadyAutoRunDebounce, code]); + + useEffect(() => { + if (!auto_run) { + cancelAutoRunDebounce(); + } + }, [auto_run]); + + const runExec = async () => { + try { + if (code.length === 0) { + throw '01'; + } + if (status === 'running' || status === 'overflow') { + stopJs(); + } + resetOverflowTimeout(); + if (!persist_logs) clearLogs(); + setStatus('running'); + await runJs(code); + } catch (e) { + console.error(e); + if (e === '01') { + toast.warning('Nothing to execute!'); + } + } finally { + resetExecutionLayer(); + } + }; + + const stopExec = () => { + stopJs(); + resetExecutionLayer(); + }; + + const resetExecutionLayer = () => { + setStatus('idle'); + cancelOveflowTimeout(); + cancelAutoRunDebounce(); + }; + + const value = { + status, + runExec, + stopExec, + }; + + return ( + + {children} + + ); +}; diff --git a/src/features/editor/providers/index.tsx b/src/features/editor/providers/index.tsx index 5a1cfea..f00d7cc 100644 --- a/src/features/editor/providers/index.tsx +++ b/src/features/editor/providers/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { EditorBehaviorProvider } from './editor-behavior'; +import { ExecutionLayerProvider } from './execution-layer'; import { HelpProvider } from './help-provider'; import { MetatagsProvider } from './meta-provider'; import { SettingsDialogProvider } from './settings-dialog-provider'; @@ -17,7 +18,9 @@ const EditorProviders: React.FC = ({ children }) => { - <>{children} + + <>{children} +