feat(site-builder): add LayerFocusContext for layers-to-array-editor focus

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 07:08:26 -07:00
co-authored by Claude Opus 5
parent f30fc6efec
commit f9561c8c54
3 changed files with 128 additions and 22 deletions
+25 -22
View File
@@ -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).
<MobileChromeProvider>
<div className="editor-app">
<TopBar
device={device}
onDeviceChange={setDevice}
showGuides={showGuides}
onToggleGuides={() => setShowGuides(!showGuides)}
/>
<div className="editor-container">
{!isMobile && <LeftPanel />}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Canvas device={device} showGuides={showGuides} />
<LayerFocusProvider>
<div className="editor-app">
<TopBar
device={device}
onDeviceChange={setDevice}
showGuides={showGuides}
onToggleGuides={() => setShowGuides(!showGuides)}
/>
<div className="editor-container">
{!isMobile && <LeftPanel />}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Canvas device={device} showGuides={showGuides} />
</div>
{!isMobile && <RightPanel />}
</div>
{!isMobile && <RightPanel />}
{isMobile && <MobilePanelBar />}
{isMobile && <MobileSelectionToolbar />}
<ContextMenu
visible={menuState.visible}
x={menuState.x}
y={menuState.y}
nodeId={menuState.nodeId}
onClose={hideMenu}
/>
</div>
{isMobile && <MobilePanelBar />}
{isMobile && <MobileSelectionToolbar />}
<ContextMenu
visible={menuState.visible}
x={menuState.x}
y={menuState.y}
nodeId={menuState.nodeId}
onClose={hideMenu}
/>
</div>
</LayerFocusProvider>
</MobileChromeProvider>
);
};
@@ -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 (
<div>
<button onClick={() => requestFocus('n1', 'features', 2)}>request</button>
<span data-testid="state">{focus ? `${focus.nodeId}:${focus.prop}:${focus.index}:${focus.nonce}` : 'none'}</span>
</div>
);
};
function render() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<LayerFocusProvider><Probe /></LayerFocusProvider>);
});
}
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(<Probe />);
});
}).not.toThrow();
});
});
@@ -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<LayerFocusValue>({
focus: null,
requestFocus: () => {},
});
export const useLayerFocus = (): LayerFocusValue => useContext(LayerFocusContext);
export const LayerFocusProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [focus, setFocus] = useState<LayerFocusRequest | null>(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 <LayerFocusContext.Provider value={value}>{children}</LayerFocusContext.Provider>;
};