PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / Agent / components / layouts / SidebarLayout.jsx

SidebarLayout.jsx in Extendify 3.2.1, at src/Agent/components/layouts/SidebarLayout.jsx

346 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import {
2 CANVAS_PANE_WIDTH,
3 CanvasPane,
4 DOT_GRID,
5 useCanvasSeat,
6 } from '@agent/components/Canvas';
7 import { usePortal } from '@agent/hooks/usePortal';
8 import { DESKTOP_MIN_WIDTH, useGlobalStore } from '@agent/state/global';
9 import { createPortal, useEffect, useRef } from '@wordpress/element';
10 import { __ } from '@wordpress/i18n';
11 import { close, Icon } from '@wordpress/icons';
12 import { motion } from 'framer-motion';
13 import { OptionsPopover } from '../OptionsPopover';
14
15 const SIDEBAR_WIDTH = 384;
16 const CANVAS_WIDTH = SIDEBAR_WIDTH + CANVAS_PANE_WIDTH;
17 const FRAME_WIDTH = 8; // border-8
18 const ANIMATE_TIME = 300;
19
20 export const SidebarLayout = ({ children }) => {
21 const mountNode = usePortal('extendify-agent-sidebar-mount');
22 const frameNode = usePortal('extendify-agent-border-frame-mount');
23 const { open, setOpen } = useGlobalStore();
24 const seat = useCanvasSeat();
25 const beside = seat === 'beside';
26 useLayoutShift(open);
27
28 const closeAgent = () => {
29 setOpen(false);
30 // External contract: no in-repo listener by design — notifies
31 // host-page/analytics consumers the user dismissed the agent.
32 window.dispatchEvent(new CustomEvent('extendify-agent:closed-button'));
33 };
34
35 useEffect(() => {
36 if (open) return;
37 if (!mountNode?.contains(document.activeElement)) return;
38 document.activeElement?.blur();
39 }, [open]);
40
41 if (!mountNode) return null;
42
43 // A border that sits around the entire browser to look like the sidebar is inside it
44 const frameAnim = {
45 open: {
46 top: FRAME_WIDTH,
47 right: FRAME_WIDTH,
48 bottom: FRAME_WIDTH,
49 left: SIDEBAR_WIDTH,
50 boxShadow: '#e0e0e0 0px 0px 0px 9999px',
51 borderRadius: '1rem',
52 },
53 closed: {
54 inset: 0,
55 boxShadow: '0 0 0 0 #fff',
56 borderRadius: 0,
57 },
58 };
59
60 // Unclipped, the 9999px shadow covers the notification bar.
61 const frameBar = frameNode
62 ? createPortal(
63 <div className="fixed top-0 left-0 right-0 bottom-(--extendify-notification-bar-height,0px) overflow-hidden pointer-events-none z-high">
64 <motion.div
65 className="absolute overflow-hidden"
66 initial={false}
67 animate={open ? 'open' : 'closed'}
68 variants={frameAnim}
69 transition={{ duration: ANIMATE_TIME / 1000, ease: 'easeInOut' }}
70 />
71 <motion.div
72 className="absolute rounded-2xl shadow-xl"
73 style={{
74 top: FRAME_WIDTH,
75 right: FRAME_WIDTH,
76 bottom: FRAME_WIDTH,
77 left: SIDEBAR_WIDTH,
78 }}
79 initial={false}
80 animate={{ opacity: open ? 1 : 0 }}
81 transition={{
82 duration: 0.15,
83 delay: open ? ANIMATE_TIME / 1000 : 0,
84 }}
85 />
86 </div>,
87 frameNode,
88 )
89 : null;
90
91 // app/Agent/Skeleton.php mirrors this markup for the deferred mount; edit both.
92 const sidebar = mountNode
93 ? createPortal(
94 <motion.div
95 className={`fixed top-0 bottom-[var(--extendify-notification-bar-height,0px)] left-0 border-transparent border-8 ${
96 // An emptied panel belongs under the scrim with the rest of the page.
97 seat === 'modal' ? 'z-high' : 'z-higher'
98 }`}
99 id="extendify-agent-sidebar"
100 data-extendify-agent-panel
101 data-extendify-agent-emptied={seat === 'modal' ? '' : undefined}
102 initial={false}
103 inert={open ? undefined : ''}
104 animate={{
105 x: open ? 0 : -SIDEBAR_WIDTH,
106 width: beside ? CANVAS_WIDTH : SIDEBAR_WIDTH,
107 }}
108 transition={{ duration: ANIMATE_TIME / 1000, ease: 'easeInOut' }}
109 >
110 <div
111 className={`relative h-full flex shadow-lg rounded-2xl overflow-hidden ${beside ? 'bg-gray-50' : 'bg-white'}`}
112 style={beside ? DOT_GRID : undefined}
113 >
114 <div
115 className="relative z-10 h-full flex shrink-0 flex-col rounded-2xl overflow-hidden bg-white shadow-lg"
116 style={{ width: SIDEBAR_WIDTH - FRAME_WIDTH * 2 }}
117 >
118 <div className="group flex shrink-0 items-center justify-between overflow-hidden bg-banner-main text-banner-text">
119 <div className="flex h-full grow items-center justify-between gap-1 p-0 py-2.5">
120 <div className="flex h-5 px-4 max-w-36 overflow-hidden">
121 <img
122 className="max-h-full max-w-full object-contain"
123 src={window.extSharedData.partnerLogo}
124 alt={window.extSharedData.partnerName}
125 />
126 </div>
127 </div>
128 <div className="flex gap-1 h-full items-center p-2">
129 {seat ? null : (
130 <>
131 <OptionsPopover />
132 <button
133 type="button"
134 className="relative z-10 flex justify-center h-6 w-6 items-center border-0 bg-banner-main text-banner-text outline-hidden ring-design-main focus:shadow-none focus:outline-hidden focus-visible:outline-design-main focus:ring-2 hover:opacity-80 rounded-sm"
135 onClick={closeAgent}
136 >
137 <Icon
138 className="pointer-events-none fill-current leading-none"
139 icon={close}
140 size={18}
141 />
142 <span className="sr-only">
143 {__('Close window', 'extendify-local')}
144 </span>
145 </button>
146 </>
147 )}
148 </div>
149 </div>
150 {open ? children : null}
151 </div>
152 <CanvasPane seat="beside" />
153 </div>
154 </motion.div>,
155 mountNode,
156 )
157 : null;
158
159 return (
160 <>
161 {frameBar}
162 {sidebar}
163 </>
164 );
165 };
166
167 // Survives save-triggered reloads (modal saves, undo) — the page can
168 // reload while the agent is open and we want to land at the same scroll.
169 const SCROLL_STASH_KEY = 'extendify-agent-wsb-scroll-stash';
170
171 const urlKey = () => window.location.pathname + window.location.search;
172
173 const stashScroll = (scroll) => {
174 try {
175 window.sessionStorage.setItem(
176 SCROLL_STASH_KEY,
177 JSON.stringify({ key: urlKey(), scroll }),
178 );
179 } catch (_) {
180 /* no-op */
181 }
182 };
183
184 const consumeStashedScroll = () => {
185 try {
186 const raw = window.sessionStorage.getItem(SCROLL_STASH_KEY);
187 window.sessionStorage.removeItem(SCROLL_STASH_KEY);
188 if (!raw) return 0;
189 const data = JSON.parse(raw);
190 if (!data || data.key !== urlKey()) return 0;
191 const n = Number(data.scroll);
192 return Number.isFinite(n) && n > 0 ? n : 0;
193 } catch (_) {
194 return 0;
195 }
196 };
197
198 export const useLayoutShift = (open) => {
199 const ease = 'ease-in-out';
200 const t = (props) =>
201 props.map((p) => `${p} ${ANIMATE_TIME}ms ${ease}`).join(', ');
202 const firstRun = useRef(true);
203 const savedScroll = useRef(0);
204
205 useEffect(() => {
206 const onBeforeUnload = () => {
207 const wsb = document.querySelector('.wp-site-blocks');
208 const scroll = wsb?.scrollTop || window.scrollY || 0;
209 if (scroll > 0) stashScroll(scroll);
210 };
211 window.addEventListener('beforeunload', onBeforeUnload);
212 return () => window.removeEventListener('beforeunload', onBeforeUnload);
213 }, []);
214
215 useEffect(() => {
216 const siteBlocks = document.querySelector('.wp-site-blocks');
217 const wpadminbar = document.querySelector('#wpadminbar');
218 const stickyHeader = document.querySelector(
219 'header.is-position-sticky, header.wp-block-template-part:has(.ext-header-sticky)',
220 );
221
222 // firstRun.current flips below before applyScaling runs in rAF.
223 const isFirstApply = firstRun.current;
224
225 const applyScaling = () => {
226 if (!siteBlocks) return;
227
228 // Unmount leaves these styles, so scaling below the breakpoint is permanent.
229 if (open && window.innerWidth >= DESKTOP_MIN_WIDTH) {
230 // Capture before `position: fixed` zeroes window.scrollY.
231 // Fall through to savedScroll.current so resize / strict-mode
232 // re-runs don't clobber it with the now-pinned scrollY (0).
233 const stashed = isFirstApply ? consumeStashedScroll() : 0;
234 savedScroll.current = stashed || window.scrollY || savedScroll.current;
235
236 const viewportWidth = window.innerWidth;
237 const scale = (viewportWidth - SIDEBAR_WIDTH) / viewportWidth;
238
239 // Subtract 40 because translateY(40px) below pushes the element down.
240 const scaledHeight = (window.innerHeight - 40) / scale;
241 // Divided by the scale because this height is in pre-scale units.
242 const height =
243 `calc(${scaledHeight}px - ` +
244 `var(--extendify-notification-bar-height, 0px) / ${scale})`;
245
246 Object.assign(siteBlocks.style, {
247 transformOrigin: 'top left',
248 transform: `translateX(${SIDEBAR_WIDTH}px) translateY(40px) scale(${scale})`,
249 height,
250 overflowY: 'auto',
251 // `auto` so the scrollTop below is instant; `smooth` would animate from 0.
252 scrollBehavior: 'auto',
253 });
254 // Force layout so scrollTop respects the new height/overflow.
255 void siteBlocks.scrollHeight;
256 siteBlocks.scrollTop = savedScroll.current;
257 if (stickyHeader) {
258 stickyHeader.style.setProperty(
259 '--wp-admin--admin-bar--position-offset',
260 '0px',
261 );
262 }
263 document.body.style.overflow = 'hidden';
264 document.body.style.position = 'fixed';
265 document.body.style.top = '0';
266 document.body.style.left = '0';
267 document.body.style.width = '100vw';
268 } else {
269 const stashed = isFirstApply ? consumeStashedScroll() : 0;
270 const wsbScroll =
271 siteBlocks.scrollTop || stashed || savedScroll.current;
272 Object.assign(siteBlocks.style, {
273 transformOrigin: 'top left',
274 transform: 'translateX(0) translateY(0) scale(1)',
275 height: '',
276 overflowY: '',
277 maxWidth: '100vw',
278 scrollBehavior: '',
279 });
280 if (stickyHeader) {
281 stickyHeader.style.removeProperty(
282 '--wp-admin--admin-bar--position-offset',
283 '32px',
284 );
285 }
286 document.body.style.overflow = '';
287 document.body.style.position = '';
288 document.body.style.top = '';
289 document.body.style.left = '';
290 document.body.style.width = '';
291 // `instant` overrides themes that set html { scroll-behavior: smooth }.
292 void document.documentElement.scrollHeight;
293 window.scrollTo({ top: wsbScroll, left: 0, behavior: 'instant' });
294 savedScroll.current = 0;
295 }
296 };
297
298 if (!firstRun.current) {
299 if (siteBlocks) {
300 siteBlocks.style.transition = t(['transform']);
301 }
302 if (wpadminbar) {
303 wpadminbar.style.transition = t([
304 'margin-left',
305 'margin-top',
306 'margin-right',
307 'border-radius',
308 'max-width',
309 ]);
310 }
311 } else {
312 firstRun.current = false;
313 }
314
315 const raf = requestAnimationFrame(() => {
316 applyScaling();
317
318 // External contract: no in-repo listener by design — lets host-page
319 // consumers react to the agent reflowing the viewport.
320 window.dispatchEvent(
321 new CustomEvent('extendify-agent:layout-shift', {
322 detail: { open },
323 }),
324 );
325
326 // agent.css carries the bar offsets, so they drop with the
327 // breakpoint rather than with this component.
328 document.documentElement.classList.toggle('extendify-agent-docked', open);
329 });
330
331 window.addEventListener('resize', applyScaling);
332
333 return () => {
334 if (siteBlocks?.scrollTop) {
335 savedScroll.current = siteBlocks.scrollTop;
336 }
337 cancelAnimationFrame(raf);
338 window.removeEventListener('resize', applyScaling);
339 document.body.style.overflowX = '';
340 // Don't clear wsb / wpadminbar styles here. Cleanup is sync,
341 // but the next effect's close branch animates them in rAF —
342 // wiping them now collapses the un-zoom to a no-op jump.
343 };
344 }, [open]);
345 };
346