PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
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.1.5, at src/Agent/components/layouts/SidebarLayout.jsx

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