| @@ -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 | }; |