PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 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 All 128 releases
← All changes | src/Agent/components/layouts/SidebarLayout.jsx +156 -79 3.0.6 → 3.2.2 View file →
@@ -1,6 +1,12 @@
1 +import {
2 + CANVAS_PANE_WIDTH,
3 + CanvasPane,
4 + DOT_GRID,
5 + useCanvasSeat,
6 +} from '@agent/components/Canvas';
1 7 import { usePortal } from '@agent/hooks/usePortal';
2 -import { useGlobalStore } from '@agent/state/global';
8 +import { DESKTOP_MIN_WIDTH, useGlobalStore } from '@agent/state/global';
3 9 import { createPortal, useEffect, useRef } from '@wordpress/element';
4 10 import { __ } from '@wordpress/i18n';
5 11 import { close, Icon } from '@wordpress/icons';
6 12 import { motion } from 'framer-motion';
@@ -5,9 +11,10 @@
5 11 import { close, Icon } from '@wordpress/icons';
6 12 import { motion } from 'framer-motion';
7 13 import { OptionsPopover } from '../OptionsPopover';
8 14
9 -const SIDEBAR_WIDTH = 384; // 96 * 4 (w-96)
15 +const SIDEBAR_WIDTH = 384;
16 +const CANVAS_WIDTH = SIDEBAR_WIDTH + CANVAS_PANE_WIDTH;
10 17 const FRAME_WIDTH = 8; // border-8
11 18 const ANIMATE_TIME = 300;
12 19
13 20 export const SidebarLayout = ({ children }) => {
@@ -13,12 +20,16 @@
13 20 export const SidebarLayout = ({ children }) => {
14 21 const mountNode = usePortal('extendify-agent-sidebar-mount');
15 22 const frameNode = usePortal('extendify-agent-border-frame-mount');
16 23 const { open, setOpen } = useGlobalStore();
24 + const seat = useCanvasSeat();
25 + const beside = seat === 'beside';
17 26 useLayoutShift(open);
18 27
19 28 const closeAgent = () => {
20 29 setOpen(false);
30 + // External contract: no in-repo listener by design — notifies
31 + // host-page/analytics consumers the user dismissed the agent.
21 32 window.dispatchEvent(new CustomEvent('extendify-agent:closed-button'));
22 33 };
23 34
24 35 useEffect(() => {
@@ -45,11 +56,12 @@
45 56 borderRadius: 0,
46 57 },
47 58 };
48 59
60 + // Unclipped, the 9999px shadow covers the notification bar.
49 61 const frameBar = frameNode
50 62 ? createPortal(
51 - <div className="fixed inset-0 pointer-events-none z-high">
63 + <div className="fixed top-0 left-0 right-0 bottom-(--extendify-notification-bar-height,0px) overflow-hidden pointer-events-none z-high">
52 64 <motion.div
53 65 className="absolute overflow-hidden"
54 66 initial={false}
55 67 animate={open ? 'open' : 'closed'}
@@ -75,49 +87,70 @@
75 87 frameNode,
76 88 )
77 89 : null;
78 90
91 + // app/Agent/Skeleton.php mirrors this markup for the deferred mount; edit both.
79 92 const sidebar = mountNode
80 93 ? createPortal(
81 94 <motion.div
82 - style={{ width: SIDEBAR_WIDTH }}
83 - className=" fixed top-0 bottom-0 left-0 w-96 flex-col z-higher border-transparent border-8"
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 + }`}
84 99 id="extendify-agent-sidebar"
100 + data-extendify-agent-panel
101 + data-extendify-agent-emptied={seat === 'modal' ? '' : undefined}
85 102 initial={false}
86 103 inert={open ? undefined : ''}
87 - animate={{ x: open ? 0 : -SIDEBAR_WIDTH }}
104 + animate={{
105 + x: open ? 0 : -SIDEBAR_WIDTH,
106 + width: beside ? CANVAS_WIDTH : SIDEBAR_WIDTH,
107 + }}
88 108 transition={{ duration: ANIMATE_TIME / 1000, ease: 'easeInOut' }}
89 109 >
90 - <div className="h-full flex flex-col shadow-lg rounded-2xl overflow-hidden bg-white">
91 - <div className="group flex shrink-0 items-center justify-between overflow-hidden bg-banner-main text-banner-text">
92 - <div className="flex h-full grow items-center justify-between gap-1 p-0 py-2.5">
93 - <div className="flex h-5 px-4 max-w-36 overflow-hidden">
94 - <img
95 - className="max-h-full max-w-full object-contain"
96 - src={window.extSharedData.partnerLogo}
97 - alt={window.extSharedData.partnerName}
98 - />
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>
99 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>
100 149 </div>
101 - <div className="flex gap-1 h-full items-center p-2">
102 - <OptionsPopover />
103 - <button
104 - type="button"
105 - 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"
106 - onClick={closeAgent}
107 - >
108 - <Icon
109 - className="pointer-events-none fill-current leading-none"
110 - icon={close}
111 - size={18}
112 - />
113 - <span className="sr-only">
114 - {__('Close window', 'extendify-local')}
115 - </span>
116 - </button>
117 - </div>
150 + {open ? children : null}
118 151 </div>
119 - {open ? children : null}
152 + <CanvasPane seat="beside" />
120 153 </div>
121 154 </motion.div>,
122 155 mountNode,
123 156 )
@@ -130,15 +163,57 @@
130 163 </>
131 164 );
132 165 };
133 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 +
134 198 export const useLayoutShift = (open) => {
135 199 const ease = 'ease-in-out';
136 200 const t = (props) =>
137 201 props.map((p) => `${p} ${ANIMATE_TIME}ms ${ease}`).join(', ');
138 202 const firstRun = useRef(true);
203 + const savedScroll = useRef(0);
139 204
140 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(() => {
141 216 const siteBlocks = document.querySelector('.wp-site-blocks');
142 217 const wpadminbar = document.querySelector('#wpadminbar');
143 218 const stickyHeader = document.querySelector(
144 219 'header.is-position-sticky, header.wp-block-template-part:has(.ext-header-sticky)',
@@ -143,23 +218,43 @@
143 218 const stickyHeader = document.querySelector(
144 219 'header.is-position-sticky, header.wp-block-template-part:has(.ext-header-sticky)',
145 220 );
146 221
222 + // firstRun.current flips below before applyScaling runs in rAF.
223 + const isFirstApply = firstRun.current;
224 +
147 225 const applyScaling = () => {
148 226 if (!siteBlocks) return;
149 227
150 - if (open) {
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 +
151 236 const viewportWidth = window.innerWidth;
152 237 const scale = (viewportWidth - SIDEBAR_WIDTH) / viewportWidth;
153 - const scaledHeight = window.innerHeight / scale;
154 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 +
155 246 Object.assign(siteBlocks.style, {
156 247 transformOrigin: 'top left',
157 248 transform: `translateX(${SIDEBAR_WIDTH}px) translateY(40px) scale(${scale})`,
158 - height: `${scaledHeight}px`,
249 + height,
159 250 overflowY: 'auto',
160 - scrollBehavior: 'smooth',
251 + // `auto` so the scrollTop below is instant; `smooth` would animate from 0.
252 + scrollBehavior: 'auto',
161 253 });
254 + // Force layout so scrollTop respects the new height/overflow.
255 + void siteBlocks.scrollHeight;
256 + siteBlocks.scrollTop = savedScroll.current;
162 257 if (stickyHeader) {
163 258 stickyHeader.style.setProperty(
164 259 '--wp-admin--admin-bar--position-offset',
165 260 '0px',
@@ -169,10 +264,12 @@
169 264 document.body.style.position = 'fixed';
170 265 document.body.style.top = '0';
171 266 document.body.style.left = '0';
172 267 document.body.style.width = '100vw';
173 - window.scrollTo(0, 0);
174 268 } else {
269 + const stashed = isFirstApply ? consumeStashedScroll() : 0;
270 + const wsbScroll =
271 + siteBlocks.scrollTop || stashed || savedScroll.current;
175 272 Object.assign(siteBlocks.style, {
176 273 transformOrigin: 'top left',
177 274 transform: 'translateX(0) translateY(0) scale(1)',
178 275 height: '',
@@ -190,8 +287,12 @@
190 287 document.body.style.position = '';
191 288 document.body.style.top = '';
192 289 document.body.style.left = '';
193 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;
194 295 }
195 296 };
196 297
197 298 if (!firstRun.current) {
@@ -211,58 +312,34 @@
211 312 firstRun.current = false;
212 313 }
213 314
214 315 const raf = requestAnimationFrame(() => {
215 - const fw = open ? `${FRAME_WIDTH}px` : '0px';
216 - const ml = open ? `${SIDEBAR_WIDTH}px` : '0px';
316 + applyScaling();
217 317
218 - applyScaling();
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 + );
219 325
220 - if (wpadminbar) {
221 - Object.assign(wpadminbar.style, {
222 - marginTop: fw,
223 - marginRight: fw,
224 - marginBottom: '0px',
225 - marginLeft: ml,
226 - borderRadius: open ? '8px 8px 0 0' : '0',
227 - maxWidth: open
228 - ? `calc(100% - ${SIDEBAR_WIDTH + FRAME_WIDTH}px)`
229 - : '100%',
230 - });
231 - }
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);
232 329 });
233 330
234 331 window.addEventListener('resize', applyScaling);
235 332
236 333 return () => {
334 + if (siteBlocks?.scrollTop) {
335 + savedScroll.current = siteBlocks.scrollTop;
336 + }
237 337 cancelAnimationFrame(raf);
238 338 window.removeEventListener('resize', applyScaling);
239 339 document.body.style.overflowX = '';
240 - if (siteBlocks) {
241 - Object.assign(siteBlocks.style, {
242 - transition: '',
243 - transform: '',
244 - transformOrigin: '',
245 - height: '',
246 - overflowY: '',
247 - maxWidth: '',
248 - });
249 - }
250 - if (stickyHeader) {
251 - stickyHeader.style.removeProperty(
252 - '--wp-admin--admin-bar--position-offset',
253 - );
254 - }
255 - if (wpadminbar) {
256 - Object.assign(wpadminbar.style, {
257 - transition: '',
258 - marginTop: '',
259 - marginRight: '',
260 - marginBottom: '',
261 - marginLeft: '',
262 - borderRadius: '',
263 - maxWidth: '',
264 - });
265 - }
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.
266 343 };
267 344 }, [open]);
268 345 };