import {
CANVAS_PANE_WIDTH,
CanvasPane,
DOT_GRID,
useCanvasSeat,
} from '@agent/components/Canvas';
import { usePortal } from '@agent/hooks/usePortal';
import { DESKTOP_MIN_WIDTH, useGlobalStore } from '@agent/state/global';
import { createPortal, useEffect, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { close, Icon } from '@wordpress/icons';
import { motion } from 'framer-motion';
import { OptionsPopover } from '../OptionsPopover';
const SIDEBAR_WIDTH = 384;
const CANVAS_WIDTH = SIDEBAR_WIDTH + CANVAS_PANE_WIDTH;
const FRAME_WIDTH = 8; // border-8
const ANIMATE_TIME = 300;
export const SidebarLayout = ({ children }) => {
const mountNode = usePortal('extendify-agent-sidebar-mount');
const frameNode = usePortal('extendify-agent-border-frame-mount');
const { open, setOpen } = useGlobalStore();
const seat = useCanvasSeat();
const beside = seat === 'beside';
useLayoutShift(open);
const closeAgent = () => {
setOpen(false);
// External contract: no in-repo listener by design — notifies
// host-page/analytics consumers the user dismissed the agent.
window.dispatchEvent(new CustomEvent('extendify-agent:closed-button'));
};
useEffect(() => {
if (open) return;
if (!mountNode?.contains(document.activeElement)) return;
document.activeElement?.blur();
}, [open]);
if (!mountNode) return null;
// A border that sits around the entire browser to look like the sidebar is inside it
const frameAnim = {
open: {
top: FRAME_WIDTH,
right: FRAME_WIDTH,
bottom: FRAME_WIDTH,
left: SIDEBAR_WIDTH,
boxShadow: '#e0e0e0 0px 0px 0px 9999px',
borderRadius: '1rem',
},
closed: {
inset: 0,
boxShadow: '0 0 0 0 #fff',
borderRadius: 0,
},
};
// Unclipped, the 9999px shadow covers the notification bar.
const frameBar = frameNode
? createPortal(
,
frameNode,
)
: null;
// app/Agent/Skeleton.php mirrors this markup for the deferred mount; edit both.
const sidebar = mountNode
? createPortal(
,
mountNode,
)
: null;
return (
<>
{frameBar}
{sidebar}
>
);
};
// Survives save-triggered reloads (modal saves, undo) — the page can
// reload while the agent is open and we want to land at the same scroll.
const SCROLL_STASH_KEY = 'extendify-agent-wsb-scroll-stash';
const urlKey = () => window.location.pathname + window.location.search;
const stashScroll = (scroll) => {
try {
window.sessionStorage.setItem(
SCROLL_STASH_KEY,
JSON.stringify({ key: urlKey(), scroll }),
);
} catch (_) {
/* no-op */
}
};
const consumeStashedScroll = () => {
try {
const raw = window.sessionStorage.getItem(SCROLL_STASH_KEY);
window.sessionStorage.removeItem(SCROLL_STASH_KEY);
if (!raw) return 0;
const data = JSON.parse(raw);
if (!data || data.key !== urlKey()) return 0;
const n = Number(data.scroll);
return Number.isFinite(n) && n > 0 ? n : 0;
} catch (_) {
return 0;
}
};
export const useLayoutShift = (open) => {
const ease = 'ease-in-out';
const t = (props) =>
props.map((p) => `${p} ${ANIMATE_TIME}ms ${ease}`).join(', ');
const firstRun = useRef(true);
const savedScroll = useRef(0);
useEffect(() => {
const onBeforeUnload = () => {
const wsb = document.querySelector('.wp-site-blocks');
const scroll = wsb?.scrollTop || window.scrollY || 0;
if (scroll > 0) stashScroll(scroll);
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, []);
useEffect(() => {
const siteBlocks = document.querySelector('.wp-site-blocks');
const wpadminbar = document.querySelector('#wpadminbar');
const stickyHeader = document.querySelector(
'header.is-position-sticky, header.wp-block-template-part:has(.ext-header-sticky)',
);
// firstRun.current flips below before applyScaling runs in rAF.
const isFirstApply = firstRun.current;
const applyScaling = () => {
if (!siteBlocks) return;
// Unmount leaves these styles, so scaling below the breakpoint is permanent.
if (open && window.innerWidth >= DESKTOP_MIN_WIDTH) {
// Capture before `position: fixed` zeroes window.scrollY.
// Fall through to savedScroll.current so resize / strict-mode
// re-runs don't clobber it with the now-pinned scrollY (0).
const stashed = isFirstApply ? consumeStashedScroll() : 0;
savedScroll.current = stashed || window.scrollY || savedScroll.current;
const viewportWidth = window.innerWidth;
const scale = (viewportWidth - SIDEBAR_WIDTH) / viewportWidth;
// Subtract 40 because translateY(40px) below pushes the element down.
const scaledHeight = (window.innerHeight - 40) / scale;
// Divided by the scale because this height is in pre-scale units.
const height =
`calc(${scaledHeight}px - ` +
`var(--extendify-notification-bar-height, 0px) / ${scale})`;
Object.assign(siteBlocks.style, {
transformOrigin: 'top left',
transform: `translateX(${SIDEBAR_WIDTH}px) translateY(40px) scale(${scale})`,
height,
overflowY: 'auto',
// `auto` so the scrollTop below is instant; `smooth` would animate from 0.
scrollBehavior: 'auto',
});
// Force layout so scrollTop respects the new height/overflow.
void siteBlocks.scrollHeight;
siteBlocks.scrollTop = savedScroll.current;
if (stickyHeader) {
stickyHeader.style.setProperty(
'--wp-admin--admin-bar--position-offset',
'0px',
);
}
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = '0';
document.body.style.left = '0';
document.body.style.width = '100vw';
} else {
const stashed = isFirstApply ? consumeStashedScroll() : 0;
const wsbScroll =
siteBlocks.scrollTop || stashed || savedScroll.current;
Object.assign(siteBlocks.style, {
transformOrigin: 'top left',
transform: 'translateX(0) translateY(0) scale(1)',
height: '',
overflowY: '',
maxWidth: '100vw',
scrollBehavior: '',
});
if (stickyHeader) {
stickyHeader.style.removeProperty(
'--wp-admin--admin-bar--position-offset',
'32px',
);
}
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.width = '';
// `instant` overrides themes that set html { scroll-behavior: smooth }.
void document.documentElement.scrollHeight;
window.scrollTo({ top: wsbScroll, left: 0, behavior: 'instant' });
savedScroll.current = 0;
}
};
if (!firstRun.current) {
if (siteBlocks) {
siteBlocks.style.transition = t(['transform']);
}
if (wpadminbar) {
wpadminbar.style.transition = t([
'margin-left',
'margin-top',
'margin-right',
'border-radius',
'max-width',
]);
}
} else {
firstRun.current = false;
}
const raf = requestAnimationFrame(() => {
applyScaling();
// External contract: no in-repo listener by design — lets host-page
// consumers react to the agent reflowing the viewport.
window.dispatchEvent(
new CustomEvent('extendify-agent:layout-shift', {
detail: { open },
}),
);
// agent.css carries the bar offsets, so they drop with the
// breakpoint rather than with this component.
document.documentElement.classList.toggle('extendify-agent-docked', open);
});
window.addEventListener('resize', applyScaling);
return () => {
if (siteBlocks?.scrollTop) {
savedScroll.current = siteBlocks.scrollTop;
}
cancelAnimationFrame(raf);
window.removeEventListener('resize', applyScaling);
document.body.style.overflowX = '';
// Don't clear wsb / wpadminbar styles here. Cleanup is sync,
// but the next effect's close branch animates them in rAF —
// wiping them now collapses the un-zoom to a no-op jump.
};
}, [open]);
};