import { InspectorControls, useBlockProps, BlockControls, MediaPlaceholder } from '@wordpress/block-editor'; import { PanelBody, RangeControl, ToolbarGroup, ToolbarButton, ToggleControl, TextControl, TextareaControl, SelectControl, Popover, Button, } from '@wordpress/components'; // @ts-ignore - LinkControl is experimental import { __experimentalLinkControl as LinkControl } from '@wordpress/block-editor'; import { ColorPickerNullable } from './ColorPickerNullable'; import { __ } from '@wordpress/i18n'; import { useRef, useEffect, useState } from '@wordpress/element'; import { link as linkIcon } from '@wordpress/icons'; import { CameraState } from '../../types/model-viewer'; import { ModelViewer } from '../../model-viewer/ModelViewer'; // type definition for the block attributes export interface ModelBlockAttributes { modelUrl?: string; mimeType?: string; cameraState?: CameraState | undefined; draggable?: boolean; zoom?: number; width?: number; height?: number; color?: string; shinyMode?: boolean; shinyIntensity?: number; lightIntensity?: number; autoRotate?: boolean; autoRotateSpeed?: number; autoRotateX?: boolean; autoRotateY?: boolean; widthValue?: number; widthUnit?: 'px' | 'em' | 'rem' | 'vh' | 'vw' | '%'; heightValue?: number; heightUnit?: 'px' | 'em' | 'rem' | 'vh' | 'vw' | '%'; pluginVersion?: string; linkUrl?: string; linkOpenInNewTab?: boolean; altText?: string; enableZoom?: boolean; enableRotation?: boolean; enablePan?: boolean; enableShadows?: boolean; } // type for the props passed to the Edit component interface EditProps { attributes: ModelBlockAttributes; setAttributes: (attrs: Partial) => void; clientId: string; } /** * Edit component for the 3D model block. */ export default function Edit({ attributes, setAttributes, clientId }: EditProps) { const { modelUrl, cameraState = undefined, zoom = (window as any).Press3D?.defaultZoom ?? 0.7, lightIntensity = 1.5, color = undefined, // Let ModelViewer apply default color from settings for STL/OBJ shinyMode = false, shinyIntensity = 0, autoRotate = false, autoRotateSpeed = 2, autoRotateX = false, autoRotateY = true, widthValue = (window as any).Press3D?.defaultWidthValue ?? 100, widthUnit = (window as any).Press3D?.defaultWidthUnit ?? '%', heightValue = (window as any).Press3D?.defaultHeightValue ?? 300, heightUnit = (window as any).Press3D?.defaultHeightUnit ?? 'px', pluginVersion = (window as any).Press3D?.version, linkUrl = '', linkOpenInNewTab = false, altText = '', enableZoom = false, enableRotation = false, enablePan = false, enableShadows = false, mimeType, } = attributes; // Check if model is STL or OBJ based on file extension from URL const isStlOrObj = modelUrl ? /\.(stl|obj)$/i.test(modelUrl) : false; // Disable shininess for STL/OBJ if no color is selected const isShinyDisabled = isStlOrObj && !color; const modelRef = useRef(null); const viewerInstance = useRef(null); const linkButtonRef = useRef(null); const [dragDisabled, setDragDisabled] = useState(true); const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false); // Inicializace / update STLViewer useEffect(() => { if (!modelUrl || !modelRef.current) return; if (viewerInstance.current) { viewerInstance.current.destroy(); viewerInstance.current = null; } try { if (!modelUrl) return; // If we have a blob URL (common during upload) but no MIME type, // we cannot safely detect the loader. Better to wait for the upload to complete // and provide the full URL/MIME than to crash detection. const isBlob = modelUrl.startsWith('blob:'); const hasExtension = /\.(stl|obj|glb|gltf)$/i.test(modelUrl); if (isBlob && !mimeType) { // Waiting for upload completion... return; } // Additional safety: if not blob but no extension and no mime, also wait or fail gracefully if (!isBlob && !hasExtension && !mimeType) { return; } viewerInstance.current = new ModelViewer(modelRef.current, { modelUrl: modelUrl, cameraState: cameraState, zoom: zoom, lightIntensity: lightIntensity, mimeType: mimeType, // Only pass color if explicitly set, otherwise let ModelViewer apply default for STL/OBJ ...(color !== undefined && { color: color }), shinyIntensity: shinyIntensity, autoRotate: autoRotate, autoRotateSpeed: autoRotateSpeed, autoRotateX: autoRotateX, autoRotateY: autoRotateY, enableRotate: true, // Editor always controllable enableZoom: true, // Editor always controllable enablePan: true, // Editor always controllable widthValue: widthValue, widthUnit: widthUnit, heightValue: heightValue, heightUnit: heightUnit, pluginVersion: pluginVersion, showOrbitWidget: true, orbitWidgetOptions: { position: 'top-left' }, enableShadows: enableShadows }); viewerInstance.current.loadAsync().then(() => { // After model loads, sync color attribute with viewer's actual color // (which may be default color from settings for STL/OBJ) if (viewerInstance.current && !color) { const actualColor = viewerInstance.current.getColor(); if (actualColor) { setAttributes({ color: actualColor }); } } }); } catch (e) { console.error("Failed to initialize Press3D viewer:", e); } // uložit kameru a zoom po interakci if (viewerInstance.current) { viewerInstance.current.getControls().addEventListener('end', () => { if (!viewerInstance.current) return; setAttributes({ cameraState: viewerInstance.current.getCameraState(), zoom: viewerInstance.current.getZoom(), }); }); } return () => { viewerInstance.current?.destroy(); viewerInstance.current = null; }; }, [modelUrl]); // Nastavení dragování useEffect(() => { if (!modelRef.current) return; const blockEl = modelRef.current.closest('.block-editor-block-list__block'); if (!blockEl) return; if (dragDisabled) { blockEl.setAttribute('draggable', 'false'); } else { blockEl.setAttribute('draggable', 'true'); } }, [dragDisabled, modelUrl]); // Update šířky a výšky useEffect(() => { if (!viewerInstance.current) return; if (widthValue && widthUnit) { viewerInstance.current.setWidth(`${widthValue}${widthUnit}`); } if (heightValue && heightUnit) { viewerInstance.current.setHeight(`${heightValue}${heightUnit}`); } }, [widthValue, widthUnit, heightValue, heightUnit]); // Update barvy - only if color is explicitly set useEffect(() => { if (!viewerInstance.current) return; // Only update if color is explicitly set (not undefined) // This prevents overriding default color from settings if (color !== undefined) { viewerInstance.current.setColor(color); } }, [color]); // Update shiny intensity useEffect(() => { viewerInstance.current?.setShinyIntensity(shinyIntensity); }, [shinyIntensity]); // Reset model on color clear useEffect(() => { const handleColorClear = async (e: any) => { // Only respond if the event was triggered by this specific block if (e.detail?.clientId !== clientId) return; if (!viewerInstance.current) return; await viewerInstance.current.resetColor(); }; document.addEventListener('press3d:colorClear', handleColorClear); return () => document.removeEventListener('press3d:colorClear', handleColorClear); }, [clientId]); // Update intenzity svetla useEffect(() => { viewerInstance.current?.setLightIntensity(lightIntensity); }, [lightIntensity]); // Update autorotace useEffect(() => { viewerInstance.current?.setAutoRotate(autoRotate, autoRotateSpeed); }, [autoRotate, autoRotateSpeed]); // Update autorotate osy useEffect(() => { viewerInstance.current?.setAutoRotateAxes(autoRotateX, autoRotateY); }, [autoRotateX, autoRotateY]); // Update camera controls (stored for frontend, not applied to editor viewer) useEffect(() => { if (!viewerInstance.current) return; viewerInstance.current.setEnableZoom(enableZoom); viewerInstance.current.setEnableRotation(enableRotation); viewerInstance.current.setEnablePan(enablePan); }, [enableZoom, enableRotation, enablePan]); // Update shadows useEffect(() => { if (!viewerInstance.current) return; viewerInstance.current.setShadows(enableShadows); }, [enableShadows]); const blockProps = useBlockProps({ //className: `press3d-block1` }); return (
setDragDisabled((prev) => !prev)} /> setIsLinkPopoverOpen(!isLinkPopoverOpen)} isPressed={isLinkPopoverOpen || !!linkUrl} /> {isLinkPopoverOpen && ( setIsLinkPopoverOpen(false)} anchor={linkButtonRef.current} focusOnMount="firstElement" >
{ let newUrl = newValue?.url || ''; // Trim whitespace to prevent bypass with leading/trailing spaces newUrl = newUrl.trim(); // Block dangerous protocols (javascript:, data:, vbscript:, file:, etc.) // Allow only safe protocols: http, https, mailto, tel, and relative URLs const dangerousProtocols = /^(javascript|data|vbscript|file):/i; const safeProtocols = /^(https?:\/\/|mailto:|tel:|\/|#)/i; if (dangerousProtocols.test(newUrl) || (newUrl && !safeProtocols.test(newUrl))) { newUrl = ''; } setAttributes({ linkUrl: newUrl, linkOpenInNewTab: newValue?.opensInNewTab || false }); }} settings={[ { id: 'opensInNewTab', title: __('Open in new tab', 'press3d') } ]} /> {linkUrl && ( )}
)}
{/* Dimension */}
setAttributes({ widthValue: Number(val) })} />
setAttributes({ widthUnit: val })} />
setAttributes({ heightValue: Number(val) })} />
setAttributes({ heightUnit: val })} />
{/* Animation */} setAttributes({ autoRotate: value })} /> {autoRotate && (
setAttributes({ autoRotateX: value })} /> setAttributes({ autoRotateY: value })} />
{ const val = parseFloat(e.target.value); setAttributes({ autoRotateSpeed: isNaN(val) ? 2 : val }); }} style={{ width: '100%' }} />
-10 0 10
)}
{/* Appearance */} setAttributes({ lightIntensity: value })} min={0} max={10} step={0.1} />
setAttributes({ color: v === null ? undefined : v })} clientId={clientId} />
setAttributes({ shinyIntensity: value })} min={0} max={1} step={0.01} withInputField={false} trackColor="#c9c9c9" renderTooltipContent={(value) => `${Math.round((value ? value : 0) * 100)}%`} />
setAttributes({ enableShadows: value })} />
{/* Camera Controls */} setAttributes({ enableZoom: value })} help={__('Allow users to zoom in/out on the frontend.', 'press3d')} /> setAttributes({ enableRotation: value })} help={__('Allow users to rotate the model on the frontend.', 'press3d')} /> setAttributes({ enablePan: value })} help={__('Allow users to pan the camera on the frontend.', 'press3d')} /> {/* Accessibility */} setAttributes({ altText: value })} help={__('Describe the 3D model for screen readers.', 'press3d')} />
{modelUrl ? (
) : ( { let mime = media.mime_type || media.mime; // Collect all possible sources of the filename to find extension const candidates = [ media.filename, media.name, media.title, media.url ].filter(s => typeof s === 'string'); const hasExt = (ext: string) => candidates.some(c => c.toLowerCase().endsWith('.' + ext)); // Normalize MIME/type if browser/WP detection was generic if (hasExt('stl')) mime = 'model/stl'; else if (hasExt('obj')) mime = 'model/obj'; else if (hasExt('glb')) mime = 'model/glb'; else if (hasExt('gltf')) mime = 'model/gltf'; setAttributes({ modelUrl: media.url, mimeType: mime }); }} /> )}
); }