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/Canvas.jsx +113 -55 3.1.5 → 3.2.2 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>