From babf1566304f0c27a706848af7c127ed1108f342 Mon Sep 17 00:00:00 2001 From: Pavel Ivanov Date: Sun, 12 Jan 2025 02:47:09 +0200 Subject: [PATCH] ui/ux/dx, fixed animations, fixed what changed calculations --- .vscode/settings.json | 6 +- packages/scan/src/core/index.ts | 2 +- .../src/web/assets/css/styles.tailwind.css | 106 +++- packages/scan/src/web/assets/svgs/svgs.ts | 19 + .../src/web/components/inspector/index.tsx | 522 ++++++++++-------- .../components/inspector/overlay/index.tsx | 11 +- .../web/components/inspector/overlay/utils.ts | 70 +-- .../scan/src/web/components/toggle/index.tsx | 26 + .../scan/src/web/components/widget/header.tsx | 87 ++- .../scan/src/web/components/widget/index.tsx | 60 +- .../src/web/components/widget/settings.tsx | 107 ++++ .../web/components/widget/toolbar/arrows.tsx | 95 +++- .../web/components/widget/toolbar/index.tsx | 61 +- packages/scan/src/web/constants.ts | 2 +- .../scan/src/web/hooks/use-mount-delay.ts | 58 ++ packages/scan/src/web/state.ts | 1 + packages/scan/tailwind.config.mjs | 22 +- 17 files changed, 849 insertions(+), 406 deletions(-) create mode 100644 packages/scan/src/web/components/toggle/index.tsx create mode 100644 packages/scan/src/web/components/widget/settings.tsx create mode 100644 packages/scan/src/web/hooks/use-mount-delay.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 447349ca..a4309ac4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "source.organizeImports.biome": "always", "quickfix.biome": "always" }, + "css.lint.unknownAtRules": "ignore", "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, @@ -20,5 +21,8 @@ "[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "[css]": { + "editor.defaultFormatter": "biomejs.biome" + } } diff --git a/packages/scan/src/core/index.ts b/packages/scan/src/core/index.ts index 63c7bd0b..aef571d4 100644 --- a/packages/scan/src/core/index.ts +++ b/packages/scan/src/core/index.ts @@ -790,7 +790,7 @@ export const ignoredProps = new WeakSet< >(); export const ignoreScan = (node: ReactNode) => { - if (typeof node === 'object' && node !== null) { + if (node && typeof node === 'object') { ignoredProps.add(node); } }; diff --git a/packages/scan/src/web/assets/css/styles.tailwind.css b/packages/scan/src/web/assets/css/styles.tailwind.css index b27568ae..fa8dc39f 100644 --- a/packages/scan/src/web/assets/css/styles.tailwind.css +++ b/packages/scan/src/web/assets/css/styles.tailwind.css @@ -111,7 +111,7 @@ svg { .resize-line { @apply absolute inset-0; @apply overflow-hidden; - @apply bg-black/90; + @apply bg-black; @apply transition-all duration-150; svg { @@ -205,7 +205,6 @@ svg { } } -/* HEADER */ .react-scan-header { @apply flex items-center gap-x-2; @apply pl-3 pr-2; @@ -220,7 +219,7 @@ svg { @apply p-1; @apply min-w-fit; @apply rounded; - @apply transition-colors duration-150; + @apply transition-all duration-300; } .react-scan-replay-button { @@ -264,16 +263,10 @@ svg { } } -.react-scan-inspector { - font-size: 13px; - color: #fff; - width: 100%; -} - .react-scan-section { - @apply flex flex-col py-1; + @apply flex flex-col; @apply py-2 px-4; - @apply bg-black text-[#888]; + @apply text-[#888]; @apply before:content-[attr(data-section)] before:text-gray-500; > .react-scan-property { @@ -340,14 +333,12 @@ svg { @apply transition-all duration-150; &.react-scan-expanded { - @apply pt-2; @apply grid-rows-[1fr]; } } .react-scan-nested { @apply relative; - /* @apply border-l-1 border-gray-500/30; */ @apply overflow-hidden; &:before { @@ -358,16 +349,15 @@ svg { } } -.react-scan-hidden { - display: none; -} +.react-scan-settings { + @apply flex flex-col gap-6; + @apply py-2 px-4; + @apply text-[#888]; -.react-scan-array-container { - overflow-y: auto; - @apply ml-5; - margin-top: 8px; - border-left: 1px solid rgba(255, 255, 255, 0.1); - padding-left: 8px; + >div { + @apply flex items-center justify-between; + @apply transition-colors duration-300; + } } .react-scan-preview-line { @@ -376,14 +366,56 @@ svg { } .react-scan-flash-overlay { - position: absolute; - inset: 0; - opacity: 0; - z-index: 999999; - mix-blend-mode: multiply; - background: rgba(142, 97, 227, 0.9); - transition: opacity 150ms ease-in; - pointer-events: none; + @apply absolute inset-0; + @apply opacity-0; + @apply z-[999999]; + @apply pointer-events-none; + @apply transition-opacity duration-150; + @apply mix-blend-multiply; + @apply bg-purple-500/90; +} + +.react-scan-toggle { + @apply relative; + + input { + @apply absolute inset-0; + @apply opacity-0 z-20; + @apply cursor-pointer; + } + + input:checked { + +div { + @apply bg-neutral-600; + + &::before { + @apply translate-x-full; + @apply left-auto; + @apply bg-[#8e61e3]; + @apply border-neutral-600; + } + } + } + + >div { + @apply relative; + @apply w-8 h-4; + @apply bg-neutral-700; + @apply rounded-full; + @apply pointer-events-none; + @apply transition-colors duration-300; + &:before { + @apply content-['']; + @apply absolute top-1/2 left-0; + @apply -translate-y-1/2; + @apply w-4 h-4; + @apply bg-neutral-500; + @apply border-2 border-neutral-700; + @apply rounded-full; + @apply shadow-sm; + @apply transition-all duration-300; + } + } } .react-scan-flash-active { @@ -403,3 +435,17 @@ svg { opacity: 1; } } + +.react-scan-what-changed { + ul { + @apply list-disc; + @apply pl-4; + } + + li { + @apply whitespace-nowrap; + > div { + @apply flex items-center justify-between gap-x-2; + } + } +} diff --git a/packages/scan/src/web/assets/svgs/svgs.ts b/packages/scan/src/web/assets/svgs/svgs.ts index b3f6ba0f..9e87a19d 100644 --- a/packages/scan/src/web/assets/svgs/svgs.ts +++ b/packages/scan/src/web/assets/svgs/svgs.ts @@ -98,5 +98,24 @@ export const ICONS = ` + + + + + + + + + + + + + + + + + + + `; diff --git a/packages/scan/src/web/components/inspector/index.tsx b/packages/scan/src/web/components/inspector/index.tsx index d6578989..61b382ec 100644 --- a/packages/scan/src/web/components/inspector/index.tsx +++ b/packages/scan/src/web/components/inspector/index.tsx @@ -100,9 +100,6 @@ interface EditableValueProps { type IterableEntry = [key: string | number, value: unknown]; -const THROTTLE_MS = 16; -const DEBOUNCE_MS = 150; - const globalInspectorState = { lastRendered: new Map(), expandedPaths: new Set(), @@ -110,7 +107,21 @@ const globalInspectorState = { globalInspectorState.lastRendered.clear(); globalInspectorState.expandedPaths.clear(); flashManager.cleanupAll(); - } + resetStateTracking(); + inspectorState.value = { + fiber: null, + changes: { + state: new Set(), + props: new Set(), + context: new Set(), + }, + current: { + state: {}, + props: {}, + context: {}, + }, + }; + }, }; const inspectorState = signal({ @@ -695,7 +706,9 @@ const PropertyElement = ({ parentPath ?? '', name, ); - const [isExpanded, setIsExpanded] = useState(globalInspectorState.expandedPaths.has(currentPath)); + const [isExpanded, setIsExpanded] = useState( + globalInspectorState.expandedPaths.has(currentPath), + ); const [isEditing, setIsEditing] = useState(false); const prevValue = globalInspectorState.lastRendered.get(currentPath); @@ -797,20 +810,27 @@ const PropertyElement = ({ ); }, [section, overrideProps, overrideHookState, allowEditing, name]); - const shouldShowWarning = useMemo(() => { + const isBadRender = useMemo(() => { + const isFirstRender = !globalInspectorState.lastRendered.has(currentPath); + + if (isFirstRender) { + if (typeof value === 'function') { + return true; + } + + if (typeof value !== 'object') { + return false; + } + } + const shouldShowChange = - !globalInspectorState.lastRendered.has(currentPath) || + !isFirstRender || !isEqual(globalInspectorState.lastRendered.get(currentPath), value); - const isBadRender = - level === 0 && - shouldShowChange && - typeof value === 'object' && - value !== null && - !isPromise(value); + const isBadRender = level === 0 && shouldShowChange && !isPromise(value); return isBadRender; - }, [level, currentPath, value]); + }, [currentPath, level, value]); const clipboardText = useMemo(() => formatForClipboard(value), [value]); @@ -909,42 +929,76 @@ const PropertyElement = ({ return (
-
- {isExpandable(value) && ( - - )} +
+ { + isExpandable(value) && ( + + ) + } +
- {shouldShowWarning && ( - - )} + { + isBadRender && + !changedKeys.has(`${name}:memoized`) && + !changedKeys.has(`${name}:unmemoized`) && ( + + ) + } + { + changedKeys.has(`${name}:memoized`) + ? ( + + ) + : ( + changedKeys.has(`${name}:unmemoized`) && ( + + ) + ) + }
{name}:
- {isEditing && isEditableValue(value, parentPath) ? ( - setIsEditing(false)} - /> - ) : ( - - )} + { + isEditing && isEditableValue(value, parentPath) + ? ( + setIsEditing(false)} + /> + ) + : ( + + ) + } <>{ClipboardIcon}}
- {isExpandable(value) && isExpanded && ( -
- {renderNestedProperties(value)} -
- )} +
+ { + isExpandable(value) && ( +
+ {renderNestedProperties(value)} +
+ ) + } +
); @@ -1018,48 +1083,91 @@ const PropertySection = ({ title, section }: PropertySectionProps) => { }; const WhatChanged = constant(() => { + const refPrevFiber = useRef(null); const [isExpanded, setIsExpanded] = useState(Store.wasDetailsOpen.value); const [shouldShow, setShouldShow] = useState(false); + const { changes, fiber } = inspectorState.value; - const refTimer = useRef(); - const refPrevFiber = useRef(null); - const hasChanges = - changes.state.size > 0 || - changes.props.size > 0 || - changes.context.size > 0; + const renderSection = useCallback(( + sectionName: 'state' | 'props' | 'context', + items: Set, + getCount: (key: string) => number, + ) => { + const elements = Array.from(items).reduce((acc, key) => { + if (sectionName === 'props') { + const isUnmemoized = key.endsWith(':unmemoized'); + if (isUnmemoized) { + acc.push( +
  • +
    + {key.split(':')[0]}{' '} + +
    +
  • , + ); + } + } - useEffect(() => { - const cleanup = () => { - clearTimeout(refTimer.current); - setShouldShow(false); + const count = getCount(key); + if (count > 0) { + const displayKey = + sectionName === 'context' ? key.replace(/^context\./, '') : key; + acc.push( +
  • + {displayKey} ×{count} +
  • , + ); + } + + return acc; + }, []); + + if (!elements.length) return null; + + return ( + <> +
    + {sectionName.charAt(0).toUpperCase() + sectionName.slice(1)}: +
    +
      {elements}
    + + ); + }, []); + + const stateSection = useMemo( + () => renderSection('state', changes.state, getStateChangeCount), + [changes.state, renderSection], + ); + + const propsSection = useMemo( + () => renderSection('props', changes.props, getPropsChangeCount), + [changes.props, renderSection], + ); + + const contextSection = useMemo( + () => renderSection('context', changes.context, getContextChangeCount), + [changes.context, renderSection], + ); + + const { hasChanges, sections } = useMemo(() => { + return { + hasChanges: !!(stateSection || propsSection || contextSection), + sections: [stateSection, propsSection, contextSection], }; + }, [stateSection, propsSection, contextSection]); - if (fiber && refPrevFiber.current && fiber.type !== refPrevFiber.current.type) { - cleanup(); + useEffect(() => { + if (!refPrevFiber.current || refPrevFiber.current.type !== fiber?.type) { refPrevFiber.current = fiber; + setShouldShow(false); return; } refPrevFiber.current = fiber; - - if (!hasChanges) { - cleanup(); - return; - } - - clearTimeout(refTimer.current); - refTimer.current = setTimeout(() => { - setShouldShow(true); - }, 32); - - return cleanup; + setShouldShow(hasChanges); }, [hasChanges, fiber]); - if (!hasChanges || !shouldShow) { - return null; - } - const handleToggle = useCallback(() => { setIsExpanded((state) => { Store.wasDetailsOpen.value = !state; @@ -1068,93 +1176,61 @@ const WhatChanged = constant(() => { }, []); return ( - + { + shouldShow && refPrevFiber.current?.type === fiber?.type && ( +
    e.key === 'Enter' && handleToggle()} + className={cn( + 'flex flex-col', + 'px-1 py-2', + 'text-left text-white', + 'bg-yellow-600', + 'overflow-hidden', + 'opacity-0', + 'transition-all duration-300 delay-300', + { + 'opacity-100 delay-0': shouldShow, + }, + )} + > +
    +
    + + + + What changed? +
    +
    +
    +
    {sections}
    +
    +
    + ) + } +
    ); }); @@ -1162,76 +1238,59 @@ export const Inspector = constant(() => { const refLastInspectedFiber = useRef(null); useEffect(() => { - let rafId: ReturnType; - let debounceTimer: ReturnType; - let lastUpdateTime = 0; let isProcessing = false; - let pendingFiber: Fiber | null = null; - let lastInspectedElement: Element | null = null; - - const updateInspectorState = (fiber: Fiber, element: Element) => { - const isNewComponent = !refLastInspectedFiber.current || - refLastInspectedFiber.current.type !== fiber.type; - const isNewElement = element !== lastInspectedElement; - - if (isNewComponent || isNewElement) { - resetStateTracking(); - lastInspectedElement = element; - globalInspectorState.cleanup(); - } + const pendingUpdates = new Set(); + + const updateInspectorState = (fiber: Fiber) => { + refLastInspectedFiber.current = fiber; inspectorState.value = { fiber, changes: { - props: isNewElement ? new Set() : getChangedProps(fiber), - state: isNewElement ? new Set() : getChangedState(fiber), - context: isNewElement ? new Set() : getChangedContext(fiber), + props: getChangedProps(fiber), + state: getChangedState(fiber), + context: getChangedContext(fiber), }, current: { state: getCurrentState(fiber), props: getCurrentProps(fiber), context: getCurrentContext(fiber), - }, + } }; - - refLastInspectedFiber.current = fiber; }; - const processFiberUpdate = (fiber: Fiber, element: Element) => { - const now = Date.now(); - const timeSinceLastUpdate = now - lastUpdateTime; - - clearTimeout(debounceTimer); - cancelAnimationFrame(rafId); - - if (timeSinceLastUpdate < THROTTLE_MS) { - pendingFiber = fiber; - debounceTimer = setTimeout(() => { - rafId = requestAnimationFrame(() => { - if (pendingFiber) { - isProcessing = true; - updateInspectorState(pendingFiber, element); - isProcessing = false; - pendingFiber = null; - lastUpdateTime = Date.now(); - } - }); - }, DEBOUNCE_MS); + const processNextUpdate = () => { + if (pendingUpdates.size === 0) { + isProcessing = false; return; } - rafId = requestAnimationFrame(() => { + const nextFiber = Array.from(pendingUpdates)[0]; + pendingUpdates.delete(nextFiber); + + try { + updateInspectorState(nextFiber); + } finally { + if (pendingUpdates.size > 0) { + queueMicrotask(processNextUpdate); + } else { + isProcessing = false; + } + } + }; + + const processFiberUpdate = (fiber: Fiber) => { + pendingUpdates.add(fiber); + + if (!isProcessing) { isProcessing = true; - updateInspectorState(fiber, element); - isProcessing = false; - lastUpdateTime = now; - }); + queueMicrotask(processNextUpdate); + } }; const unSubState = Store.inspectState.subscribe((state) => { if (state.kind !== 'focused' || !state.focusedDomElement) { - clearTimeout(debounceTimer); - cancelAnimationFrame(rafId); + pendingUpdates.clear(); return; } @@ -1240,16 +1299,34 @@ export const Inspector = constant(() => { ); if (!parentCompositeFiber) return; - processFiberUpdate(parentCompositeFiber, state.focusedDomElement); + pendingUpdates.clear(); + globalInspectorState.cleanup(); + refLastInspectedFiber.current = parentCompositeFiber; + + getChangedProps(parentCompositeFiber); + getChangedState(parentCompositeFiber); + getChangedContext(parentCompositeFiber); + + inspectorState.value = { + fiber: parentCompositeFiber, + changes: { + props: new Set(), + state: new Set(), + context: new Set(), + }, + current: { + state: getCurrentState(parentCompositeFiber), + props: getCurrentProps(parentCompositeFiber), + context: getCurrentContext(parentCompositeFiber), + } + }; + }); const unSubReport = Store.lastReportTime.subscribe(() => { - if (isProcessing) return; - const inspectState = Store.inspectState.value; if (inspectState.kind !== 'focused') { - clearTimeout(debounceTimer); - cancelAnimationFrame(rafId); + pendingUpdates.clear(); return; } @@ -1263,19 +1340,17 @@ export const Inspector = constant(() => { return; } - if (parentCompositeFiber && refLastInspectedFiber.current) { - processFiberUpdate(parentCompositeFiber, element); + if (parentCompositeFiber.type === refLastInspectedFiber.current?.type) { + processFiberUpdate(parentCompositeFiber); } }); return () => { unSubState(); unSubReport(); - clearTimeout(debounceTimer); - cancelAnimationFrame(rafId); - pendingFiber = null; - lastInspectedElement = null; + pendingUpdates.clear(); globalInspectorState.cleanup(); + resetStateTracking(); }; }, []); @@ -1290,6 +1365,7 @@ export const Inspector = constant(() => { ); }); + export const replayComponent = async (fiber: Fiber): Promise => { try { const { overrideProps, overrideHookState } = getOverrideMethods(); diff --git a/packages/scan/src/web/components/inspector/overlay/index.tsx b/packages/scan/src/web/components/inspector/overlay/index.tsx index 1cd93bdb..8ea92917 100644 --- a/packages/scan/src/web/components/inspector/overlay/index.tsx +++ b/packages/scan/src/web/components/inspector/overlay/index.tsx @@ -7,6 +7,7 @@ import { getCompositeComponentFromElement, nonVisualTags, } from '~web/components/inspector/utils'; +import { signalIsSettingsOpen } from '~web/state'; import { cn, throttle } from '~web/utils/helpers'; import { lerp } from '~web/utils/lerp'; @@ -326,8 +327,9 @@ export const ScanOverlay = () => { !refCanvas.current || e.propertyName !== 'opacity' || !refIsFadingOut.current - ) + ) { return; + } refCanvas.current.removeEventListener( 'transitionend', handleTransitionEnd, @@ -335,7 +337,6 @@ export const ScanOverlay = () => { cleanupCanvas(refCanvas.current); onComplete?.(); }; - const existingListener = refCleanupMap.current.get('fade-out'); if (existingListener) { existingListener(); @@ -352,7 +353,9 @@ export const ScanOverlay = () => { refIsFadingOut.current = true; refCanvas.current.classList.remove('fade-in'); - refCanvas.current.classList.add('fade-out'); + setTimeout(() => { + refCanvas.current?.classList.add('fade-out'); + }, 100); }; const startFadeIn = () => { @@ -509,6 +512,7 @@ export const ScanOverlay = () => { } case 'inspecting': { startFadeOut(() => { + signalIsSettingsOpen.value = false; Store.inspectState.value = { kind: 'inspect-off', }; @@ -539,6 +543,7 @@ export const ScanOverlay = () => { switch (state.kind) { case 'inspect-off': + startFadeOut(); return; case 'inspecting': diff --git a/packages/scan/src/web/components/inspector/overlay/utils.ts b/packages/scan/src/web/components/inspector/overlay/utils.ts index 684099c9..63fe9fe2 100644 --- a/packages/scan/src/web/components/inspector/overlay/utils.ts +++ b/packages/scan/src/web/components/inspector/overlay/utils.ts @@ -148,12 +148,8 @@ export const isDirectComponent = (fiber: Fiber): boolean => { export const getCurrentState = (fiber: Fiber | null) => { if (!fiber) return {}; - try { - if (fiber.tag === FunctionComponentTag && isDirectComponent(fiber)) { - return getCurrentFiberState(fiber) ?? {}; - } - } catch { - // Silently fail + if (fiber.tag === FunctionComponentTag && isDirectComponent(fiber)) { + return getCurrentFiberState(fiber) ?? {}; } return {}; }; @@ -273,42 +269,50 @@ export const getCurrentProps = (fiber: Fiber): Record => { fiber.alternate?.pendingProps || fiber.memoizedProps; - return { ...baseProps }; -}; - -export const getChangedProps = (fiber: Fiber): Set => { - const changes = new Set(); - if (!fiber.alternate) return changes; - - const previousProps = fiber.alternate.memoizedProps ?? {}; - const currentProps = fiber.memoizedProps ?? {}; + const result: Record = {}; - const propsOrder = getPropsOrder(fiber); - const orderedProps = [...propsOrder, ...Object.keys(currentProps)]; - const uniqueOrderedProps = [...new Set(orderedProps)]; + for (const [key, value] of Object.entries(baseProps)) { + result[key] = value; + if ((value && typeof value === 'object') || typeof value === 'function') { + if (fiber.alternate?.memoizedProps) { + const prevValue = fiber.alternate.memoizedProps[key]; + const status = value === prevValue ? 'memoized' : 'unmemoized'; + propsChangeCounts.set(`${key}:${status}`, 0); + } + } + } - for (const key of uniqueOrderedProps) { - if (key === 'children') continue; - if (!(key in currentProps)) continue; + return result; +}; - const currentValue = currentProps[key]; - const previousValue = previousProps[key]; +export const getChangedProps = (fiber: Fiber | null): Set => { + if (!fiber?.memoizedProps) return new Set(); - if (!isEqual(currentValue, previousValue)) { - changes.add(key); + const currentProps = fiber.memoizedProps; + const changes = new Set(); - if (typeof currentValue !== 'function') { - const count = (propsChangeCounts.get(key) ?? 0) + 1; - propsChangeCounts.set(key, count); + for (const [key, currentValue] of Object.entries(currentProps)) { + // Track memoization for non-primitive values (functions, objects, arrays) + if ( + (currentValue && typeof currentValue === 'object') || + typeof currentValue === 'function' + ) { + if (fiber.alternate?.memoizedProps) { + const prevValue = fiber.alternate.memoizedProps[key]; + const status = currentValue === prevValue ? 'memoized' : 'unmemoized'; + changes.add(`${key}:${status}`); } + continue; } - } - for (const key in previousProps) { - if (key === 'children') continue; - if (!(key in currentProps)) { + // Track changes for primitive values + if ( + fiber.alternate?.memoizedProps && + key in fiber.alternate.memoizedProps && + !isEqual(fiber.alternate.memoizedProps[key], currentValue) + ) { changes.add(key); - const count = (propsChangeCounts.get(key) ?? 0) + 1; + const count = (propsChangeCounts.get(key) || 0) + 1; propsChangeCounts.set(key, count); } } diff --git a/packages/scan/src/web/components/toggle/index.tsx b/packages/scan/src/web/components/toggle/index.tsx new file mode 100644 index 00000000..dc457525 --- /dev/null +++ b/packages/scan/src/web/components/toggle/index.tsx @@ -0,0 +1,26 @@ +import type { JSX } from 'preact'; +import { cn } from '~web/utils/helpers'; + +type ToggleProps = Omit, 'className' | 'onChange'> & { + checked: boolean; + onChange: ((e: Event) => void); +}; + +export const Toggle = ({ + checked, + onChange, + class: className, + ...props +}: ToggleProps) => { + return ( +
    + +
    +
    + ); +}; diff --git a/packages/scan/src/web/components/widget/header.tsx b/packages/scan/src/web/components/widget/header.tsx index 9dd250f6..777ef3f5 100644 --- a/packages/scan/src/web/components/widget/header.tsx +++ b/packages/scan/src/web/components/widget/header.tsx @@ -1,7 +1,9 @@ import { getDisplayName } from 'bippy'; -import { useEffect, useRef } from 'preact/hooks'; +import { useEffect, useRef, useState } from 'preact/hooks'; import { Store } from '~core/index'; import { replayComponent } from '~web/components/inspector'; +import { signalIsSettingsOpen } from '~web/state'; +import { cn } from '~web/utils/helpers'; import { Icon } from '../icon'; import { getCompositeComponentFromElement, @@ -19,11 +21,22 @@ export const BtnReplay = () => { }, }); - const { overrideProps, overrideHookState } = getOverrideMethods(); - const canEdit = !!overrideProps; + const [canEdit, setCanEdit] = useState(false); + const isSettingsOpen = signalIsSettingsOpen.value; + + useEffect(() => { + const { overrideProps } = getOverrideMethods(); + const canEdit = !!overrideProps; + + requestAnimationFrame(() => { + setCanEdit(canEdit); + }); + }, []); + const handleReplay = (e: MouseEvent) => { e.stopPropagation(); + const { overrideProps, overrideHookState } = getOverrideMethods(); const state = replayState.current; const button = e.currentTarget as HTMLElement; @@ -60,8 +73,13 @@ export const BtnReplay = () => { @@ -86,11 +104,13 @@ const useSubscribeFocusedFiber = (onUpdate: () => void) => { }, []); }; -export const Header = () => { +const HeaderInspect = () => { const refRaf = useRef(null); const refComponentName = useRef(null); const refMetrics = useRef(null); + const isSettingsOpen = signalIsSettingsOpen.value; + useSubscribeFocusedFiber(() => { cancelAnimationFrame(refRaf.current ?? 0); refRaf.current = requestAnimationFrame(() => { @@ -119,7 +139,52 @@ export const Header = () => { }); }); + return ( +
    + + +
    + ); +}; + +const HeaderSettings = () => { + const isSettingsOpen = signalIsSettingsOpen.value; + return ( + + ); +}; + +export const Header = () => { const handleClose = () => { + if (signalIsSettingsOpen.value) { + signalIsSettingsOpen.value = false; + return; + } + Store.inspectState.value = { kind: 'inspect-off', }; @@ -127,13 +192,11 @@ export const Header = () => { return (
    - - - +
    + + +
    + {Store.inspectState.value.kind !== 'inspect-off' && }
    -
    - -
    +
    diff --git a/packages/scan/src/web/components/widget/settings.tsx b/packages/scan/src/web/components/widget/settings.tsx new file mode 100644 index 00000000..eb3c1e52 --- /dev/null +++ b/packages/scan/src/web/components/widget/settings.tsx @@ -0,0 +1,107 @@ +import { useCallback } from 'preact/hooks'; +import { ReactScanInternals, setOptions } from '~core/index'; +import { Toggle } from '~web/components/toggle'; +import { useDelayedValue } from '~web/hooks/use-mount-delay'; +import { signalIsSettingsOpen } from '~web/state'; +import { cn } from '~web/utils/helpers'; + +export const Settings = () => { + const isSettingsOpen = signalIsSettingsOpen.value; + const isMounted = useDelayedValue(isSettingsOpen, 0, 1000); + + const onSoundToggle = useCallback(() => { + const newSoundState = !ReactScanInternals.options.value.playSound; + setOptions({ playSound: newSoundState }); + }, []); + + const onToggle = useCallback((e: Event) => { + const target = e.currentTarget as HTMLInputElement; + const type = target.dataset.type; + const value = target.checked; + + if (type) { + setOptions({ [type]: value }); + } + }, []); + + return ( +
    + {isMounted && ( + <> +
    + Play Sound + +
    + +
    + Include Children + +
    + +
    + Log renders to the console + +
    + +
    + Report data to getReport() + +
    + +
    + Always show labels + +
    + +
    + Show labels on hover + +
    + +
    + Track unnecessary renders + +
    + + )} +
    + ); +}; diff --git a/packages/scan/src/web/components/widget/toolbar/arrows.tsx b/packages/scan/src/web/components/widget/toolbar/arrows.tsx index 8647b688..24e45ed5 100644 --- a/packages/scan/src/web/components/widget/toolbar/arrows.tsx +++ b/packages/scan/src/web/components/widget/toolbar/arrows.tsx @@ -5,6 +5,7 @@ import { type InspectableElement, getInspectableElements, } from '~web/components/inspector/utils'; +import { useDelayedValue } from '~web/hooks/use-mount-delay'; import { cn } from '~web/utils/helpers'; import { constant } from '~web/utils/preact/constant'; @@ -14,6 +15,7 @@ export const Arrows = constant(() => { const refAllElements = useRef>([]); const [shouldRender, setShouldRender] = useState(false); + const isMounted = useDelayedValue(shouldRender, 0, 1000); const findNextElement = useCallback( (currentElement: HTMLElement, direction: 'next' | 'previous') => { @@ -64,39 +66,59 @@ export const Arrows = constant(() => { // biome-ignore lint/correctness/useExhaustiveDependencies: no deps useEffect(() => { const unsubscribe = Store.inspectState.subscribe((state) => { - if (state.kind === 'focused') { + + if (state.kind === 'focused' && refButtonPrevious.current && refButtonNext.current) { refAllElements.current = getInspectableElements(); + + const hasPrevious = !!findNextElement( + state.focusedDomElement, + 'previous', + ); + refButtonPrevious.current.classList.toggle( + 'opacity-50', + !hasPrevious, + ); + refButtonPrevious.current.classList.toggle( + 'cursor-not-allowed', + !hasPrevious, + ); + + const hasNext = !!findNextElement(state.focusedDomElement, 'next'); + refButtonNext.current.classList.toggle( + 'opacity-50', + !hasNext, + ); + refButtonNext.current.classList.toggle( + 'cursor-not-allowed', + !hasNext, + ); + setShouldRender(true); - if (refButtonPrevious.current) { - const hasPrevious = !!findNextElement( - state.focusedDomElement, - 'previous', - ); - refButtonPrevious.current.classList.toggle( - 'opacity-50', - !hasPrevious, - ); - refButtonPrevious.current.classList.toggle( - 'cursor-not-allowed', - !hasPrevious, - ); - } - if (refButtonNext.current) { - const hasNext = !!findNextElement(state.focusedDomElement, 'next'); - refButtonNext.current.classList.toggle('opacity-50', !hasNext); - refButtonNext.current.classList.toggle( - 'cursor-not-allowed', - !hasNext, - ); - } } - if (state.kind === 'inspecting') { + if (state.kind === 'inspecting' && refButtonPrevious.current && refButtonNext.current) { + refButtonPrevious.current.classList.toggle( + 'opacity-50', + true, + ); + refButtonPrevious.current.classList.toggle( + 'cursor-not-allowed', + true, + ); + refButtonNext.current.classList.toggle( + 'opacity-50', + true, + ); + refButtonNext.current.classList.toggle( + 'cursor-not-allowed', + true, + ); setShouldRender(true); } if (state.kind === 'inspect-off') { refAllElements.current = []; + setShouldRender(false); } if (state.kind === 'uninitialized') { @@ -111,15 +133,17 @@ export const Arrows = constant(() => { }; }, []); - if (!shouldRender) return null; - return (
    @@ -136,7 +167,13 @@ export const Arrows = constant(() => { ref={refButtonNext} title="Next element" onClick={onNextFocus} - className="flex cursor-not-allowed items-center justify-center px-3 opacity-50" + className={cn( + 'button', + 'flex items-center justify-center', + 'px-3 opacity-50', + 'transition-all duration-300', + 'cursor-not-allowed', + )} > diff --git a/packages/scan/src/web/components/widget/toolbar/index.tsx b/packages/scan/src/web/components/widget/toolbar/index.tsx index fd49ff54..dcad17d8 100644 --- a/packages/scan/src/web/components/widget/toolbar/index.tsx +++ b/packages/scan/src/web/components/widget/toolbar/index.tsx @@ -1,14 +1,16 @@ -import { useCallback, useEffect } from 'preact/hooks'; -import { ReactScanInternals, Store, setOptions } from '~core/index'; +import { useCallback, useEffect, useRef } from 'preact/hooks'; +import { ReactScanInternals, Store } from '~core/index'; import { Icon } from '~web/components/icon'; import FpsMeter from '~web/components/widget/fps-meter'; import { Arrows } from '~web/components/widget/toolbar/arrows'; +import { signalIsSettingsOpen } from '~web/state'; import { cn } from '~web/utils/helpers'; import { constant } from '~web/utils/preact/constant'; export const Toolbar = constant(() => { - const inspectState = Store.inspectState; + const refSettingsButton = useRef(null); + const inspectState = Store.inspectState; const isInspectActive = inspectState.value.kind === 'inspecting'; const isInspectFocused = inspectState.value.kind === 'focused'; @@ -44,13 +46,12 @@ export const Toolbar = constant(() => { } }, []); - const onSoundToggle = useCallback(() => { - const newSoundState = !ReactScanInternals.options.value.playSound; - setOptions({ playSound: newSoundState }); + const onToggleSettings = useCallback(() => { + signalIsSettingsOpen.value = !signalIsSettingsOpen.value; }, []); useEffect(() => { - const unsubscribe = Store.inspectState.subscribe((state) => { + const unSubState = Store.inspectState.subscribe((state) => { if (state.kind === 'uninitialized') { Store.inspectState.value = { kind: 'inspect-off', @@ -58,8 +59,13 @@ export const Toolbar = constant(() => { } }); + const unSubSettings = signalIsSettingsOpen.subscribe((state) => { + refSettingsButton.current?.classList.toggle('text-inspect', state); + }); + return () => { - unsubscribe(); + unSubState(); + unSubSettings(); }; }, []); @@ -68,17 +74,22 @@ export const Toolbar = constant(() => { if (isInspectActive) { inspectIcon = ; - inspectColor = 'rgba(142, 97, 227, 1)'; + inspectColor = '#8e61e3'; } else if (isInspectFocused) { inspectIcon = ; - inspectColor = 'rgba(142, 97, 227, 1)'; + inspectColor = '#8e61e3'; } else { inspectIcon = ; inspectColor = '#999'; } return ( -
    +
    react-scan diff --git a/packages/scan/src/web/constants.ts b/packages/scan/src/web/constants.ts index 07c83175..240a9400 100644 --- a/packages/scan/src/web/constants.ts +++ b/packages/scan/src/web/constants.ts @@ -1,6 +1,6 @@ export const SAFE_AREA = 24; export const MIN_SIZE = { - width: 360, + width: 320, height: 36, } as const; diff --git a/packages/scan/src/web/hooks/use-mount-delay.ts b/packages/scan/src/web/hooks/use-mount-delay.ts new file mode 100644 index 00000000..a3337aa9 --- /dev/null +++ b/packages/scan/src/web/hooks/use-mount-delay.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; + +/** + * Delays a boolean value change by a specified duration. + * Perfect for coordinating animations with state changes. + * + * @param {boolean} value - The boolean value to delay + * @param {number} onDelay - Milliseconds to wait before changing to true + * @param {number} [offDelay] - Milliseconds to wait before changing to false (defaults to onDelay) + * @returns {boolean} The delayed value + * + * @example + * // Delay both transitions by 300ms + * const isVisible = useDelayedValue(show, 300); + * + * @example + * // Quick show (100ms), slow hide (500ms) + * const isVisible = useDelayedValue(show, 100, 500); + * + * @example + * // Use with CSS transitions + * const isVisible = useDelayedValue(show, 300); + * return ( + *
    + * {content} + *
    + * ); + */ +export const useDelayedValue = ( + value: boolean, + onDelay: number, + offDelay: number = onDelay, +): boolean => { + const refTimeout = useRef(); + const [delayedValue, setDelayedValue] = useState(value); + + /* + * biome-ignore lint/correctness/useExhaustiveDependencies: + * delayedValue is intentionally omitted to prevent unnecessary timeouts + * and used only in the early return check + */ + useEffect(() => { + if (value === delayedValue) return; + + const delay = value ? onDelay : offDelay; + refTimeout.current = setTimeout(() => setDelayedValue(value), delay); + + return () => clearTimeout(refTimeout.current); + }, [value, onDelay, offDelay]); + + return delayedValue; +}; diff --git a/packages/scan/src/web/state.ts b/packages/scan/src/web/state.ts index 85afa553..94d6bd47 100644 --- a/packages/scan/src/web/state.ts +++ b/packages/scan/src/web/state.ts @@ -7,6 +7,7 @@ import type { import { LOCALSTORAGE_KEY, MIN_SIZE, SAFE_AREA } from './constants'; import { readLocalStorage, saveLocalStorage } from './utils/helpers'; +export const signalIsSettingsOpen = signal(false); export const signalRefWidget = signal(null); export const defaultWidgetConfig = { diff --git a/packages/scan/tailwind.config.mjs b/packages/scan/tailwind.config.mjs index 42de4b57..a2b19ed6 100644 --- a/packages/scan/tailwind.config.mjs +++ b/packages/scan/tailwind.config.mjs @@ -7,17 +7,27 @@ export default { theme: { extend: { fontFamily: { - mono: ['Menlo', 'Consolas', 'Monaco', 'Liberation Mono', 'Lucida Console', 'monospace'], + mono: [ + 'Menlo', + 'Consolas', + 'Monaco', + 'Liberation Mono', + 'Lucida Console', + 'monospace', + ], + }, + colors: { + inspect: '#8e61e3', }, fontSize: { - 'xxs': '0.5rem', + xxs: '0.5rem', }, cursor: { 'nwse-resize': 'nwse-resize', 'nesw-resize': 'nesw-resize', 'ns-resize': 'ns-resize', 'ew-resize': 'ew-resize', - 'move': 'move', + move: 'move', }, keyframes: { fadeIn: { @@ -43,8 +53,8 @@ export default { animation: { 'fade-in': 'fadeIn ease-in forwards', 'fade-out': 'fadeOut ease-out forwards', - 'rotate': 'rotate linear infinite', - 'shake': 'shake 0.4s ease-in-out forwards', + rotate: 'rotate linear infinite', + shake: 'shake 0.4s ease-in-out forwards', }, zIndex: { 100: 100, @@ -59,7 +69,7 @@ export default { 'cursor-nesw-resize', 'cursor-ns-resize', 'cursor-ew-resize', - 'cursor-move' + 'cursor-move', ], plugins: [ ({ addUtilities }) => {