diff --git a/craft/src/editor/EditorShell.tsx b/craft/src/editor/EditorShell.tsx index d09b81f..5fa112c 100644 --- a/craft/src/editor/EditorShell.tsx +++ b/craft/src/editor/EditorShell.tsx @@ -11,6 +11,7 @@ import { useContextMenu } from '../hooks/useContextMenu'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { useIsMobile } from '../hooks/useIsMobile'; import { MobileChromeProvider } from '../state/MobileChromeContext'; +import { LayerFocusProvider } from '../panels/left/LayerFocusContext'; import { DeviceMode } from '../types'; const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides'; @@ -83,30 +84,32 @@ export const EditorShell: React.FC = () => { // doesn't render MobilePanelBar and TopBar's desktop branch behaves // identically to before (same booleans, just sourced from context). -
- setShowGuides(!showGuides)} - /> -
- {!isMobile && } -
- + +
+ setShowGuides(!showGuides)} + /> +
+ {!isMobile && } +
+ +
+ {!isMobile && }
- {!isMobile && } + {isMobile && } + {isMobile && } +
- {isMobile && } - {isMobile && } - -
+ ); }; diff --git a/craft/src/panels/left/LayerFocusContext.test.tsx b/craft/src/panels/left/LayerFocusContext.test.tsx new file mode 100644 index 0000000..98e35a3 --- /dev/null +++ b/craft/src/panels/left/LayerFocusContext.test.tsx @@ -0,0 +1,59 @@ +import { describe, test, expect } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { LayerFocusProvider, useLayerFocus } from './LayerFocusContext'; + +let container: HTMLDivElement; +let root: Root; + +const Probe: React.FC = () => { + const { focus, requestFocus } = useLayerFocus(); + return ( +
+ + {focus ? `${focus.nodeId}:${focus.prop}:${focus.index}:${focus.nonce}` : 'none'} +
+ ); +}; + +function render() { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(); + }); +} + +describe('LayerFocusContext', () => { + test('starts with no focus request', () => { + render(); + expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('none'); + }); + + test('requestFocus publishes the target', () => { + render(); + act(() => { (container.querySelector('button') as HTMLButtonElement).click(); }); + expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:1'); + }); + + test('repeating the same request bumps the nonce so consumers re-fire', () => { + render(); + const btn = container.querySelector('button') as HTMLButtonElement; + act(() => { btn.click(); }); + act(() => { btn.click(); }); + expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:2'); + }); + + test('useLayerFocus outside a provider is inert rather than a crash', () => { + container = document.createElement('div'); + document.body.appendChild(container); + expect(() => { + act(() => { + root = createRoot(container); + root.render(); + }); + }).not.toThrow(); + }); +}); diff --git a/craft/src/panels/left/LayerFocusContext.tsx b/craft/src/panels/left/LayerFocusContext.tsx new file mode 100644 index 0000000..a39cb0e --- /dev/null +++ b/craft/src/panels/left/LayerFocusContext.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; + +/** + * Carries "the user clicked a virtual Layers row -- scroll that array item's + * card into view" from the Layers panel (left) to the array editors (right). + * + * Deliberately a request, not a command: consumers that don't implement + * scrolling simply ignore it. Selecting the parent node always happens in + * LayersPanel itself, so a click is useful even with no consumer at all. + */ + +export interface LayerFocusRequest { + nodeId: string; + prop: string; + index: number; + /** Bumped on every request so an identical repeat still re-fires effects. */ + nonce: number; +} + +export interface LayerFocusValue { + focus: LayerFocusRequest | null; + requestFocus(nodeId: string, prop: string, index: number): void; +} + +const LayerFocusContext = createContext({ + focus: null, + requestFocus: () => {}, +}); + +export const useLayerFocus = (): LayerFocusValue => useContext(LayerFocusContext); + +export const LayerFocusProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [focus, setFocus] = useState(null); + const nonceRef = useRef(0); + + const requestFocus = useCallback((nodeId: string, prop: string, index: number) => { + nonceRef.current += 1; + setFocus({ nodeId, prop, index, nonce: nonceRef.current }); + }, []); + + const value = useMemo(() => ({ focus, requestFocus }), [focus, requestFocus]); + + return {children}; +};