PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
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
extendify / src / Agent / components / Canvas.jsx

Canvas.jsx in Extendify 3.2.1, at src/Agent/components/Canvas.jsx

280 lines 8.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { usePortal } from '@agent/hooks/usePortal';
2 import { useWhenFinishedToolProps } from '@agent/hooks/useWhenFinishedToolProps';
3 import { canvasSeat, canvasView } from '@agent/lib/canvas-views';
4 import {
5 CANVAS_CONFETTI,
6 confettiIn,
7 dropConfetti,
8 FALLING,
9 } from '@agent/lib/confetti';
10 import { useGlobalStore } from '@agent/state/global';
11 import { useWorkflowStore } from '@agent/state/workflows';
12 import {
13 createElement,
14 createPortal,
15 useEffect,
16 useRef,
17 } from '@wordpress/element';
18 import { __ } from '@wordpress/i18n';
19 import { close, Icon } from '@wordpress/icons';
20 import { AnimatePresence, motion, useIsPresent } from 'framer-motion';
21
22 export const CANVAS_PANE_WIDTH = 384;
23 const CANVAS_SIZE = {
24 width: 768,
25 height: 600,
26 maxWidth: '100%',
27 // A percentage never caps: the flex line is as tall as the box itself.
28 maxHeight: 'calc(100vh - 4rem)',
29 };
30 const SLIDE_TIME = 300;
31 const TRANSITION = { duration: SLIDE_TIME / 1000, ease: 'easeInOut' };
32 const CANVAS_CLASS = 'extendify-agent-canvas-open';
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]';
38 export const DOT_GRID = {
39 backgroundImage:
40 'radial-gradient(circle, rgb(0 0 0 / 0.12) 1px, transparent 1px)',
41 backgroundSize: '12px 12px',
42 };
43
44 const CloseCanvas = ({ onClick }) => (
45 <button
46 type="button"
47 className="absolute end-2 top-2 z-20 flex h-6 w-6 items-center justify-center rounded-sm border-0 bg-transparent text-gray-900 outline-hidden ring-design-main hover:opacity-80 focus:shadow-none focus:outline-hidden focus:ring-2 focus-visible:outline-design-main"
48 onClick={onClick}
49 >
50 <Icon
51 className="pointer-events-none fill-current leading-none"
52 icon={close}
53 size={18}
54 />
55 <span className="sr-only">{__('Close', 'extendify-local')}</span>
56 </button>
57 );
58
59 const Celebration = () => {
60 const canvas = useRef(null);
61
62 useEffect(() => {
63 const shoot = confettiIn(canvas.current);
64 let stop = () => {};
65 const burst = ({ detail }) => {
66 stop();
67 stop = dropConfetti(shoot, detail ?? FALLING);
68 };
69 window.addEventListener(CANVAS_CONFETTI, burst);
70 return () => {
71 window.removeEventListener(CANVAS_CONFETTI, burst);
72 stop();
73 };
74 }, []);
75
76 return (
77 <canvas
78 ref={canvas}
79 className="pointer-events-none absolute inset-0 z-0 h-full w-full"
80 />
81 );
82 };
83
84 // Every node the agent mounts carries this, the mobile pill included.
85 const AGENT = '.extendify-agent';
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
96 // A trap scoped to the surface would lock the user out of the chat.
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 }
103
104 return () => {
105 for (const fn of undo) fn();
106 };
107 };
108
109 const useCanvasTool = () => {
110 const { getWorkflow } = useWorkflowStore();
111 const props = useWhenFinishedToolProps();
112 const { component, canvas } = getWorkflow()?.whenFinished ?? {};
113 const view = canvasView(canvas);
114 if (!view || !component || !props?.id) return null;
115 return { component, props, view };
116 };
117
118 const useWorkflowView = () =>
119 canvasView(useWorkflowStore().getWorkflow()?.whenFinished?.canvas);
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
126 export const useCanvasOpen = () => Boolean(useCanvasTool());
127
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 }) => {
136 const canvas = useCanvasTool();
137 const hosted = useCanvasSeat() === seat;
138
139 return (
140 <AnimatePresence>
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)}
175 </motion.div>
176 </div>
177 </motion.div>
178 );
179 };
180
181 // Calling usePortal here would leave a mount node behind with no canvas open.
182 export const Canvas = () => {
183 const canvas = useCanvasTool();
184
185 return (
186 <AnimatePresence>
187 {canvas ? <CanvasOverlay canvas={canvas} /> : null}
188 </AnimatePresence>
189 );
190 };
191
192 const CanvasOverlay = ({ canvas }) => {
193 const { isMobile } = useGlobalStore();
194 const mountNode = usePortal(MOUNT_ID);
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;
199
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 );
205
206 // Closing the canvas leaves focus on a button that no longer exists.
207 useEffect(() => {
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);
220 // The page has to paint unblurred once, or there is nothing to ease from.
221 const frame = requestAnimationFrame(() =>
222 document.body.classList.add(BLUR_CLASS),
223 );
224 return () => {
225 cancelAnimationFrame(frame);
226 document.body.classList.remove(...classes, BLUR_CLASS);
227 };
228 }, [dims]);
229
230 // Cleanup runs after the exit, too late for the blur to ease out with it.
231 useEffect(() => {
232 if (!isPresent) document.body.classList.remove(BLUR_CLASS);
233 }, [isPresent]);
234
235 if (!mountNode) return null;
236
237 const fade = {
238 initial: { opacity: 0 },
239 animate: { opacity: 1 },
240 exit: { opacity: 0 },
241 transition: TRANSITION,
242 };
243 // Both clear the admin bar (99999); a dimmed agent panel (999999) needs clearing too.
244 const overAgent = dims === 'agent';
245 const scrim = (
246 <motion.div
247 {...fade}
248 data-extendify-agent-scrim
249 className={`fixed inset-0 bg-black/60 ${overAgent ? 'z-max-1' : 'z-[100000]'}`}
250 />
251 );
252 return createPortal(
253 <>
254 {scrim}
255 {embedded ? null : (
256 <motion.div
257 {...fade}
258 className={`pointer-events-none fixed inset-0 overflow-auto ${overAgent ? 'z-max' : 'z-[100001]'}`}
259 >
260 <div className="pointer-events-auto flex min-h-full items-center justify-center px-4 py-8">
261 <div
262 role="dialog"
263 aria-label={__('Agent canvas', 'extendify-local')}
264 className="relative overflow-hidden rounded-2xl bg-gray-50 shadow-lg"
265 style={{ ...DOT_GRID, ...CANVAS_SIZE }}
266 >
267 <CloseCanvas onClick={canvas.props.onCancel} />
268 <Celebration />
269 <div className="relative z-10 h-full overflow-auto px-4 pt-12 pb-8">
270 {createElement(canvas.component, canvas.props)}
271 </div>
272 </div>
273 </div>
274 </motion.div>
275 )}
276 </>,
277 mountNode,
278 );
279 };
280