PluginProbe
Slider Block by Sliderberg – WordPress Slider & Carousel Plugin for Gutenberg / trunk
Slider Block by Sliderberg – WordPress Slider & Carousel Plugin for Gutenberg vtrunk
1.2.3 1.2.2 1.2.0 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.7 1.0.8 1.0.9
sliderberg / shared / src / components / BackgroundImageControl.tsx

BackgroundImageControl.tsx in Slider Block by Sliderberg – WordPress Slider & Carousel Plugin for Gutenberg trunk, at shared/src/components/BackgroundImageControl.tsx

416 lines 11.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Background Image Control Component
3 * WordPress Gutenberg-style background image control with media upload,
4 * focal point picker, and fixed background toggle
5 *
6 * Follows WordPress patterns from:
7 * packages/block-editor/src/hooks/background.js
8 * packages/block-editor/src/components/background-image-control/index.js
9 */
10
11 import React, { useState, useRef } from "react";
12 // @ts-ignore - WordPress i18n package
13 import { __ } from "@wordpress/i18n";
14 // @ts-ignore - WordPress i18n package
15 import { sprintf } from "@wordpress/i18n";
16 import { useDispatch, useSelect } from "@wordpress/data";
17 import {
18 useBlockEditContext,
19 store as blockEditorStore,
20 } from "@wordpress/block-editor";
21 import {
22 Button,
23 FocalPointPicker,
24 ToggleControl,
25 DropZone,
26 Spinner,
27 VisuallyHidden,
28 Dropdown,
29 __experimentalVStack as VStack,
30 } from "@wordpress/components";
31 import { reset as resetIcon } from "@wordpress/icons";
32 import { isBlobURL } from "@wordpress/blob";
33 import { store as noticesStore } from "@wordpress/notices";
34 import { focus } from "@wordpress/dom";
35 import { getFilename } from "@wordpress/url";
36 import type { BackgroundImageControlProps } from "./types";
37
38 interface FocalPoint {
39 x: number;
40 y: number;
41 }
42
43 interface MediaObject {
44 id?: number;
45 url: string;
46 alt?: string;
47 media_type?: string;
48 type?: string;
49 }
50
51 // MediaReplaceFlow component - access from global wp object
52 const MediaReplaceFlow =
53 (window as any).wp.blockEditor?.MediaReplaceFlow || (() => null);
54
55 // Default values matching WordPress BACKGROUND_BLOCK_DEFAULT_VALUES
56
57 /**
58 * Focuses the toggle button for accessibility after modal/dropdown closes
59 * Uses requestAnimationFrame to ensure DOM updates are complete
60 */
61 const focusToggleButton = (containerRef: React.RefObject<HTMLDivElement>) => {
62 window.requestAnimationFrame(() => {
63 if (containerRef.current) {
64 const [toggleButton] = focus.tabbable.find(containerRef.current);
65 toggleButton?.focus();
66 }
67 });
68 };
69
70 /**
71 * Background Image Control Component
72 * Provides media upload, focal point picker, and fixed background toggle
73 * following WordPress Gutenberg patterns from the Cover block
74 */
75 const BackgroundImageControl: React.FC<BackgroundImageControlProps> = ({
76 label = __("Background Image", "sliderberg"),
77 }) => {
78 const { clientId } = useBlockEditContext();
79 const { updateBlockAttributes } = useDispatch("core/block-editor") as any;
80 const { createErrorNotice } = useDispatch(noticesStore) as any;
81
82 // State for upload tracking and UI feedback
83 const [isUploading, setIsUploading] = useState(false);
84 const containerRef = useRef<HTMLDivElement>(null);
85
86 // Popover props for consistent positioning
87 const BACKGROUND_POPOVER_PROPS = {
88 placement: "left-start" as const,
89 offset: 36,
90 shift: true,
91 };
92
93 const { backgroundImage, focalPoint, isFixed, mediaUpload } = useSelect(
94 (select: any) => {
95 const blockEditor = select(blockEditorStore);
96 const attributes = blockEditor.getBlockAttributes(clientId) as Record<
97 string,
98 any
99 >;
100 const settings = blockEditor.getSettings();
101 return {
102 backgroundImage: attributes.backgroundImage as MediaObject | null,
103 focalPoint: attributes.focalPoint as FocalPoint,
104 isFixed: attributes.isFixed as boolean,
105 mediaUpload: settings.mediaUpload,
106 };
107 },
108 [clientId],
109 ) as {
110 backgroundImage: MediaObject | null;
111 focalPoint: FocalPoint;
112 isFixed: boolean;
113 mediaUpload: any;
114 };
115
116 const setAttributes = (newAttributes: Record<string, any>) =>
117 updateBlockAttributes(clientId, newAttributes);
118
119 /**
120 * Handle upload errors with WordPress-native error notices
121 */
122 const onUploadError = (message: string) => {
123 createErrorNotice(message, { type: "snackbar" });
124 setIsUploading(false);
125 };
126
127 /**
128 * Handle media selection from media library
129 * Validates file types, handles blob URLs, and sets default position
130 */
131 const onSelectMedia = (media: MediaObject) => {
132 if (!media || !media.url) {
133 onRemoveImage();
134 setIsUploading(false);
135 return;
136 }
137
138 // Check if upload is still in progress (blob URL)
139 if (isBlobURL(media.url)) {
140 setIsUploading(true);
141 return;
142 }
143
144 // Validate file type - only allow images
145 const mediaType = (media as any).media_type || (media as any).type;
146 if (mediaType && mediaType !== "image") {
147 onUploadError(
148 __("Only images can be used as a background image.", "sliderberg"),
149 );
150 return;
151 }
152
153 setAttributes({
154 backgroundImage: {
155 id: media.id,
156 url: media.url,
157 alt: media.alt || "",
158 },
159 // Set default position '50% 0' to increase chance focus point is visible
160 focalPoint: focalPoint || { x: 0.5, y: 0 },
161 });
162
163 setIsUploading(false);
164 // Return focus to toggle button after selection
165 focusToggleButton(containerRef);
166 };
167
168 /**
169 * Clear/remove background image
170 */
171 const onRemoveImage = () => {
172 setAttributes({
173 backgroundImage: null,
174 focalPoint: { x: 0.5, y: 0.5 },
175 isFixed: false,
176 });
177 // Return focus to toggle button after removal
178 focusToggleButton(containerRef);
179 };
180
181 /**
182 * Handle drag-and-drop file uploads
183 * Restricts to single image file
184 */
185 const onFilesDrop = (filesList: File[]) => {
186 if (!mediaUpload) {
187 return;
188 }
189
190 mediaUpload({
191 allowedTypes: ["image"],
192 filesList,
193 onFileChange([image]: MediaObject[]) {
194 onSelectMedia(image);
195 },
196 onError: onUploadError,
197 multiple: false,
198 });
199 };
200
201 /**
202 * Update focal point - converts picker coords to CSS background-position
203 */
204 const onChangeFocalPoint = (newFocalPoint: FocalPoint) => {
205 setAttributes({
206 focalPoint: newFocalPoint,
207 });
208 };
209
210 /**
211 * Toggle fixed background
212 */
213 const onToggleFixed = (value: boolean) => {
214 setAttributes({ isFixed: value });
215 };
216
217 const imageLabel =
218 backgroundImage?.alt || getFilename(backgroundImage?.url || "") || label;
219
220 return (
221 <div
222 ref={containerRef}
223 className="sliderberg-background-image-control"
224 style={{ position: "relative" }}
225 >
226 {isUploading && (
227 <div
228 style={{
229 position: "absolute",
230 top: 0,
231 left: 0,
232 right: 0,
233 bottom: 0,
234 display: "flex",
235 alignItems: "center",
236 justifyContent: "center",
237 backgroundColor: "rgba(255, 255, 255, 0.8)",
238 zIndex: 1,
239 borderRadius: "2px",
240 }}
241 >
242 <Spinner />
243 </div>
244 )}
245
246 {!backgroundImage ? (
247 <>
248 <MediaReplaceFlow
249 mediaId={undefined}
250 mediaURL={undefined}
251 allowedTypes={["image"]}
252 accept="image/*"
253 onSelect={onSelectMedia}
254 onError={onUploadError}
255 name={label}
256 renderToggle={(props: any) => (
257 <Button
258 {...props}
259 __next40pxDefaultSize
260 variant="secondary"
261 style={{
262 width: "100%",
263 justifyContent: "center",
264 height: "40px",
265 }}
266 >
267 {isUploading ? <Spinner /> : props.children}
268 </Button>
269 )}
270 />
271 <DropZone
272 onFilesDrop={onFilesDrop}
273 label={__("Drop to upload", "sliderberg")}
274 />
275 </>
276 ) : (
277 <div
278 style={{
279 border: "1px solid #ddd",
280 borderRadius: "2px",
281 position: "relative",
282 }}
283 >
284 <Dropdown
285 popoverProps={BACKGROUND_POPOVER_PROPS}
286 renderToggle={({ onToggle, isOpen }) => (
287 <>
288 <button
289 type="button"
290 onClick={onToggle}
291 aria-expanded={isOpen}
292 aria-label={__("Background image options", "sliderberg")}
293 style={{
294 width: "100%",
295 display: "flex",
296 alignItems: "center",
297 padding: "12px",
298 background: isOpen ? "#f0f0f0" : "#fff",
299 border: "none",
300 cursor: "pointer",
301 transition: "background 0.1s ease",
302 gap: "12px",
303 }}
304 onMouseEnter={(e) => {
305 if (!isOpen) {
306 e.currentTarget.style.background = "#f9f9f9";
307 }
308 }}
309 onMouseLeave={(e) => {
310 if (!isOpen) {
311 e.currentTarget.style.background = "#fff";
312 }
313 }}
314 >
315 <div
316 style={{
317 width: "32px",
318 height: "32px",
319 borderRadius: "50%",
320 overflow: "hidden",
321 flexShrink: 0,
322 backgroundImage: `url(${backgroundImage.url})`,
323 backgroundSize: "cover",
324 backgroundPosition: `${(focalPoint?.x || 0.5) * 100}% ${
325 (focalPoint?.y || 0.5) * 100
326 }%`,
327 border: "1px solid #ddd",
328 }}
329 />
330 <span
331 style={{
332 flex: 1,
333 textAlign: "left",
334 color: "#2271b1",
335 fontSize: "13px",
336 overflow: "hidden",
337 }}
338 >
339 {imageLabel}
340 </span>
341 </button>
342 <Button
343 __next40pxDefaultSize
344 label={__("Reset", "sliderberg")}
345 icon={resetIcon}
346 size="small"
347 onClick={() => {
348 onRemoveImage();
349 focusToggleButton(containerRef);
350 }}
351 style={{
352 position: "absolute",
353 top: "8px",
354 right: "8px",
355 minWidth: "auto",
356 padding: "4px",
357 zIndex: 1,
358 }}
359 />
360 </>
361 )}
362 renderContent={() => (
363 <div
364 style={{
365 padding: "16px",
366 minWidth: "320px",
367 maxWidth: "400px",
368 }}
369 >
370 <VStack spacing={5}>
371 <div>
372 <div
373 style={{
374 fontSize: "11px",
375 fontWeight: 500,
376 textTransform: "uppercase",
377 color: "#1e1e1e",
378 marginBottom: "8px",
379 letterSpacing: "0.4px",
380 }}
381 >
382 {__("Focal Point", "sliderberg")}
383 </div>
384 <FocalPointPicker
385 url={backgroundImage.url}
386 value={focalPoint || { x: 0.5, y: 0.5 }}
387 onChange={onChangeFocalPoint}
388 />
389 </div>
390 <ToggleControl
391 label={__("Fixed background", "sliderberg")}
392 checked={isFixed}
393 onChange={onToggleFixed}
394 />
395 </VStack>
396 </div>
397 )}
398 />
399 <DropZone
400 onFilesDrop={onFilesDrop}
401 label={__("Drop to upload", "sliderberg")}
402 />
403 </div>
404 )}
405
406 <VisuallyHidden as="span">
407 {backgroundImage
408 ? sprintf(__("Background image: %s", "sliderberg"), imageLabel)
409 : __("No background image selected", "sliderberg")}
410 </VisuallyHidden>
411 </div>
412 );
413 };
414
415 export default BackgroundImageControl;
416