PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.2.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.2.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
better-payment / src / campaign-builder / components / LiveCanvas.js

LiveCanvas.js in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.2.1, at src/campaign-builder/components/LiveCanvas.js

419 lines 17.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
2 import { __ } from '@wordpress/i18n';
3 import apiFetch from '@wordpress/api-fetch';
4 import { useSortable, SortableContext } from '@dnd-kit/sortable';
5 import { useDroppable } from '@dnd-kit/core';
6 import { CSS } from '@dnd-kit/utilities';
7
8 // ── SVG icons (match ElementCard toolbar exactly) ─────────────────────────────
9
10 const IconDrag = () => (
11 <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
12 <path d="M6 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm4 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-4 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm4 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-4 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm4 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" fill="currentColor"/>
13 </svg>
14 );
15
16 const IconEdit = () => (
17 <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
18 <path d="M11.5 2.5l2 2-7 7H4.5v-2l7-7z" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
19 </svg>
20 );
21
22 const IconDelete = () => (
23 <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
24 <path d="M2 4h12M5.333 4V2.667C5.333 2.313 5.473 1.974 5.724 1.724 5.974 1.473 6.313 1.333 6.667 1.333H9.333C9.687 1.333 10.026 1.473 10.276 1.724 10.527 1.974 10.667 2.313 10.667 2.667V4M12.667 4 12 13.333C12 13.687 11.86 14.026 11.609 14.276 11.359 14.527 11.02 14.667 10.667 14.667H5.333C4.98 14.667 4.641 14.527 4.391 14.276 4.14 14.026 4 13.687 4 13.333L3.333 4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
25 </svg>
26 );
27
28 const IconMoveUp = () => (
29 <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
30 <path d="M8 12V4M4 8l4-4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
31 </svg>
32 );
33
34 const IconMoveDown = () => (
35 <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
36 <path d="M8 4v8m4-4-4 4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
37 </svg>
38 );
39
40 // ── ElementHotspot — draggable transparent overlay over one rendered element ──
41
42 function ElementHotspot( {
43 element,
44 columnId,
45 index,
46 totalElements,
47 rect,
48 isSelected,
49 isDropTarget,
50 dispatch,
51 } ) {
52 const {
53 attributes,
54 listeners,
55 setNodeRef,
56 transform,
57 transition,
58 isDragging,
59 } = useSortable( {
60 id: element.id,
61 data: { source: 'canvas', columnId, element },
62 } );
63
64 if ( ! rect ) return null;
65
66 const style = {
67 position: 'absolute',
68 top: rect.top,
69 left: rect.left,
70 width: rect.width,
71 height: rect.height,
72 // Only apply dnd-kit transform to the actively dragged hotspot.
73 transform: isDragging ? CSS.Transform.toString( transform ) : undefined,
74 transition: isDragging ? transition : undefined,
75 zIndex: isDragging ? 50 : 2,
76 };
77
78 const isFirst = index === 0;
79 const isLast = index === totalElements - 1;
80
81 return (
82 <div
83 ref={ setNodeRef }
84 style={ style }
85 className={ [
86 'bp-lc-hotspot',
87 isSelected ? 'is-selected' : '',
88 isDragging ? 'is-dragging' : '',
89 isDropTarget ? 'is-drop-target' : '',
90 ].filter( Boolean ).join( ' ' ) }
91 onClick={ ( e ) => {
92 e.stopPropagation();
93 dispatch( { type: 'SELECT_ELEMENT', elementId: element.id, columnId } );
94 } }
95 { ...attributes }
96 >
97 {/* Floating toolbar — appears on hover / when selected */}
98 <div className="bp-lc-hotspot__toolbar">
99 <button
100 type="button"
101 className="bp-lc-hotspot__toolbar-btn bp-lc-hotspot__toolbar-btn--drag"
102 title={ __( 'Drag to reorder', 'better-payment' ) }
103 onClick={ ( e ) => e.stopPropagation() }
104 { ...listeners }
105 >
106 <IconDrag />
107 </button>
108
109 { ! isFirst && (
110 <button
111 type="button"
112 className="bp-lc-hotspot__toolbar-btn"
113 title={ __( 'Move up', 'better-payment' ) }
114 onClick={ ( e ) => {
115 e.stopPropagation();
116 dispatch( { type: 'REORDER_ELEMENTS', columnId, oldIndex: index, newIndex: index - 1 } );
117 } }
118 >
119 <IconMoveUp />
120 </button>
121 ) }
122
123 { ! isLast && (
124 <button
125 type="button"
126 className="bp-lc-hotspot__toolbar-btn"
127 title={ __( 'Move down', 'better-payment' ) }
128 onClick={ ( e ) => {
129 e.stopPropagation();
130 dispatch( { type: 'REORDER_ELEMENTS', columnId, oldIndex: index, newIndex: index + 1 } );
131 } }
132 >
133 <IconMoveDown />
134 </button>
135 ) }
136
137 <button
138 type="button"
139 className="bp-lc-hotspot__toolbar-btn"
140 title={ __( 'Edit element', 'better-payment' ) }
141 onClick={ ( e ) => {
142 e.stopPropagation();
143 dispatch( { type: 'SELECT_ELEMENT', elementId: element.id, columnId } );
144 } }
145 >
146 <IconEdit />
147 </button>
148
149 <button
150 type="button"
151 className="bp-lc-hotspot__toolbar-btn bp-lc-hotspot__toolbar-btn--delete"
152 title={ __( 'Delete', 'better-payment' ) }
153 onClick={ ( e ) => {
154 e.stopPropagation();
155 dispatch( { type: 'REMOVE_ELEMENT', columnId, elementId: element.id } );
156 } }
157 >
158 <IconDelete />
159 </button>
160 </div>
161 </div>
162 );
163 }
164
165 // ── ColumnDropZone — droppable zone matching the rendered column ──────────────
166
167 function ColumnDropZone( { column, rect } ) {
168 const isEmpty = column.elements.length === 0;
169
170 const { setNodeRef, isOver } = useDroppable( {
171 id: `col-${ column.id }`,
172 data: { columnId: column.id },
173 } );
174
175 if ( ! rect ) return null;
176
177 return (
178 <div
179 ref={ setNodeRef }
180 style={ {
181 position: 'absolute',
182 top: rect.top,
183 left: rect.left,
184 width: rect.width,
185 height: rect.height,
186 zIndex: 1,
187 pointerEvents: 'none',
188 } }
189 className={ [
190 'bp-lc-col-zone',
191 isOver ? 'is-over' : '',
192 isEmpty ? 'is-empty' : '',
193 ].filter( Boolean ).join( ' ' ) }
194 >
195 { isEmpty && (
196 <div className="bp-lc-col-zone__hint">
197 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
198 <path d="M12 5v14M5 12h14"/>
199 </svg>
200 <span>{ __( 'Drop elements here', 'better-payment' ) }</span>
201 </div>
202 ) }
203 </div>
204 );
205 }
206
207 // ── LiveCanvas ────────────────────────────────────────────────────────────────
208
209 export default function LiveCanvas( {
210 state,
211 campaignId,
212 selectedElementId,
213 dispatch,
214 dropTargetElementId,
215 } ) {
216 const [ previewHtml, setPreviewHtml ] = useState( '' );
217 const [ loading, setLoading ] = useState( false );
218 const [ iframeHeight, setIframeHeight ] = useState( 300 );
219 const [ elementRects, setElementRects ] = useState( {} );
220 const [ columnRects, setColumnRects ] = useState( {} );
221
222 const iframeRef = useRef( null );
223 const debounceRef = useRef( null );
224 const requestIdRef = useRef( 0 );
225 const isFirstRender = useRef( true );
226 const previewHtmlRef = useRef( '' );
227
228 const columns = state.layout?.columns || [];
229
230 // Clear stale hotspot rects the moment layout changes so misplaced hotspots
231 // don't linger while the new iframe preview is loading.
232 useEffect( () => {
233 if ( isFirstRender.current ) return;
234 setElementRects( {} );
235 setColumnRects( {} );
236 }, [ state.layout ] ); // eslint-disable-line react-hooks/exhaustive-deps
237
238 // ── Fetch preview HTML (debounced 200ms, immediate on first render) ───────
239 useEffect( () => {
240 const delay = isFirstRender.current ? 0 : 200;
241 isFirstRender.current = false;
242
243 clearTimeout( debounceRef.current );
244 debounceRef.current = setTimeout( () => {
245 const requestId = ++requestIdRef.current;
246 setLoading( true );
247
248 apiFetch( {
249 path: 'better-payment/v1/campaigns/preview',
250 method: 'POST',
251 data: {
252 campaign_id: campaignId || 0,
253 layout: state.layout,
254 meta: state.meta,
255 },
256 } )
257 .then( ( data ) => {
258 if ( requestId !== requestIdRef.current ) return;
259 const newHtml = data.html || '';
260 const unchanged = newHtml === previewHtmlRef.current;
261 previewHtmlRef.current = newHtml;
262 setPreviewHtml( newHtml );
263 setLoading( false );
264 if ( unchanged ) {
265 // React won't update srcDoc (same string) → iframe won't
266 // reload → onLoad won't fire → measure manually so hotspots
267 // are restored after the rect-clearing effect ran.
268 requestAnimationFrame( measurePositions );
269 }
270 } )
271 .catch( () => {
272 if ( requestId !== requestIdRef.current ) return;
273 setLoading( false );
274 } );
275 }, delay );
276
277 return () => clearTimeout( debounceRef.current );
278 }, [ state.layout, state.meta, campaignId ] ); // eslint-disable-line react-hooks/exhaustive-deps
279
280 // ── Measure element/column positions after iframe loads ───────────────────
281 const measurePositions = useCallback( () => {
282 const iframe = iframeRef.current;
283 if ( ! iframe || ! iframe.contentDocument ) return;
284
285 const doc = iframe.contentDocument;
286
287 // Auto-height: expand iframe to its full content height so there is no
288 // internal scroll. This keeps the iframe viewport origin fixed at (0,0)
289 // relative to the iframe document — a prerequisite for the rect math below.
290 const contentHeight = Math.max(
291 doc.documentElement.scrollHeight,
292 doc.body ? doc.body.scrollHeight : 0
293 );
294 setIframeHeight( contentHeight );
295
296 // getBoundingClientRect() called from inside the iframe returns coords in
297 // the iframe's own viewport space (origin = iframe top-left = 0,0).
298 // The overlay is position:absolute starting at the same (0,0) corner of
299 // .bp-lc-inner, so we use r.top / r.left directly — no parent-window
300 // offset needed.
301 //
302 // PADDING: expand each hotspot 4px beyond the element boundary so the
303 // hover border has breathing room and doesn't sit flush on the content.
304 const PAD = 4;
305
306 const elRects = {};
307 doc.querySelectorAll( '[data-bp-element-id]' ).forEach( ( el ) => {
308 const r = el.getBoundingClientRect();
309 elRects[ el.dataset.bpElementId ] = {
310 top: r.top - PAD,
311 left: r.left - PAD,
312 width: r.width + PAD * 2,
313 height: r.height + PAD * 2,
314 };
315 } );
316 setElementRects( elRects );
317
318 const colRects = {};
319 doc.querySelectorAll( '[data-bp-column-id]' ).forEach( ( el ) => {
320 const r = el.getBoundingClientRect();
321 colRects[ el.dataset.bpColumnId ] = {
322 top: r.top,
323 left: r.left,
324 width: r.width,
325 height: r.height,
326 };
327 } );
328 setColumnRects( colRects );
329 }, [] );
330
331 // Re-measure on window resize.
332 useEffect( () => {
333 window.addEventListener( 'resize', measurePositions );
334 return () => window.removeEventListener( 'resize', measurePositions );
335 }, [ measurePositions ] );
336
337 // ── Empty state ───────────────────────────────────────────────────────────
338 if ( columns.length === 0 ) {
339 return (
340 <div className="bp-cb-canvas bp-cb-canvas--empty">
341 <div className="bp-cb-canvas-placeholder">
342 <button
343 className="bp-cb-canvas-placeholder__add"
344 title={ __( 'Choose a template to start', 'better-payment' ) }
345 onClick={ () => dispatch( { type: 'OPEN_TEMPLATE_MODAL' } ) }
346 >
347 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">
348 <circle cx="16" cy="16" r="15" stroke="#bbb" strokeWidth="1.5"/>
349 <path d="M16 10v12M10 16h12" stroke="#bbb" strokeWidth="2" strokeLinecap="round"/>
350 </svg>
351 </button>
352 <p>{ __( 'Drag elements from the right panel, or choose a template.', 'better-payment' ) }</p>
353 </div>
354 </div>
355 );
356 }
357
358 // ── Render ────────────────────────────────────────────────────────────────
359 return (
360 <div
361 className="bp-lc-wrap"
362 onClick={ () => dispatch( { type: 'DESELECT_ELEMENT' } ) }
363 >
364 {/* Shade — direct child of bp-lc-wrap so position:absolute;inset:0 covers
365 the full canvas area (flex slot), not just the iframe content height */}
366 { loading && <div className="bp-lc-shade" /> }
367
368 {/* Outer shell — iframe + overlay share the same coordinate space */}
369 <div className="bp-lc-inner" style={ { height: iframeHeight || 'auto' } }>
370
371 {/* Live preview iframe — pointer-events disabled so overlay intercepts all input */}
372 <iframe
373 ref={ iframeRef }
374 srcDoc={ previewHtml }
375 className="bp-lc-iframe"
376 style={ { height: iframeHeight } }
377 onLoad={ measurePositions }
378 title={ __( 'Campaign Preview', 'better-payment' ) }
379 sandbox="allow-same-origin allow-scripts"
380 />
381
382 {/* Transparent interaction overlay */}
383 <div
384 className="bp-lc-overlay"
385 style={ { height: iframeHeight } }
386 >
387 { columns.map( ( column ) => (
388 <SortableContext
389 key={ column.id }
390 items={ column.elements.map( ( el ) => el.id ) }
391 >
392 {/* Column drop zone for panel-drag targets */}
393 <ColumnDropZone
394 column={ column }
395 rect={ columnRects[ column.id ] }
396 />
397
398 {/* Per-element hotspots */}
399 { column.elements.map( ( element, index ) => (
400 <ElementHotspot
401 key={ element.id }
402 element={ element }
403 columnId={ column.id }
404 index={ index }
405 totalElements={ column.elements.length }
406 rect={ elementRects[ element.id ] }
407 isSelected={ element.id === selectedElementId }
408 isDropTarget={ element.id === dropTargetElementId }
409 dispatch={ dispatch }
410 />
411 ) ) }
412 </SortableContext>
413 ) ) }
414 </div>
415 </div>
416 </div>
417 );
418 }
419