Files
site-builder/craft/src/panels/left/LayersPanel.tsx
T
shadowdaoandClaude Opus 5.5 9697dc95a3 feat(editor): wire business panel, section commands and Layers follow-ups
- GuidedStyles classifies Opening Hours / Call Bar / Reviews / Booking to
  BusinessStylePanel (#60) and shows a Regenerate button in the type
  header, hidden when edit.regenerate-section's when() is false (#58)
- LayersPanel TYPE_ICONS for the four business blocks (FA 4.7) (#60)
- Context menu entries 'Save as section…' and 'Regenerate this section'
  invoke site.save-as-section / edit.regenerate-section (#52, #58)
- commands: useCommandState(id) hook (registered/enabled/run)
- Layers: keep dropEffect 'move' on refused targets so drop fires and the
  refusal notice shows; renaming to the default name stores no
  custom.label (#47)

Refs #60
Refs #58
Refs #52
Refs #47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:45:16 -07:00

631 lines
24 KiB
TypeScript

import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { useEditor } from '@craftjs/core';
import { useNotices } from '../../state/NoticeContext';
import { getAncestorIds } from '../../utils/craft-helpers';
import { canMove, computeDrop, moveLayer, type DropWhere } from './layers-dnd';
import { clickableProps } from '../../utils/a11y';
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
import { useLayerFocus } from './LayerFocusContext';
import { findUnreachableNodeIds } from '../../utils/orphan-repair';
/**
* Per-type icon lookup keyed by the component's `craft.displayName` (the
* same human label `LayerNode` already resolves and shows as the row text).
* Mirrors the icon choices in `BlocksPanel.tsx` where a block exists for the
* type; the remaining entries (Navbar, Container, Section, Columns,
* Background Section, Map, Video, Image, form fields, and the richer
* section blocks not draggable from the Basic/Sections categories) get a
* sensible FA4 icon of their own. Unknown/future types fall back to
* `DEFAULT_ICON`.
*/
const TYPE_ICONS: Record<string, string> = {
// Layout
Container: 'fa-square-o',
Section: 'fa-window-maximize',
Columns: 'fa-th-large',
'Background Section': 'fa-picture-o',
// Basic
Heading: 'fa-header',
Text: 'fa-paragraph',
Button: 'fa-square',
Logo: 'fa-bookmark',
Menu: 'fa-bars',
Navbar: 'fa-bars',
Footer: 'fa-window-minimize',
Divider: 'fa-minus',
Spacer: 'fa-arrows-v',
Icon: 'fa-star',
'Star Rating': 'fa-star-half-o',
'Social Links': 'fa-share-alt',
'Search Bar': 'fa-search',
HTML: 'fa-code',
// Media
Image: 'fa-image',
Video: 'fa-play-circle',
Map: 'fa-map-marker',
// Sections
Hero: 'fa-star-o',
'Features Grid': 'fa-th-large',
'CTA Section': 'fa-bullhorn',
'Call to Action': 'fa-bullhorn',
Countdown: 'fa-clock-o',
Testimonials: 'fa-quote-left',
'Content Slider': 'fa-sliders',
'Number Counter': 'fa-sort-numeric-asc',
Accordion: 'fa-list',
Tabs: 'fa-folder-o',
'Pricing Table': 'fa-usd',
Gallery: 'fa-th',
// Forms
Form: 'fa-wpforms',
Input: 'fa-i-cursor',
Textarea: 'fa-align-left',
'Submit Button': 'fa-paper-plane',
'Contact Form': 'fa-envelope',
'Subscribe Form': 'fa-paper-plane',
// Local business (#60)
'Opening Hours': 'fa-clock-o',
'Call Bar': 'fa-phone',
Reviews: 'fa-star',
Booking: 'fa-calendar',
};
const DEFAULT_ICON = 'fa-cube';
const ROOT_ICON = 'fa-desktop';
/**
* A display-only row for one item of a composite's array prop (a Features
* Grid feature, a Tabs tab, ...). Not a Craft node: it can't be dragged or
* deleted. Clicking it selects the PARENT and asks the array editor to
* scroll that item's card into view.
*/
const VirtualRowNode: React.FC<{
parentId: string;
prop: string;
index: number;
label: string;
depth: number;
onActivate: () => void;
}> = ({ index, label, depth, onActivate }) => (
<div
{...clickableProps(onActivate)}
className="layer-virtual-row"
title={label}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
paddingLeft: `${8 + depth * 16}px`,
fontSize: 11,
color: 'var(--color-text-dim)',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'none',
}}
>
<span style={{ marginRight: 6, fontSize: 8, flexShrink: 0 }} aria-hidden="true">&#9642;</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.6, flexShrink: 0 }}>{index + 1}</span>
</div>
);
/**
* Panel-local UI state (collapsed ids, drag/drop line, inline rename). Lives
* outside the Craft store AND outside React state on purpose: rows subscribe
* to a primitive slice of it, so a dragover only re-renders the two rows whose
* slice changed instead of the whole tree (#40's isolation property).
*/
interface LayersUiState {
collapsed: ReadonlySet<string>;
dropTargetId: string | null;
dropWhere: DropWhere | null;
draggingId: string | null;
renamingId: string | null;
}
export interface LayersUi {
get(): LayersUiState;
set(patch: Partial<LayersUiState>): void;
subscribe(cb: () => void): () => void;
}
export function createLayersUi(): LayersUi {
let state: LayersUiState = {
collapsed: new Set(), dropTargetId: null, dropWhere: null, draggingId: null, renamingId: null,
};
const subs = new Set<() => void>();
return {
get: () => state,
set(patch) {
const next = { ...state, ...patch };
if ((Object.keys(patch) as (keyof LayersUiState)[]).every((k) => state[k] === next[k])) return;
state = next;
subs.forEach((cb) => cb());
},
subscribe(cb) { subs.add(cb); return () => { subs.delete(cb); }; },
};
}
const LayersUiContext = createContext<LayersUi>(createLayersUi());
function useLayersUiValue<T>(ui: LayersUi, select: (s: LayersUiState) => T): T {
return useSyncExternalStore(ui.subscribe, () => select(ui.get()));
}
/** Inline rename field. Enter/blur commit, Escape cancels. */
const RenameInput: React.FC<{
initial: string;
onCommit: (v: string) => void;
onCancel: () => void;
}> = ({ initial, onCommit, onCancel }) => {
const done = useRef(false);
const finish = (fn: () => void) => { if (!done.current) { done.current = true; fn(); } };
return (
<input
className="layer-rename-input"
aria-label="Layer name"
defaultValue={initial}
autoFocus
onFocus={(e) => e.currentTarget.select()}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); finish(() => onCommit(e.currentTarget.value)); }
else if (e.key === 'Escape') { e.preventDefault(); finish(onCancel); }
}}
onBlur={(e) => finish(() => onCommit(e.currentTarget.value))}
/>
);
};
interface LayerNodeProps {
nodeId: string;
depth: number;
}
/** Test-only seam: set `.current` to observe every LayerNode render. */
export const layerNodeRenderProbe: { current: ((nodeId: string) => void) | null } = { current: null };
/** Comma-joined child ids (real children first, then linked nodes). */
function childIdsOf(data: { nodes?: string[]; linkedNodes?: Record<string, string> }): string {
return [...(data.nodes || []), ...(Object.values(data.linkedNodes || {}) as string[])].join(',');
}
const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
layerNodeRenderProbe.current?.(nodeId);
const { requestFocus } = useLayerFocus();
// The collector returns PRIMITIVES only, so Craft's shallow-equal check
// skips the re-render for hover / unrelated prop edits. Returning the whole
// node object would re-render this row on every prop change anywhere in it.
const ui = useContext(LayersUiContext);
const { notify } = useNotices();
const { exists, displayName, customLabel, isHidden, isLinked, childIdsSig, virtualSig, isSelected, actions, query } = useEditor((state) => {
const n = state.nodes[nodeId];
if (!n) return { exists: false, displayName: '', customLabel: '', isHidden: false, isLinked: false, childIdsSig: '', virtualSig: '', isSelected: false };
const name = (n.data.props?.aiName as string) || n.data.displayName || (n.data.type as any)?.resolvedName || 'Node';
// Virtual rows come from array props; collect them as one string so only
// a change in the row labels (not any other prop) re-renders the row.
const virtualSig = VIRTUAL_CHILD_PROPS[name] ? JSON.stringify(deriveVirtualRows(name, n.data.props || {})) : '';
const selectedIds = state.events.selected;
const selId = selectedIds ? Array.from(selectedIds)[0] : null;
const parent = n.data.parent ? state.nodes[n.data.parent] : null;
const isLinked = !!parent && (Object.values(parent.data.linkedNodes || {}) as string[]).includes(nodeId);
return {
exists: true,
displayName: name,
customLabel: (n.data.custom?.label as string) || '',
isHidden: !!n.data.hidden,
isLinked,
childIdsSig: childIdsOf(n.data),
virtualSig,
isSelected: selId === nodeId,
};
});
const allChildren = useMemo(() => (childIdsSig ? childIdsSig.split(',') : []), [childIdsSig]);
const virtualRows = useMemo<{ index: number; label: string }[]>(() => (virtualSig ? JSON.parse(virtualSig) : []), [virtualSig]);
const isRoot = nodeId === 'ROOT';
// ROOT and linked slots (Section inner, Columns col-N) are structural.
const structural = isRoot || isLinked;
const isCollapsed = useLayersUiValue(ui, (u) => u.collapsed.has(nodeId));
const isRenaming = useLayersUiValue(ui, (u) => u.renamingId === nodeId);
const dropWhere = useLayersUiValue(ui, (u) => (u.dropTargetId === nodeId ? u.dropWhere : null));
const isDragging = useLayersUiValue(ui, (u) => u.draggingId === nodeId);
const handleActivate = useCallback(() => {
actions.selectNode(nodeId);
}, [actions, nodeId]);
// Layers-panel <-> canvas hover sync (Item 12): tags the target node's own
// DOM element (found via `query`) with `data-layer-hovered` directly.
// `RenderNode.tsx` mirrors Craft's *own* `hovered` node event onto
// `data-craft-hovered` for the (separate, mouse-driven) canvas hover
// outline -- editor.css's rule matches both attributes. This panel can't
// reuse that path: `setNodeEvent` is a private action that `useEditor()`
// strips from its `actions` return at runtime (see
// @craftjs/core/dist/esm's `xe()` action stripper, not just its TypeScript
// type), so calling it here throws. Writing our own attribute directly is
// a supported, version-stable way to get the same highlight.
const hoveredDomRef = useRef<HTMLElement | null>(null);
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
const dom = query.node(nodeId).get()?.dom;
if (dom) {
dom.setAttribute('data-layer-hovered', '');
hoveredDomRef.current = dom;
}
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
}
},
[query, nodeId, isSelected],
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
hoveredDomRef.current?.removeAttribute('data-layer-hovered');
hoveredDomRef.current = null;
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'transparent';
}
},
[isSelected],
);
// Safety net: if this row unmounts (e.g. the user switches away from the
// Layers tab, or the tree re-shuffles) while still hovered, clear the
// attribute so it doesn't get stuck highlighted on the canvas forever.
useEffect(() => {
return () => {
hoveredDomRef.current?.removeAttribute('data-layer-hovered');
hoveredDomRef.current = null;
};
}, []);
const toggleCollapsed = useCallback((e: React.SyntheticEvent) => {
e.stopPropagation();
const next = new Set(ui.get().collapsed);
if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId);
ui.set({ collapsed: next });
}, [ui, nodeId]);
const startRename = useCallback(() => {
if (!structural) ui.set({ renamingId: nodeId });
}, [ui, nodeId, structural]);
const commitRename = useCallback((value: string) => {
ui.set({ renamingId: null });
const typed = value.trim();
// Typing the default name back means "no custom label": never store a
// redundant custom.label equal to the type name.
const v = typed === displayName ? '' : typed;
if (v === customLabel) return;
actions.setCustom(nodeId, (c: any) => {
if (v) c.label = v; else delete c.label;
});
}, [ui, actions, nodeId, customLabel, displayName]);
const toggleHidden = useCallback((e: React.SyntheticEvent) => {
e.stopPropagation();
actions.setHidden(nodeId, !isHidden);
}, [actions, nodeId, isHidden]);
// Where would a drop at this pointer position land? Resolved from the live
// tree at event time, so no extra collector fields are needed.
const resolveDrop = useCallback((e: React.DragEvent<HTMLElement>) => {
const node = query.node(nodeId).get();
if (!node) return null;
const parentId: string | null = node.data.parent || null;
const siblings: string[] = parentId ? query.node(parentId).get()?.data?.nodes || [] : [];
return computeDrop(
{ clientY: e.clientY, rect: e.currentTarget.getBoundingClientRect() },
{
id: nodeId,
parentId,
index: siblings.indexOf(nodeId),
isCanvas: !!node.data.isCanvas,
childCount: (node.data.nodes || []).length,
},
);
}, [query, nodeId]);
const handleDragStart = (e: React.DragEvent<HTMLElement>) => {
if (structural || isRenaming) { e.preventDefault(); return; }
e.dataTransfer?.setData('text/plain', nodeId);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
ui.set({ draggingId: nodeId });
};
const clearDrag = () => ui.set({ draggingId: null, dropTargetId: null, dropWhere: null });
const handleDragOver = (e: React.DragEvent<HTMLElement>) => {
const dragId = ui.get().draggingId;
if (!dragId) return;
e.preventDefault();
const drop = resolveDrop(e);
const ok = !!drop && dragId !== nodeId && moveLayerAllowed(query, dragId, drop.parentId);
// Always 'move': with dropEffect 'none' the browser never fires `drop`, so
// a refused target could not explain itself. Disallowed targets get no drop
// line here and are refused (with a notice) in handleDrop via moveLayer.
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
ui.set(ok && drop ? { dropTargetId: nodeId, dropWhere: drop.where } : { dropTargetId: null, dropWhere: null });
};
const handleDrop = (e: React.DragEvent<HTMLElement>) => {
const dragId = ui.get().draggingId;
clearDrag();
if (!dragId) return;
e.preventDefault();
e.stopPropagation();
const drop = resolveDrop(e);
if (!drop || dragId === nodeId) return;
const res = moveLayer(query, actions, dragId, drop.parentId, drop.index);
if (!res.ok) notify({ kind: 'error', message: res.error || "That layer can't be moved there.", timeoutMs: 4000 });
};
if (!exists) return null;
const label = customLabel || displayName;
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
const virtualSpec = VIRTUAL_CHILD_PROPS[displayName];
const hasDisclosure = allChildren.length + virtualRows.length > 0;
const rowClickable = clickableProps(handleActivate);
return (
<div>
<div
{...rowClickable}
onKeyDown={(e) => {
if (e.target === e.currentTarget && e.key === 'F2') { e.preventDefault(); startRename(); return; }
rowClickable.onKeyDown(e);
}}
onDoubleClick={startRename}
draggable={!structural && !isRenaming}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={clearDrag}
data-layer-id={nodeId}
data-drop={dropWhere || undefined}
aria-selected={isSelected}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`layer-node-row${isHidden ? ' layer-hidden' : ''}${isDragging ? ' layer-dragging' : ''}`}
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
padding: '5px 8px',
paddingLeft: `${8 + depth * 16}px`,
fontSize: 12,
fontWeight: isSelected ? 600 : 400,
color: isSelected ? 'var(--color-accent)' : 'var(--color-text)',
background: isSelected ? 'var(--color-accent-subtle)' : 'transparent',
borderLeft: isSelected ? '2px solid var(--color-accent)' : '2px solid transparent',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'none',
}}
onFocus={handleMouseEnter}
onBlur={handleMouseLeave}
>
{/* Indent guides: one vertical line per ancestor depth level,
aligned under each ancestor's disclosure/icon column so nested
rows read as a tree instead of a flat, ever-further-indented list. */}
{Array.from({ length: depth }).map((_, level) => (
<span
key={level}
aria-hidden="true"
style={{
position: 'absolute',
left: `${8 + level * 16 + 4}px`,
top: 0,
bottom: 0,
width: 1,
background: 'var(--color-border)',
}}
/>
))}
{/* Disclosure chevron: real expand/collapse toggle */}
{hasDisclosure ? (
<span
role="button"
className="layer-chevron"
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
aria-expanded={!isCollapsed}
onClick={toggleCollapsed}
style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0, cursor: 'pointer' }}
>
{isCollapsed ? '\u25B6' : '\u25BC'}
</span>
) : (
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent', flexShrink: 0 }}>
&#9660;
</span>
)}
{/* Component type icon */}
<i
className={`fa ${icon}`}
aria-hidden="true"
style={{
marginRight: 6,
fontSize: 11,
width: 12,
textAlign: 'center',
flexShrink: 0,
color: isSelected ? 'var(--color-accent)' : 'var(--color-text-dim)',
}}
/>
{/* Name (inline input while renaming) */}
{isRenaming ? (
<RenameInput initial={customLabel || displayName} onCommit={commitRename} onCancel={() => ui.set({ renamingId: null })} />
) : (
<span className="layer-name" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{isRoot ? 'Canvas (Root)' : label}
</span>
)}
{/* Hide / show on canvas (undoable; hidden blocks are skipped on export) */}
{!structural && (
<button
type="button"
className="layer-eye-btn"
aria-label={isHidden ? `Show ${label}` : `Hide ${label}`}
aria-pressed={isHidden}
title={isHidden ? 'Hidden on all devices - click to show' : 'Hide'}
onClick={toggleHidden}
onDoubleClick={(e) => e.stopPropagation()}
>
<i className={`fa ${isHidden ? 'fa-eye-slash' : 'fa-eye'}`} aria-hidden="true" />
</button>
)}
</div>
{/* Virtual rows: array-prop items (composite content Craft doesn't see
as child nodes), rendered before real children. */}
{!isCollapsed && virtualSpec && virtualRows.map((row) => (
<VirtualRowNode
key={`${nodeId}:${virtualSpec.prop}:${row.index}`}
parentId={nodeId}
prop={virtualSpec.prop}
index={row.index}
label={row.label}
depth={depth + 1}
onActivate={() => {
actions.selectNode(nodeId);
requestFocus(nodeId, virtualSpec.prop, row.index);
}}
/>
))}
{/* Render children */}
{!isCollapsed && allChildren.map((childId) => (
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</div>
);
};
/** Dragover feedback check: same rules `moveLayer` enforces on drop. */
function moveLayerAllowed(query: any, nodeId: string, parentId: string): boolean {
return canMove(query, nodeId, parentId);
}
const LayerNode = React.memo(LayerNodeImpl);
export const LayersPanel: React.FC = () => {
// The collector returns two strings so Craft's shallow-equal check skips
// the re-render for hover / selection / unrelated prop edits. Only a
// structural change (ids, children, linked nodes, hidden) alters `sig`.
const cache = useRef<{ sig: string; unplacedSig: string } | null>(null);
const { sig, unplacedSig } = useEditor((state) => {
const ids = Object.keys(state.nodes);
const sig = ids.map((id) => {
const d = state.nodes[id].data;
return `${id}:${(d.nodes || []).join(',')}|${(Object.values(d.linkedNodes || {}) as string[]).join(',')}|${d.hidden ? 1 : 0}`;
}).join(';');
if (cache.current?.sig === sig) return cache.current;
const serializable: Record<string, any> = {};
for (const id of ids) {
const d = state.nodes[id].data;
serializable[id] = { nodes: d.nodes || [], linkedNodes: d.linkedNodes || {} };
}
const next = { sig, unplacedSig: findUnreachableNodeIds(serializable).join(',') };
cache.current = next;
return next;
});
// Panel-local UI store (collapsed ids, drop line, rename) - see LayersUi.
const ui = useMemo(() => createLayersUi(), []);
// Selecting a node (canvas click, breadcrumb, Layers) reveals it: drop its
// ancestors from the collapsed set. Primitive collector, so only this thin
// shell re-renders on selection; the memoised rows are untouched.
const { selectedId, query } = useEditor((state) => {
const sel = state.events.selected;
return { selectedId: (sel ? Array.from(sel)[0] : '') as string };
});
useEffect(() => {
if (!selectedId) return;
const collapsed = ui.get().collapsed;
if (collapsed.size === 0) return;
const hit = [...getAncestorIds(query, selectedId), 'ROOT'].filter((id) => collapsed.has(id));
if (hit.length === 0) return;
const next = new Set(collapsed);
hit.forEach((id) => next.delete(id));
ui.set({ collapsed: next });
}, [selectedId, query, ui]);
const nodeIds = useMemo(() => sig.split(';').map((e) => e.slice(0, e.indexOf(':'))), [sig]);
const unplacedIds = useMemo(() => (unplacedSig ? unplacedSig.split(',') : []), [unplacedSig]);
const hasRoot = nodeIds.includes('ROOT');
if (!hasRoot) {
return (
<div className="panel-placeholder">
No content on canvas
</div>
);
}
return (
<LayersUiContext.Provider value={ui}>
<div style={{ display: 'flex', flexDirection: 'column', margin: '-12px', minHeight: 0 }}>
<div
style={{
padding: '8px 12px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: 'var(--color-text-muted)',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
}}
>
Component Tree
</div>
{/* Own scroll container: a deep or long tree must stay fully reachable
regardless of how the parent tab panel is sized. */}
<div className="layers-tree-scroll" style={{ overflowY: 'auto', flex: 1, minHeight: 0 }}>
<LayerNode nodeId="ROOT" depth={0} />
{/* Unplaced: nodes no parent lists. `repairOrphanNodes` reattaches
these on load, so this should stay empty -- it exists so an
element stranded mid-session is still selectable and deletable
rather than invisible. */}
{unplacedIds.length > 0 && (
<>
<div
style={{
padding: '8px 12px',
marginTop: 8,
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: 'var(--color-warning, #f59e0b)',
borderTop: '1px solid var(--color-border)',
}}
title="These elements are not attached to the page. Select one to delete it."
>
Unplaced ({unplacedIds.length})
</div>
{unplacedIds.map((id) => (
<LayerNode key={id} nodeId={id} depth={1} />
))}
</>
)}
</div>
</div>
</LayersUiContext.Provider>
);
};