| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { usePortal } from '@agent/hooks/usePortal'; |
| 2 | 2 | import { useWhenFinishedToolProps } from '@agent/hooks/useWhenFinishedToolProps'; |
| 3 | +import { canvasSeat, canvasView } from '@agent/lib/canvas-views'; | |
| 3 | 4 | import { |
| 4 | 5 | CANVAS_CONFETTI, |
| 5 | 6 | confettiIn, |
| 6 | 7 | dropConfetti, |
| @@ -22,13 +23,19 @@ | ||
| 22 | 23 | const CANVAS_SIZE = { |
| 23 | 24 | width: 768, |
| 24 | 25 | height: 600, |
| 25 | 26 | maxWidth: '100%', |
| 26 | - maxHeight: '100%', | |
| 27 | + // A percentage never caps: the flex line is as tall as the box itself. | |
| 28 | + maxHeight: 'calc(100vh - 4rem)', | |
| 27 | 29 | }; |
| 28 | 30 | const SLIDE_TIME = 300; |
| 31 | +const TRANSITION = { duration: SLIDE_TIME / 1000, ease: 'easeInOut' }; | |
| 29 | 32 | const CANVAS_CLASS = 'extendify-agent-canvas-open'; |
| 30 | 33 | const BLUR_CLASS = 'extendify-agent-canvas-blurred'; |
| 34 | +const DIM_AGENT_CLASS = 'extendify-agent-canvas-dims-agent'; | |
| 35 | +const MOUNT_ID = 'extendify-agent-canvas-mount'; | |
| 36 | +// Blurring a panel's ancestor would reposition the fixed panel inside it. | |
| 37 | +const PANEL = '[data-extendify-agent-panel]'; | |
| 31 | 38 | export const DOT_GRID = { |
| 32 | 39 | backgroundImage: |
| 33 | 40 | 'radial-gradient(circle, rgb(0 0 0 / 0.12) 1px, transparent 1px)', |
| 34 | 41 | backgroundSize: '12px 12px', |
| @@ -73,20 +80,30 @@ | ||
| 73 | 80 | /> |
| 74 | 81 | ); |
| 75 | 82 | }; |
| 76 | 83 | |
| 77 | -// The mobile pill is a body sibling, so inerting every child kills it. | |
| 78 | -const PAGE = '#wpwrap, .wp-site-blocks, #wpadminbar, #extendify-toolbar'; | |
| 84 | +// Every node the agent mounts carries this, the mobile pill included. | |
| 85 | +const AGENT = '.extendify-agent'; | |
| 79 | 86 | |
| 87 | +const inertNodes = (nodes) => { | |
| 88 | + const covered = [...nodes].filter((el) => !el.hasAttribute('inert')); | |
| 89 | + for (const el of covered) el.setAttribute('inert', ''); | |
| 90 | + | |
| 91 | + return () => { | |
| 92 | + for (const el of covered) el.removeAttribute('inert'); | |
| 93 | + }; | |
| 94 | +}; | |
| 95 | + | |
| 80 | 96 | // A trap scoped to the surface would lock the user out of the chat. |
| 81 | -const inertPage = () => { | |
| 82 | - const page = [...document.querySelectorAll(PAGE)].filter( | |
| 83 | - (el) => !el.hasAttribute('inert'), | |
| 84 | - ); | |
| 85 | - for (const el of page) el.setAttribute('inert', ''); | |
| 97 | +const inertBehind = (dims) => { | |
| 98 | + const page = [...document.body.children].filter((el) => !el.matches(AGENT)); | |
| 99 | + const undo = [inertNodes(page)]; | |
| 100 | + if (dims === 'agent') { | |
| 101 | + undo.push(inertNodes(document.querySelectorAll(PANEL))); | |
| 102 | + } | |
| 86 | 103 | |
| 87 | 104 | return () => { |
| 88 | - for (const el of page) el.removeAttribute('inert'); | |
| 105 | + for (const fn of undo) fn(); | |
| 89 | 106 | }; |
| 90 | 107 | }; |
| 91 | 108 | |
| 92 | 109 | const useCanvasTool = () => { |
| @@ -92,50 +109,73 @@ | ||
| 92 | 109 | const useCanvasTool = () => { |
| 93 | 110 | const { getWorkflow } = useWorkflowStore(); |
| 94 | 111 | const props = useWhenFinishedToolProps(); |
| 95 | 112 | const { component, canvas } = getWorkflow()?.whenFinished ?? {}; |
| 96 | - if (!canvas || !component || !props?.id) return null; | |
| 97 | - return { component, props }; | |
| 113 | + const view = canvasView(canvas); | |
| 114 | + if (!view || !component || !props?.id) return null; | |
| 115 | + return { component, props, view }; | |
| 98 | 116 | }; |
| 99 | 117 | |
| 100 | -export const useCanvasWorkflow = () => | |
| 101 | - Boolean(useWorkflowStore().getWorkflow()?.whenFinished?.canvas); | |
| 118 | +const useWorkflowView = () => | |
| 119 | + canvasView(useWorkflowStore().getWorkflow()?.whenFinished?.canvas); | |
| 102 | 120 | |
| 121 | +export const useCanvasWorkflow = () => Boolean(useWorkflowView()); | |
| 122 | + | |
| 123 | +// Gates on the workflow, not the open surface, or the input flickers to busy first. | |
| 124 | +export const useCanvasAssist = () => Boolean(useWorkflowView()?.assist); | |
| 125 | + | |
| 103 | 126 | export const useCanvasOpen = () => Boolean(useCanvasTool()); |
| 104 | 127 | |
| 105 | -export const CanvasPane = () => { | |
| 128 | +export const useCanvasSeat = () => { | |
| 129 | + const { isMobile, mode } = useGlobalStore(); | |
| 130 | + const seat = useCanvasTool()?.view.seat ?? null; | |
| 131 | + return canvasSeat(seat, { mode, isMobile }); | |
| 132 | +}; | |
| 133 | + | |
| 134 | +// Two layouts can be mounted at once, so each names the seat it hosts. | |
| 135 | +export const CanvasPane = ({ seat }) => { | |
| 106 | 136 | const canvas = useCanvasTool(); |
| 137 | + const hosted = useCanvasSeat() === seat; | |
| 107 | 138 | |
| 108 | 139 | return ( |
| 109 | 140 | <AnimatePresence> |
| 110 | - {canvas ? ( | |
| 111 | - <motion.div | |
| 112 | - role="dialog" | |
| 113 | - aria-label={__('Agent canvas', 'extendify-local')} | |
| 114 | - className="relative z-0 min-h-0 shrink-0" | |
| 115 | - style={{ width: CANVAS_PANE_WIDTH }} | |
| 116 | - initial={{ opacity: 0 }} | |
| 117 | - animate={{ opacity: 1 }} | |
| 118 | - exit={{ opacity: 0 }} | |
| 119 | - transition={{ duration: SLIDE_TIME / 1000, ease: 'easeInOut' }} | |
| 120 | - > | |
| 121 | - <CloseCanvas onClick={canvas.props.onCancel} /> | |
| 122 | - <Celebration /> | |
| 123 | - <div className="relative z-10 h-full overflow-auto px-4 py-12"> | |
| 124 | - {/* The dotted background must not slide in with the component. */} | |
| 125 | - <motion.div | |
| 126 | - className="h-full" | |
| 127 | - initial={{ x: -CANVAS_PANE_WIDTH }} | |
| 128 | - animate={{ x: 0 }} | |
| 129 | - exit={{ x: -CANVAS_PANE_WIDTH }} | |
| 130 | - transition={{ duration: SLIDE_TIME / 1000, ease: 'easeInOut' }} | |
| 131 | - > | |
| 132 | - {createElement(canvas.component, canvas.props)} | |
| 133 | - </motion.div> | |
| 134 | - </div> | |
| 141 | + {hosted ? <SeatedCanvas canvas={canvas} seat={seat} /> : null} | |
| 142 | + </AnimatePresence> | |
| 143 | + ); | |
| 144 | +}; | |
| 145 | + | |
| 146 | +const SeatedCanvas = ({ canvas, seat }) => { | |
| 147 | + const beside = seat === 'beside'; | |
| 148 | + // Only the widening sidebar gives the pane somewhere to slide from. | |
| 149 | + const slide = beside | |
| 150 | + ? { | |
| 151 | + initial: { x: -CANVAS_PANE_WIDTH }, | |
| 152 | + animate: { x: 0 }, | |
| 153 | + exit: { x: -CANVAS_PANE_WIDTH }, | |
| 154 | + transition: TRANSITION, | |
| 155 | + } | |
| 156 | + : {}; | |
| 157 | + | |
| 158 | + return ( | |
| 159 | + <motion.div | |
| 160 | + role="dialog" | |
| 161 | + aria-label={__('Agent canvas', 'extendify-local')} | |
| 162 | + className={`relative z-0 min-h-0 ${beside ? 'shrink-0' : 'flex-1 min-w-0'}`} | |
| 163 | + style={beside ? { width: CANVAS_PANE_WIDTH } : undefined} | |
| 164 | + initial={{ opacity: 0 }} | |
| 165 | + animate={{ opacity: 1 }} | |
| 166 | + exit={{ opacity: 0 }} | |
| 167 | + transition={TRANSITION} | |
| 168 | + > | |
| 169 | + <CloseCanvas onClick={canvas.props.onCancel} /> | |
| 170 | + <Celebration /> | |
| 171 | + <div className="relative z-10 h-full overflow-auto px-4 py-12"> | |
| 172 | + {/* The dotted background must not slide in with the component. */} | |
| 173 | + <motion.div className="h-full" {...slide}> | |
| 174 | + {createElement(canvas.component, canvas.props)} | |
| 135 | 175 | </motion.div> |
| 136 | - ) : null} | |
| 137 | - </AnimatePresence> | |
| 176 | + </div> | |
| 177 | + </motion.div> | |
| 138 | 178 | ); |
| 139 | 179 | }; |
| 140 | 180 | |
| 141 | 181 | // Calling usePortal here would leave a mount node behind with no canvas open. |
| @@ -149,16 +189,35 @@ | ||
| 149 | 189 | ); |
| 150 | 190 | }; |
| 151 | 191 | |
| 152 | 192 | const CanvasOverlay = ({ canvas }) => { |
| 153 | - const { isMobile, mode } = useGlobalStore(); | |
| 154 | - const mountNode = usePortal('extendify-agent-canvas-mount'); | |
| 193 | + const { isMobile } = useGlobalStore(); | |
| 194 | + const mountNode = usePortal(MOUNT_ID); | |
| 155 | 195 | const isPresent = useIsPresent(); |
| 196 | + const { seat, dims } = canvas.view; | |
| 197 | + // Live state is empty during the exit, and a surface appearing then never leaves. | |
| 198 | + const embedded = Boolean(seat) && !isMobile; | |
| 156 | 199 | |
| 157 | - useEffect(inertPage, []); | |
| 200 | + // Lifting inert at unmount instead would leave the chat inert as it takes focus. | |
| 201 | + useEffect( | |
| 202 | + () => (isPresent ? inertBehind(dims) : undefined), | |
| 203 | + [isPresent, dims], | |
| 204 | + ); | |
| 158 | 205 | |
| 206 | + // Closing the canvas leaves focus on a button that no longer exists. | |
| 159 | 207 | useEffect(() => { |
| 160 | - document.body.classList.add(CANVAS_CLASS); | |
| 208 | + if (isPresent) return; | |
| 209 | + // The chat can be remounting into another layout in this same commit. | |
| 210 | + const frame = requestAnimationFrame(() => | |
| 211 | + document.querySelector('#extendify-agent-chat textarea')?.focus(), | |
| 212 | + ); | |
| 213 | + return () => cancelAnimationFrame(frame); | |
| 214 | + }, [isPresent]); | |
| 215 | + | |
| 216 | + useEffect(() => { | |
| 217 | + const classes = | |
| 218 | + dims === 'agent' ? [CANVAS_CLASS, DIM_AGENT_CLASS] : [CANVAS_CLASS]; | |
| 219 | + document.body.classList.add(...classes); | |
| 161 | 220 | // The page has to paint unblurred once, or there is nothing to ease from. |
| 162 | 221 | const frame = requestAnimationFrame(() => |
| 163 | 222 | document.body.classList.add(BLUR_CLASS), |
| 164 | 223 | ); |
| @@ -163,11 +222,11 @@ | ||
| 163 | 222 | document.body.classList.add(BLUR_CLASS), |
| 164 | 223 | ); |
| 165 | 224 | return () => { |
| 166 | 225 | cancelAnimationFrame(frame); |
| 167 | - document.body.classList.remove(CANVAS_CLASS, BLUR_CLASS); | |
| 226 | + document.body.classList.remove(...classes, BLUR_CLASS); | |
| 168 | 227 | }; |
| 169 | - }, []); | |
| 228 | + }, [dims]); | |
| 170 | 229 | |
| 171 | 230 | // Cleanup runs after the exit, too late for the blur to ease out with it. |
| 172 | 231 | useEffect(() => { |
| 173 | 232 | if (!isPresent) document.body.classList.remove(BLUR_CLASS); |
| @@ -178,20 +237,19 @@ | ||
| 178 | 237 | const fade = { |
| 179 | 238 | initial: { opacity: 0 }, |
| 180 | 239 | animate: { opacity: 1 }, |
| 181 | 240 | exit: { opacity: 0 }, |
| 182 | - transition: { duration: SLIDE_TIME / 1000, ease: 'easeInOut' }, | |
| 241 | + transition: TRANSITION, | |
| 183 | 242 | }; |
| 184 | - // Scrim and surface both clear the admin bar (99999) and stay under the panel (999999). | |
| 243 | + // Both clear the admin bar (99999); a dimmed agent panel (999999) needs clearing too. | |
| 244 | + const overAgent = dims === 'agent'; | |
| 185 | 245 | const scrim = ( |
| 186 | 246 | <motion.div |
| 187 | 247 | {...fade} |
| 188 | 248 | data-extendify-agent-scrim |
| 189 | - className="fixed inset-0 z-[100000] bg-black/60" | |
| 249 | + className={`fixed inset-0 bg-black/60 ${overAgent ? 'z-max-1' : 'z-[100000]'}`} | |
| 190 | 250 | /> |
| 191 | 251 | ); |
| 192 | - const embedded = mode === 'docked-left' && !isMobile; | |
| 193 | - | |
| 194 | 252 | return createPortal( |
| 195 | 253 | <> |
| 196 | 254 | {scrim} |
| 197 | 255 | {embedded ? null : ( |
| @@ -196,20 +254,20 @@ | ||
| 196 | 254 | {scrim} |
| 197 | 255 | {embedded ? null : ( |
| 198 | 256 | <motion.div |
| 199 | 257 | {...fade} |
| 200 | - className="pointer-events-none fixed inset-0 z-[100001] overflow-auto" | |
| 258 | + className={`pointer-events-none fixed inset-0 overflow-auto ${overAgent ? 'z-max' : 'z-[100001]'}`} | |
| 201 | 259 | > |
| 202 | 260 | <div className="pointer-events-auto flex min-h-full items-center justify-center px-4 py-8"> |
| 203 | 261 | <div |
| 204 | 262 | role="dialog" |
| 205 | 263 | aria-label={__('Agent canvas', 'extendify-local')} |
| 206 | - className="relative overflow-hidden rounded-2xl bg-gray-50 pt-12 shadow-lg" | |
| 264 | + className="relative overflow-hidden rounded-2xl bg-gray-50 shadow-lg" | |
| 207 | 265 | style={{ ...DOT_GRID, ...CANVAS_SIZE }} |
| 208 | 266 | > |
| 209 | 267 | <CloseCanvas onClick={canvas.props.onCancel} /> |
| 210 | 268 | <Celebration /> |
| 211 | - <div className="relative z-10 h-full overflow-auto px-4 pb-8"> | |
| 269 | + <div className="relative z-10 h-full overflow-auto px-4 pt-12 pb-8"> | |
| 212 | 270 | {createElement(canvas.component, canvas.props)} |
| 213 | 271 | </div> |
| 214 | 272 | </div> |
| 215 | 273 | </div> |