PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
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.1.5, at src/Agent/components/Canvas.jsx

222 lines 6.1 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 {
4 CANVAS_CONFETTI,
5 confettiIn,
6 dropConfetti,
7 FALLING,
8 } from '@agent/lib/confetti';
9 import { useGlobalStore } from '@agent/state/global';
10 import { useWorkflowStore } from '@agent/state/workflows';
11 import {
12 createElement,
13 createPortal,
14 useEffect,
15 useRef,
16 } from '@wordpress/element';
17 import { __ } from '@wordpress/i18n';
18 import { close, Icon } from '@wordpress/icons';
19 import { AnimatePresence, motion, useIsPresent } from 'framer-motion';
20
21 export const CANVAS_PANE_WIDTH = 384;
22 const CANVAS_SIZE = {
23 width: 768,
24 height: 600,
25 maxWidth: '100%',
26 maxHeight: '100%',
27 };
28 const SLIDE_TIME = 300;
29 const CANVAS_CLASS = 'extendify-agent-canvas-open';
30 const BLUR_CLASS = 'extendify-agent-canvas-blurred';
31 export const DOT_GRID = {
32 backgroundImage:
33 'radial-gradient(circle, rgb(0 0 0 / 0.12) 1px, transparent 1px)',
34 backgroundSize: '12px 12px',
35 };
36
37 const CloseCanvas = ({ onClick }) => (
38 <button
39 type="button"
40 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"
41 onClick={onClick}
42 >
43 <Icon
44 className="pointer-events-none fill-current leading-none"
45 icon={close}
46 size={18}
47 />
48 <span className="sr-only">{__('Close', 'extendify-local')}</span>
49 </button>
50 );
51
52 const Celebration = () => {
53 const canvas = useRef(null);
54
55 useEffect(() => {
56 const shoot = confettiIn(canvas.current);
57 let stop = () => {};
58 const burst = ({ detail }) => {
59 stop();
60 stop = dropConfetti(shoot, detail ?? FALLING);
61 };
62 window.addEventListener(CANVAS_CONFETTI, burst);
63 return () => {
64 window.removeEventListener(CANVAS_CONFETTI, burst);
65 stop();
66 };
67 }, []);
68
69 return (
70 <canvas
71 ref={canvas}
72 className="pointer-events-none absolute inset-0 z-0 h-full w-full"
73 />
74 );
75 };
76
77 // The mobile pill is a body sibling, so inerting every child kills it.
78 const PAGE = '#wpwrap, .wp-site-blocks, #wpadminbar, #extendify-toolbar';
79
80 // 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', '');
86
87 return () => {
88 for (const el of page) el.removeAttribute('inert');
89 };
90 };
91
92 const useCanvasTool = () => {
93 const { getWorkflow } = useWorkflowStore();
94 const props = useWhenFinishedToolProps();
95 const { component, canvas } = getWorkflow()?.whenFinished ?? {};
96 if (!canvas || !component || !props?.id) return null;
97 return { component, props };
98 };
99
100 export const useCanvasWorkflow = () =>
101 Boolean(useWorkflowStore().getWorkflow()?.whenFinished?.canvas);
102
103 export const useCanvasOpen = () => Boolean(useCanvasTool());
104
105 export const CanvasPane = () => {
106 const canvas = useCanvasTool();
107
108 return (
109 <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>
135 </motion.div>
136 ) : null}
137 </AnimatePresence>
138 );
139 };
140
141 // Calling usePortal here would leave a mount node behind with no canvas open.
142 export const Canvas = () => {
143 const canvas = useCanvasTool();
144
145 return (
146 <AnimatePresence>
147 {canvas ? <CanvasOverlay canvas={canvas} /> : null}
148 </AnimatePresence>
149 );
150 };
151
152 const CanvasOverlay = ({ canvas }) => {
153 const { isMobile, mode } = useGlobalStore();
154 const mountNode = usePortal('extendify-agent-canvas-mount');
155 const isPresent = useIsPresent();
156
157 useEffect(inertPage, []);
158
159 useEffect(() => {
160 document.body.classList.add(CANVAS_CLASS);
161 // The page has to paint unblurred once, or there is nothing to ease from.
162 const frame = requestAnimationFrame(() =>
163 document.body.classList.add(BLUR_CLASS),
164 );
165 return () => {
166 cancelAnimationFrame(frame);
167 document.body.classList.remove(CANVAS_CLASS, BLUR_CLASS);
168 };
169 }, []);
170
171 // Cleanup runs after the exit, too late for the blur to ease out with it.
172 useEffect(() => {
173 if (!isPresent) document.body.classList.remove(BLUR_CLASS);
174 }, [isPresent]);
175
176 if (!mountNode) return null;
177
178 const fade = {
179 initial: { opacity: 0 },
180 animate: { opacity: 1 },
181 exit: { opacity: 0 },
182 transition: { duration: SLIDE_TIME / 1000, ease: 'easeInOut' },
183 };
184 // Scrim and surface both clear the admin bar (99999) and stay under the panel (999999).
185 const scrim = (
186 <motion.div
187 {...fade}
188 data-extendify-agent-scrim
189 className="fixed inset-0 z-[100000] bg-black/60"
190 />
191 );
192 const embedded = mode === 'docked-left' && !isMobile;
193
194 return createPortal(
195 <>
196 {scrim}
197 {embedded ? null : (
198 <motion.div
199 {...fade}
200 className="pointer-events-none fixed inset-0 z-[100001] overflow-auto"
201 >
202 <div className="pointer-events-auto flex min-h-full items-center justify-center px-4 py-8">
203 <div
204 role="dialog"
205 aria-label={__('Agent canvas', 'extendify-local')}
206 className="relative overflow-hidden rounded-2xl bg-gray-50 pt-12 shadow-lg"
207 style={{ ...DOT_GRID, ...CANVAS_SIZE }}
208 >
209 <CloseCanvas onClick={canvas.props.onCancel} />
210 <Celebration />
211 <div className="relative z-10 h-full overflow-auto px-4 pb-8">
212 {createElement(canvas.component, canvas.props)}
213 </div>
214 </div>
215 </div>
216 </motion.div>
217 )}
218 </>,
219 mountNode,
220 );
221 };
222