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/Canvas.jsx +113 -55 3.1.5 → trunk View file →
@@ -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>