@atlaskit/editor-plugin-media-editing
Version:
MediaEditing plugin for @atlaskit/editor-core
582 lines (556 loc) • 19.4 kB
JavaScript
/* 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';