PluginProbe
Extendify / trunk
Extendify vtrunk
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
← All changes | src/Agent/components/layouts/SidebarLayout.jsx +156 -79 3.0.5 → trunk 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 };