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

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