UNPKG

@atlaskit/editor-plugin-media-editing

Version:

MediaEditing plugin for @atlaskit/editor-core

582 lines (556 loc) 19.4 kB
/* Cropper.tsx generated by @compiled/babel-plugin v0.39.1 */ import { ax, ix } from "@compiled/react/runtime"; import React, { forwardRef, useImperativeHandle, useRef, useCallback, useEffect, useState } from 'react'; import { bind } from 'bind-event-listener'; const isSSRRender = () => typeof document === 'undefined' || process.env.REACT_SSR === 'true'; /** * Props for the Cropper component */ // Type-safe intrinsic element types for CropperJS web components (local to this file) // Helper to create web component elements with proper typing const CropperCanvas = 'cropper-canvas'; const CropperImage = 'cropper-image'; const CropperSelection = 'cropper-selection'; const CropperShade = 'cropper-shade'; const CropperGrid = 'cropper-grid'; const CropperCrosshair = 'cropper-crosshair'; const CropperHandle = 'cropper-handle'; /** * Options for getting the cropped canvas */ /** * Methods exposed via ref */ /** * Cropper component - A React wrapper for CropperJS 2.x web components * * @example * ```tsx * const cropperRef = useRef<CropperRef>(null); * * <Cropper * ref={cropperRef} * src="/image.jpg" * aspectRatio={16/9} * onReady={(canvas) => console.log('Ready!')} * onChange={(e) => console.log(e.detail.bounds)} * /> * ``` */ export const Cropper = /*#__PURE__*/forwardRef(({ src, alt = '', crossOrigin, aspectRatio, initialAspectRatio, initialCoverage = 1, background = true, rotatable = true, scalable = true, translatable = true, movable = true, resizable = true, zoomable = true, multiple = false, outlined = true, className, onImageReady, isCircle = false }, ref) => { const canvasRef = useRef(null); const selectionRef = useRef(null); const imageRef = useRef(null); const previousSelectionRef = useRef(null); const [isImageReady, setIsImageReady] = useState(false); const [isCropperLoaded, setIsCropperLoaded] = useState(false); const getCanvas = useCallback(() => canvasRef.current, []); const getImage = useCallback(() => imageRef.current, []); const getSelection = useCallback(() => selectionRef.current, []); const getCroppedCanvas = useCallback(options => { const selection = selectionRef.current; if (!selection) { return Promise.resolve(null); } // Check if the $toCanvas method exists (web component might not be fully initialized) if (typeof selection.$toCanvas !== 'function') { return Promise.resolve(null); } // If circular crop, we need to apply a circular mask if (isCircle) { // For circular crops, force square dimensions to maintain perfect circle const squareOptions = { ...options, height: options === null || options === void 0 ? void 0 : options.width // Make height equal to width for a perfect circle }; return selection.$toCanvas({ ...squareOptions, beforeDraw: (context, canvas) => { // For circles, ensure we have a square canvas const size = Math.min(canvas.width, canvas.height); const radius = size / 2; const centerX = size / 2; const centerY = size / 2; // Enable high-quality image rendering context.imageSmoothingEnabled = true; context.imageSmoothingQuality = 'high'; // Create a circular clipping path with anti-aliasing context.beginPath(); context.arc(centerX, centerY, radius, 0, Math.PI * 2); context.clip(); } }); } return selection.$toCanvas(options); }, [isCircle]); const fitStencilToImage = useCallback(() => { const canvas = canvasRef.current; const image = imageRef.current; const selection = selectionRef.current; if (canvas && image && selection) { // Get the real time positions const canvasRect = canvas.getBoundingClientRect(); const imageRect = image.getBoundingClientRect(); // Calculate coordinates relative to the canvas requestAnimationFrame(() => { const x = imageRect.left - canvasRect.left; const y = imageRect.top - canvasRect.top; const width = imageRect.width; const height = imageRect.height; // Apply these to the selection (the stencil) if (typeof selection.$change === 'function') { selection.$change(Math.floor(x), Math.floor(y), Math.floor(width), Math.floor(height)); } }); } }, []); // Clamp position to keep crop area within image boundaries const clampPosition = useCallback(current => { const canvas = canvasRef.current; const image = imageRef.current; if (!canvas || !image) { return null; } const canvasRect = canvas.getBoundingClientRect(); const imageRect = image.getBoundingClientRect(); const imageX = imageRect.left - canvasRect.left; const imageY = imageRect.top - canvasRect.top; const imageWidth = imageRect.width; const imageHeight = imageRect.height; let clampedX = current.x; let clampedY = current.y; // Clamp left/right if (clampedX < imageX) { clampedX = imageX; } else if (clampedX + current.width > imageX + imageWidth) { clampedX = imageX + imageWidth - current.width; } // Clamp top/bottom if (clampedY < imageY) { clampedY = imageY; } else if (clampedY + current.height > imageY + imageHeight) { clampedY = imageY + imageHeight - current.height; } return { x: clampedX, y: clampedY }; }, []); // Correct dimensions when resizing with aspect ratio constraints const correctResizeDimensions = useCallback((current, selection) => { const canvas = canvasRef.current; const image = imageRef.current; if (!canvas || !image) { return null; } const canvasRect = canvas.getBoundingClientRect(); const imageRect = image.getBoundingClientRect(); const imageX = imageRect.left - canvasRect.left; const imageY = imageRect.top - canvasRect.top; const imageWidth = imageRect.width; const imageHeight = imageRect.height; const maxWidthAvailable = imageX + imageWidth - current.x; const maxHeightAvailable = imageY + imageHeight - current.y; let correctedWidth = current.width; let correctedHeight = current.height; const hasAspectRatio = selection.aspectRatio && selection.aspectRatio > 0; if (hasAspectRatio) { const aspectRatio = selection.aspectRatio; const heightIfMaxWidth = maxWidthAvailable / aspectRatio; const widthIfMaxHeight = maxHeightAvailable * aspectRatio; // Determine which axis is the limiting factor if (heightIfMaxWidth <= maxHeightAvailable) { correctedWidth = maxWidthAvailable; correctedHeight = heightIfMaxWidth; } else { correctedHeight = maxHeightAvailable; correctedWidth = widthIfMaxHeight; } } else { correctedWidth = Math.min(correctedWidth, maxWidthAvailable); correctedHeight = Math.min(correctedHeight, maxHeightAvailable); } // Ensure positive dimensions correctedWidth = Math.max(1, correctedWidth); correctedHeight = Math.max(1, correctedHeight); return { width: correctedWidth, height: correctedHeight }; }, []); // Handle move operation (position changed but size stayed same) const handleMove = useCallback(() => { requestAnimationFrame(() => { const selection = selectionRef.current; if (!selection) { return; } const current = { x: selection.x || 0, y: selection.y || 0, width: selection.width || 0, height: selection.height || 0 }; const clamped = clampPosition(current); if (!clamped) { return; } if (typeof selection.$change === 'function') { selection.$change(Math.floor(clamped.x), Math.floor(clamped.y), Math.floor(current.width), Math.floor(current.height)); } previousSelectionRef.current = { width: current.width, height: current.height }; }); }, [clampPosition]); // Handle resize operation (size changed, may need aspect ratio correction) const handleResize = useCallback(() => { requestAnimationFrame(() => { const selection = selectionRef.current; if (!selection) { return; } const current = { x: selection.x || 0, y: selection.y || 0, width: selection.width || 0, height: selection.height || 0 }; const corrected = correctResizeDimensions(current, selection); if (!corrected) { return; } const canvas = canvasRef.current; const image = imageRef.current; if (!canvas || !image) { return; } const canvasRect = canvas.getBoundingClientRect(); const imageRect = image.getBoundingClientRect(); const imageX = imageRect.left - canvasRect.left; const imageY = imageRect.top - canvasRect.top; const correctedX = Math.max(imageX, current.x); const correctedY = Math.max(imageY, current.y); if (typeof selection.$change === 'function') { selection.$change(Math.floor(correctedX), Math.floor(correctedY), Math.floor(corrected.width), Math.floor(corrected.height)); } previousSelectionRef.current = { width: corrected.width, height: corrected.height }; }); }, [correctResizeDimensions]); // Check if selection is within image boundaries const isSelectionOutOfBounds = useCallback((imageX, imageY, imageWidth, imageHeight, selection) => { const tolerance = 1; return selection.x < imageX - tolerance || selection.y < imageY - tolerance || selection.x + selection.width > imageX + imageWidth + tolerance || selection.y + selection.height > imageY + imageHeight + tolerance; }, []); // Detect if operation is a move or resize based on dimension change const isMovingSelection = useCallback(() => { const prev = previousSelectionRef.current; const curr = selectionRef.current; if (!prev || !curr) { return false; } return prev.width === curr.width && prev.height === curr.height; }, []); const handleSelectionChange = useCallback(event => { const canvas = canvasRef.current; const image = imageRef.current; const customEvent = event; const selection = customEvent.detail; if (!canvas || !image || !selection) { return; } const canvasRect = canvas.getBoundingClientRect(); const imageRect = image.getBoundingClientRect(); const imageX = imageRect.left - canvasRect.left; const imageY = imageRect.top - canvasRect.top; const imageWidth = imageRect.width; const imageHeight = imageRect.height; if (!isSelectionOutOfBounds(imageX, imageY, imageWidth, imageHeight, selection)) { previousSelectionRef.current = { width: selection.width, height: selection.height }; return; } if (isMovingSelection()) { handleMove(); } else { handleResize(); } }, [isSelectionOutOfBounds, isMovingSelection, handleMove, handleResize]); // Lazy load cropperjs only on the client to avoid SSR side effects useEffect(() => { if (!isSSRRender()) { import('cropperjs').then(() => setIsCropperLoaded(true)).catch(() => { setIsCropperLoaded(false); }); } }, []); // Auto-fit stencil to image on mount and when src changes // Only run after cropperjs has loaded (client-side only, post-hydration) useEffect(() => { if (!isCropperLoaded) { return; } // eslint-disable-next-line @atlassian/perf-linting/no-chain-state-updates -- Ignored via go/ees017 (to be fixed) setIsImageReady(false); // Hide canvas while repositioning const image = imageRef.current; if (!image) { return; } // Wait for the image to be fully loaded if (typeof image.$ready === 'function') { image.$ready(() => { const timer = setTimeout(() => { fitStencilToImage(); setIsImageReady(true); onImageReady === null || onImageReady === void 0 ? void 0 : onImageReady(true); }, 500); // Adding a small timeout as there is a rendering issue with webkit return () => clearTimeout(timer); }); } else { // Fallback to timeout if $ready is not available const timer = setTimeout(() => { fitStencilToImage(); setIsImageReady(true); onImageReady === null || onImageReady === void 0 ? void 0 : onImageReady(true); }, 2000); return () => clearTimeout(timer); } }, [src, isCropperLoaded, onImageReady, fitStencilToImage]); // Attach selection change listener to enforce boundaries (only after image is ready) useEffect(() => { if (!isImageReady) { return; } const selection = selectionRef.current; if (!selection) { return; } return bind(selection, { type: 'change', listener: handleSelectionChange }); }, [isImageReady, handleSelectionChange]); useEffect(() => { if (!canvasRef.current || !imageRef.current) { return; } const observer = new ResizeObserver(() => { var _imageRef$current; // This forces the image to recalculate its "contain" logic // whenever the canvas wrapper changes size if (typeof ((_imageRef$current = imageRef.current) === null || _imageRef$current === void 0 ? void 0 : _imageRef$current.$center) === 'function') { var _imageRef$current2; (_imageRef$current2 = imageRef.current) === null || _imageRef$current2 === void 0 ? void 0 : _imageRef$current2.$center('contain'); } }); observer.observe(canvasRef.current); return () => observer.disconnect(); }, [canvasRef, imageRef]); useEffect(() => { if (!canvasRef.current) { return; } const observer = new ResizeObserver(() => { var _selectionRef$current; (_selectionRef$current = selectionRef.current) === null || _selectionRef$current === void 0 ? void 0 : _selectionRef$current.removeAttribute('aspect-ratio'); fitStencilToImage(); }); observer.observe(canvasRef.current); return () => observer.disconnect(); }, [fitStencilToImage]); useImperativeHandle(ref, () => ({ fitStencilToImage, getCanvas, getCroppedCanvas, getImage, isImageReady, getSelection }), [getCanvas, getCroppedCanvas, getImage, isImageReady, fitStencilToImage, getSelection]); // Inject global styles for cropper handles useEffect(() => { const style = document.createElement('style'); const circularStyle = isCircle ? ` cropper-selection { outline: none; border-top: 1px solid #0052CC; border-right: 1px solid #0052CC; border-bottom: 1px solid #0052CC; border-left: 1px solid #0052CC; box-sizing: border-box; border-radius: 50%; box-shadow: 0 0 0 9999px rgba(255, 255, 255, 0.6); } ` : ` cropper-selection { outline: none; border-top: 1px solid #0052CC; border-right: 1px solid #0052CC; border-bottom: 1px solid #0052CC; border-left: 1px solid #0052CC; box-sizing: border-box; } `; style.textContent = ` cropper-canvas { background-color: #F8F8F8; background-image: none; } cropper-shade { outline-color: rgba(255,255,255,0.5); } cropper-handle[action="move"] { opacity: 0; } cropper-handle[action="ne-resize"] { border-radius: 7px 4px 4px 4px; border-right: 7px solid rgba(255,255,255,0.6); border-top: 7px solid rgba(255,255,255,0.6); height: 15px; width: 15px; background-color: transparent; transform: translate(-4px, 4px); } cropper-handle[action="ne-resize"]::after { border-radius: 5px 4px 4px 4px; border-right: 5px solid #0052CC; border-top: 5px solid #0052CC; height: 15px; width: 15px; background-color: transparent; transform: translate(-7px, -14px); } cropper-handle[action="nw-resize"] { border-radius: 4px 7px 4px 4px; border-left: 7px solid rgba(255,255,255,0.6); border-top: 7px solid rgba(255,255,255,0.6); height: 15px; width: 15px; background-color: transparent; transform: translate(4px, 4px); } cropper-handle[action="nw-resize"]::after { border-radius: 4px 5px 4px 4px; border-left: 5px solid #0052CC; border-top: 5px solid #0052CC; height: 15px; width: 15px; background-color: transparent; transform: translate(-14px, -14px); } cropper-handle[action="se-resize"] { border-radius: 4px 4px 7px 4px; border-right: 7px solid rgba(255,255,255,0.6); border-bottom: 7px solid rgba(255,255,255,0.6); height: 15px; width: 15px; background-color: transparent; transform: translate(-4px, -4px); } cropper-handle[action="se-resize"]::after { border-radius: 4px 4px 5px 4px; border-right: 5px solid #0052CC; border-bottom: 5px solid #0052CC; height: 15px; width: 15px; background-color: transparent; transform: translate(-7px, -7px); } cropper-handle[action="sw-resize"] { border-radius: 4px 4px 4px 7px; border-left: 7px solid rgba(255,255,255,0.6); border-bottom: 7px solid rgba(255,255,255,0.6); height: 15px; width: 15px; background-color: transparent; transform: translate(4px, -4px); } cropper-handle[action="sw-resize"]::after { border-radius: 4px 4px 4px 5px; border-left: 5px solid #0052CC; border-bottom: 5px solid #0052CC; height: 15px; width: 15px; background-color: transparent; transform: translate(-14px, -7px); } ${circularStyle} `; document.head.appendChild(style); return () => style.remove(); }, [isCircle]); return /*#__PURE__*/React.createElement(CropperCanvas, { ref: canvasRef, class: className, background: background // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , style: { opacity: isImageReady ? 1 : 0 } }, /*#__PURE__*/React.createElement(CropperImage, { ref: imageRef, src: src, alt: alt, crossorigin: crossOrigin, rotatable: rotatable, scalable: scalable, translatable: translatable, "initial-center-size": "contain" }), /*#__PURE__*/React.createElement(CropperShade, { x: 240, y: 5, width: 160, height: 90 }), /*#__PURE__*/React.createElement(CropperSelection, { ref: selectionRef, "initial-coverage": initialCoverage, "aspect-ratio": aspectRatio, "initial-aspect-ratio": initialAspectRatio, movable: movable, resizable: resizable, zoomable: zoomable, multiple: multiple, outlined: outlined }, /*#__PURE__*/React.createElement(CropperGrid, { role: "grid", hidden: true }), /*#__PURE__*/React.createElement(CropperCrosshair, { hidden: true }), /*#__PURE__*/React.createElement(CropperHandle, { action: "move" }), /*#__PURE__*/React.createElement(CropperHandle, { action: "ne-resize" }), /*#__PURE__*/React.createElement(CropperHandle, { action: "nw-resize" }), /*#__PURE__*/React.createElement(CropperHandle, { action: "se-resize" }), /*#__PURE__*/React.createElement(CropperHandle, { action: "sw-resize" }))); }); Cropper.displayName = 'Cropper';