'use client';
import React from 'react';
import { observer } from 'mobx-react-lite';
import { autorun } from 'mobx';
import { Arc, Group, Image, Rect, Text, Transformer } from 'react-konva';
import Konva from 'konva';
import { Portal } from 'react-konva-utils';
import { incrementLoader, triggerLoadError } from '../utils/loader';
import * as svgUtils from '../utils/svg';
import { flags } from '../utils/flags';
import { applyFilter } from './apply-filters';
import { useFadeIn } from './use-fadein';
import { isTouchDevice } from '../utils/screen';
import { StoreType } from '../model/store';
import { VideoElementType } from '../model/video-model';
function createCanvas(): HTMLCanvasElement {
return document.createElement('canvas');
}
// SVG play icon
const playIcon = new window.Image();
playIcon.src = svgUtils.svgToURL(`
`);
// SVG pause icon
const pauseIcon = new window.Image();
pauseIcon.src = svgUtils.svgToURL(`
`);
function downscaleCanvas(
canvas: HTMLCanvasElement,
scale: number,
): HTMLCanvasElement {
const offscreen = createCanvas();
offscreen.width = canvas.width;
offscreen.height = canvas.height;
const targetW = Math.max(1, Math.floor(offscreen.width * scale));
const targetH = Math.max(1, Math.floor(offscreen.height * scale));
offscreen.getContext('2d')?.drawImage(canvas, 0, 0, offscreen.width, offscreen.height);
const imageData = offscreen.getContext('2d')!.getImageData(0, 0, offscreen.width, offscreen.height);
const srcData = new Int32Array(imageData.data.buffer);
const dst = new ImageData(targetW, targetH);
const dstData = new Int32Array(dst.data.buffer);
const srcW = offscreen.width;
const dstW = targetW;
const scaleX = targetW / offscreen.width;
const scaleY = targetH / offscreen.height;
const blockW = Math.round(1 / scaleX);
const blockH = Math.round(1 / scaleY);
const blockSize = blockW * blockH;
for (let y = 0; y < targetH; y++) {
for (let x = 0; x < dstW; x++) {
const srcX = Math.round(x / scaleX);
const srcY = Math.round(y / scaleY);
let r = 0, g = 0, b = 0, a = 0;
for (let by = 0; by < blockH; by++) {
for (let bx = 0; bx < blockW; bx++) {
const px = srcData[srcX + bx + (srcY + by) * srcW];
r += (px << 24) >>> 24;
g += (px << 16) >>> 24;
b += (px << 8) >>> 24;
a += px >>> 24;
}
}
r = Math.round(r / blockSize);
g = Math.round(g / blockSize);
b = Math.round(b / blockSize);
a = Math.round(a / blockSize);
dstData[x + y * dstW] = (a << 24) | (b << 16) | (g << 8) | r;
}
}
offscreen.width = targetW;
offscreen.height = targetH;
offscreen.getContext('2d')?.putImageData(dst, 0, 0);
return offscreen;
}
export const useCornerRadiusAndCrop = (
element: VideoElementType,
image: HTMLVideoElement | HTMLCanvasElement | undefined,
crop: { x: number; y: number; width: number; height: number },
pixelRatio: number,
cornerRadius = 0,
skipDownscale = false,
): readonly [HTMLCanvasElement | HTMLVideoElement | undefined, () => void] => {
const w = Math.floor(Math.max((element as any).width * pixelRatio, 1));
const h = Math.floor(Math.max((element as any).height * pixelRatio, 1));
const r = Math.min(cornerRadius * pixelRatio, w / 2, h / 2);
const scale = Math.max((element as any).width / crop.width, (element as any).height / crop.height) * pixelRatio;
const shouldDownscale =
(element as any).page._exportingOrRendering &&
flags.imageDownScalingEnabled &&
scale < 1 &&
!skipDownscale;
const isNoCrop =
crop.x === 0 && crop.y === 0 &&
crop.width === (image as any)?.width &&
crop.height === (image as any)?.height;
const offscreen = React.useMemo(() => {
if (image && (image as any).width && (image as any).height) {
return (isNoCrop && r === 0 && !shouldDownscale) ? undefined : createCanvas();
}
return undefined;
}, [image, r, shouldDownscale, isNoCrop]);
const draw = () => {
if (!offscreen || !image) return;
offscreen.width = w;
offscreen.height = h;
const ctx = offscreen.getContext('2d');
if (!ctx) return;
if (r) {
ctx.beginPath();
ctx.moveTo(r, 0);
ctx.lineTo(w - r, 0);
ctx.arc(w - r, r, r, (3 * Math.PI) / 2, 0, false);
ctx.lineTo(w, h - r);
ctx.arc(w - r, h - r, r, 0, Math.PI / 2, false);
ctx.lineTo(r, h);
ctx.arc(r, h - r, r, Math.PI / 2, Math.PI, false);
ctx.lineTo(0, r);
ctx.arc(r, r, r, Math.PI, (3 * Math.PI) / 2, false);
ctx.clip();
}
const src = shouldDownscale ? downscaleCanvas(image as any, scale) : image;
const srcCrop = shouldDownscale
? { x: Math.floor(crop.x * scale), y: Math.floor(crop.y * scale), width: Math.floor(crop.width * scale), height: Math.floor(crop.height * scale) }
: crop;
ctx.drawImage(src as any, srcCrop.x, srcCrop.y, srcCrop.width, srcCrop.height, 0, 0, w, h);
};
React.useLayoutEffect(() => {
draw();
}, [offscreen, (element as any).width, (element as any).height, crop.x, crop.y, crop.width, crop.height, cornerRadius, pixelRatio, skipDownscale, (element as any).page._exportingOrRendering, shouldDownscale]);
React.useEffect(() => () => {
if (offscreen && offscreen.nodeName === 'CANVAS') {
Konva.Util.releaseCanvas(offscreen as any);
}
}, [offscreen]);
return [offscreen || image, draw] as const;
};
// Loading spinner component
const VideoLoadingSpinner = observer(({ element }: { element: any }) => {
const r = Math.min(30, element.width / 4, element.height / 4);
const arcRef = React.useRef(null);
React.useEffect(() => {
const arc = arcRef.current;
if (!arc) return;
const anim = new Konva.Animation((frame) => {
arc.rotate(((frame?.timeDiff) || 0) / 2);
}, arc.getLayer());
anim.start();
return () => { anim.stop(); };
});
return React.createElement(
Group,
{ x: element.a.x, y: element.a.y, rotation: element.a.rotation, listening: false, opacity: element.a.opacity, hideInExport: !element.showInExport },
React.createElement(Rect, { width: element.width, height: element.height, fill: 'rgba(124, 173, 212, 0.8)' }),
React.createElement(Arc, {
ref: arcRef,
x: element.width / 2,
y: element.height / 2,
fill: 'white',
outerRadius: Math.abs(r),
innerRadius: Math.max(1, r - 5),
angle: 270,
}),
);
});
// Error state component
const VideoError = observer(({ element }: { element: any }) => {
const fontSize = Math.max(10, Math.min(30, element.width / 25));
return React.createElement(
Group,
{ x: element.a.x, y: element.a.y, rotation: element.a.rotation, listening: false, opacity: element.a.opacity, hideInExport: !element.showInExport },
React.createElement(Rect, { width: element.width, height: element.height, fill: 'rgba(223, 102, 102, 0.8)' }),
React.createElement(Text, {
text: 'Can not load the video...',
fontSize,
width: element.width,
height: element.height,
align: 'center',
fill: 'white',
verticalAlign: 'middle',
padding: 5,
}),
);
});
// Default video loader hook - can be replaced via setVideoLoaderHook
let useVideoLoaderHook = function (src: string, crossOrigin: string): [HTMLVideoElement | undefined, string] {
const statusRef = React.useRef('loading');
const videoRef = React.useRef(undefined);
const [, forceUpdate] = React.useState(0);
const srcRef = React.useRef(src);
const crossOriginRef = React.useRef(crossOrigin);
if (srcRef.current !== src || crossOriginRef.current !== crossOrigin) {
statusRef.current = 'loading';
videoRef.current = undefined;
srcRef.current = src;
crossOriginRef.current = crossOrigin;
}
React.useLayoutEffect(() => {
if (!src) return;
const video = document.createElement('video');
function onCanPlay() {
statusRef.current = 'loaded';
videoRef.current = video;
videoRef.current.currentTime = 0;
forceUpdate(Math.random());
video.removeEventListener('canplay', onCanPlay);
}
function onError(evt: any) {
const readyState = video.readyState === 4;
const buffered = video.buffered && video.buffered.length > 0 &&
(video.buffered.end(video.buffered.length - 1) / video.duration * 100) >= 100;
if (readyState && buffered) return;
const msg = evt.message || video.error?.message || 'Unknown error';
console.error(new Error('Video failed to load: ' + msg));
statusRef.current = 'failed';
videoRef.current = undefined;
forceUpdate(Math.random());
}
video.addEventListener('canplay', onCanPlay);
video.addEventListener('error', onError);
if (crossOrigin) video.crossOrigin = crossOrigin;
video.src = src;
video.load();
return () => {
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('error', onError);
};
}, [src, crossOrigin]);
return [videoRef.current, statusRef.current];
};
export const setVideoLoaderHook = (customVideoLoaderHook: any) => {
useVideoLoaderHook = customVideoLoaderHook;
};
function isVideoReady(video: HTMLVideoElement): boolean {
return video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
}
export const useLoader = (status: string, src: string, id: string): void => {
const releaseRef = React.useRef<(() => void) | undefined>();
const release = () => {
releaseRef.current?.();
releaseRef.current = undefined;
};
React.useEffect(() => release, []);
React.useLayoutEffect(() => {
const shortSrc = src.slice(0, 200);
const label = `video with id ${id} url: ${shortSrc}`;
if (status === 'loading' && !releaseRef.current) {
releaseRef.current = incrementLoader(label);
}
if (status !== 'loading') release();
if (status === 'failed') triggerLoadError(label);
}, [status]);
};
type Props = {
store: StoreType;
element: VideoElementType;
};
// Placeholder canvas for when video isn't loaded
const placeholderCanvas = createCanvas();
export const VideoElement = observer(({ element, store }: Props) => {
const [isTransforming, setIsTransforming] = React.useState(false);
const imageRef = React.useRef(null);
const cropImageRef = React.useRef(null);
const isSelected = (store as any).selectedShapes.indexOf(element) >= 0 && (element as any).selectable;
const [isPlaying, setIsPlaying] = React.useState(false);
const [videoEl, videoStatus] = useVideoLoaderHook((element as any).src, 'anonymous');
const [isHovering, setIsHovering] = React.useState(false);
const isActivePage = (store as any).activePage === (element as any).page;
useLoader(videoStatus, (element as any).src, (element as any).id);
if (videoEl) {
(videoEl as any).playsInline = true;
}
const mediaEl = videoEl || placeholderCanvas;
// Native video dimensions — read from videoWidth/videoHeight to avoid
// mutating the element during the render phase (causes React 18 concurrent
// mode warning when multiple VideoElements render in parallel).
const nativeVideoW = videoEl ? (videoEl.videoWidth || 0) : 0;
const nativeVideoH = videoEl ? (videoEl.videoHeight || 0) : 0;
// Set duration from video metadata
React.useEffect(() => {
if (!videoEl) return;
(store as any).history.ignore(() => {
(element as any).set({ duration: 1000 * videoEl.duration });
});
}, [videoEl]);
// Play/pause sync with store
React.useEffect(() => {
if (!videoEl) return;
const animatedIds = (store as any).animatedElementsIds;
const shouldAnimate = (!animatedIds.length || animatedIds.includes((element as any).id)) && (store as any).isPlaying;
const active = isActivePage && (shouldAnimate || isPlaying);
if (!active) {
videoEl.pause();
return;
}
videoEl.currentTime = (element as any).startTime * videoEl.duration;
videoEl.play();
const anim = new Konva.Animation(() => {
imageRef.current?.getLayer()?.batchDraw();
}, imageRef.current?.getLayer());
anim.start();
const onEnded = () => {
setIsPlaying(false);
videoEl.currentTime = (element as any).startTime * videoEl.duration;
};
const onTimeUpdate = () => {
if (videoEl.currentTime >= (element as any).endTime * videoEl.duration) {
videoEl.pause();
videoEl.currentTime = (element as any).startTime * videoEl.duration;
setIsPlaying(false);
}
};
videoEl.addEventListener('ended', onEnded);
videoEl.addEventListener('timeupdate', onTimeUpdate);
return () => {
videoEl.pause();
anim.stop();
videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
};
}, [videoEl, isPlaying, (store as any).isPlaying, isActivePage]);
// Volume sync
React.useEffect(() => {
if (!videoEl) return;
videoEl.volume = (element as any).volume;
}, [videoEl, (element as any).volume]);
// Timeline sync via autorun
const loaderRelease = React.useRef<(() => void) | undefined>();
React.useEffect(() => {
const dispose = autorun(() => {
if (!videoEl) return;
const animIds = (store as any).animatedElementsIds;
if (animIds.length && !animIds.includes((element as any).id)) return;
const startMs = (element as any).startTime * videoEl.duration * 1000;
const durationMs = videoEl.duration * ((element as any).endTime - (element as any).startTime) * 1000;
const currentMs = ((store as any).currentTime || (element as any).page.startTime) - (element as any).page.startTime;
const loopedMs = currentMs % durationMs + startMs;
if (Math.abs(1000 * videoEl.currentTime - loopedMs) > 500 || !(store as any).isPlaying) {
const targetTime = loopedMs / 1000;
if (targetTime !== videoEl.currentTime) videoEl.currentTime = targetTime;
if (!isVideoReady(videoEl) && !(store as any).isPlaying && !loaderRelease.current) {
loaderRelease.current = incrementLoader(`video ${(element as any).id}`);
}
}
imageRef.current?.getLayer()?.batchDraw();
});
return () => {
dispose();
loaderRelease.current?.();
loaderRelease.current = undefined;
};
}, [store, videoEl]);
// Seeking readiness
React.useEffect(() => {
if (!videoEl) return;
const check = () => {
if (!isVideoReady(videoEl) && !(element as any).page._exportingOrRendering) return;
if (!loaderRelease.current) return;
imageRef.current?.getLayer()?.batchDraw();
loaderRelease.current?.();
loaderRelease.current = undefined;
};
let interval: ReturnType | undefined;
if ((element as any).page._exportingOrRendering) {
interval = setInterval(check, 20);
}
videoEl.addEventListener('timeupdate', check);
videoEl.addEventListener('canplay', check);
return () => {
clearInterval(interval);
videoEl.removeEventListener('timeupdate', check);
videoEl.removeEventListener('canplay', check);
};
}, [(element as any).page._exportingOrRendering, videoEl, (element as any).id]);
let { cropX, cropY, cropWidth, cropHeight } = element as any;
if (videoStatus !== 'loaded') { cropX = cropY = 0; cropWidth = cropHeight = 1; }
const nativeW = nativeVideoW || (mediaEl as any).width || 1;
const nativeH = nativeVideoH || (mediaEl as any).height || 1;
const cropW = nativeW * cropWidth;
const cropH = nativeH * cropHeight;
const aspect = (element as any).width / (element as any).height;
const isSvg = (element as any).type === 'svg';
let drawW: number, drawH: number;
if (isSvg) {
drawW = cropW; drawH = cropH;
} else if (aspect >= cropW / cropH) {
drawW = cropW; drawH = cropW / aspect;
} else {
drawW = cropH * aspect; drawH = cropH;
}
const cropRect = { x: nativeW * cropX, y: nativeH * cropY, width: drawW, height: drawH };
const cornerRadius: number = (element as any).cornerRadius ?? 0;
const [croppedImage, redrawCrop] = useCornerRadiusAndCrop(
element,
mediaEl as any,
cropRect,
(store as any)._elementsPixelRatio,
cornerRadius,
true,
);
// Clip mask via clipSrc
const [finalImage, redrawClip] = (() => {
const clipSrc = (element as any).clipSrc || '';
const [clipSrcFixed, setClipSrcFixed] = React.useState(clipSrc);
React.useEffect(() => {
(async () => {
if (!clipSrc) { setClipSrcFixed(''); return; }
const str = await svgUtils.urlToString(clipSrc);
const fixed = svgUtils.fixSize(str);
const url = svgUtils.svgToURL(fixed);
setClipSrcFixed(url);
})();
}, [clipSrc]);
const [clipImg] = React.useState(undefined);
const clipCanvas = React.useMemo(() => {
if (croppedImage && clipImg) return createCanvas();
return undefined;
}, [croppedImage, clipImg]);
const doClipDraw = () => {
if (!clipImg || !croppedImage || !(croppedImage as any).width || !(croppedImage as any).height) return;
if (!clipImg.width || !clipImg.height) return;
if (!clipCanvas) return;
const ratio = Math.max((element as any).width / clipImg.width * (store as any)._elementsPixelRatio,
(element as any).height / clipImg.height * (store as any)._elementsPixelRatio);
const tmp = createCanvas();
tmp.width = Math.max(clipImg.width * ratio, 1);
tmp.height = Math.max(clipImg.height * ratio, 1);
tmp.getContext('2d')?.drawImage(clipImg, 0, 0, tmp.width, tmp.height);
clipCanvas.width = Math.max((croppedImage as any).width, 1);
clipCanvas.height = Math.max((croppedImage as any).height, 1);
const ctx = clipCanvas.getContext('2d');
if (!ctx) return;
ctx.save();
ctx.drawImage(tmp, 0, 0, (croppedImage as any).width, (croppedImage as any).height);
Konva.Util.releaseCanvas(tmp as any);
ctx.globalCompositeOperation = 'source-in';
ctx.drawImage(croppedImage as any, 0, 0, clipCanvas.width, clipCanvas.height);
ctx.restore();
};
React.useLayoutEffect(() => { doClipDraw(); }, [clipCanvas, croppedImage, clipImg, (element as any).width, (element as any).height, (store as any)._elementsPixelRatio, cropRect, cornerRadius]);
const result = (element as any).clipSrc && clipImg ? clipCanvas : croppedImage;
return [result, doClipDraw];
})();
function redrawAll() { redrawCrop(); redrawClip(); }
const scaleForCrop = Math.max((element as any).width / drawW, (element as any).height / drawH);
// Crop mode click outside handler
React.useEffect(() => {
if (!(element as any)._cropModeEnabled) return;
const stage = imageRef.current?.getStage();
function onStageClick(e: any) {
if ((element as any)._cropModeEnabled && e.target !== cropImageRef.current) {
(element as any).toggleCropMode(false);
}
}
function onBodyClick(e: any) {
if ((element as any)._cropModeEnabled && e.target.parentNode !== stage?.content) {
(element as any).toggleCropMode(false);
}
}
document.body.addEventListener('click', onBodyClick);
stage?.on('click', onStageClick);
stage?.on('tap', onStageClick);
return () => {
document.body.removeEventListener('click', onBodyClick);
document.body.removeEventListener('touchstart', onBodyClick);
stage?.off('click', onStageClick);
stage?.off('click', onStageClick);
};
}, [(element as any)._cropModeEnabled]);
// Apply filters
React.useLayoutEffect(() => {
if (!isTransforming && !(element as any)._cropModeEnabled) {
applyFilter(imageRef.current, element as any);
return autorun(() => { applyFilter(imageRef.current, element as any); }, { delay: 100 });
}
}, [mediaEl, isTransforming, cropWidth, cropHeight, (element as any)._cropModeEnabled]);
React.useLayoutEffect(() => {
if (isTransforming || (element as any)._cropModeEnabled) {
imageRef.current?.clearCache();
} else {
applyFilter(imageRef.current, element as any);
}
}, [isTransforming, (element as any).width, (element as any).height, (element as any)._cropModeEnabled]);
React.useEffect(() => {
applyFilter(imageRef.current, element as any);
}, [(element as any).shadowEnabled, (element as any).shadowBlur, (element as any).cornerRadius,
(element as any).shadowColor, (element as any).shadowOffsetX, (element as any).shadowOffsetY, (element as any).shadowOpacity]);
// Crop transformer refs
const cropRectRef = React.useRef(null);
const innerTransformerRef = React.useRef(null);
const outerTransformerRef = React.useRef(null);
React.useLayoutEffect(() => {
if ((element as any)._cropModeEnabled) {
innerTransformerRef.current?.nodes([cropRectRef.current]);
outerTransformerRef.current?.nodes([cropImageRef.current]);
}
}, [(element as any)._cropModeEnabled]);
const handleCropDragMove = (e: any) => {
e.evt?.touches?.length > 2 && e.target.stopDrag();
if (Math.round(e.target.x()) > 0) { e.target.x(0); e.target.scaleX(1); }
if (Math.round(e.target.y()) > 0) { e.target.y(0); e.target.scaleY(1); }
const tw = e.target.width() * e.target.scaleX();
const th = e.target.height() * e.target.scaleY();
const maxCropX = 1 - drawW / nativeW;
const maxCropY = 1 - drawH / nativeH;
const newCropX = Math.min(maxCropX, Math.max(0, Math.round(-e.target.x()) / tw));
const newCropY = Math.min(maxCropY, Math.max(0, Math.round(-e.target.y()) / th));
const newCropW = Math.min(1, drawW / tw);
const newCropH = Math.min(1, drawH / th);
e.target.setAttrs({
x: -newCropX * nativeW,
y: -newCropY * nativeH,
scaleX: 1, scaleY: 1,
});
(element as any).set({ cropX: newCropX, cropY: newCropY, cropWidth: newCropW, cropHeight: newCropH });
};
const onDblClick = () => {
if ((element as any).type !== 'svg' && (element as any).contentEditable) {
setTimeout(() => { (element as any).toggleCropMode(true); });
}
};
const isLoading = videoStatus === 'loading';
const isFailed = videoStatus === 'failed';
const isReady = !isLoading && !isFailed;
const prevOpacityRef = React.useRef({ cropX: 0, cropY: 0, cropWidth: 0, cropHeight: 0 });
const opacity = isReady ? (element as any).a.opacity : 0;
useFadeIn(imageRef, opacity);
const isListening = (element as any).selectable || (store as any).role === 'admin';
const isTouch = isTouchDevice();
return React.createElement(
React.Fragment,
null,
React.createElement(
Group,
{ onMouseEnter: () => setIsHovering(true), onMouseLeave: () => setIsHovering(false) },
isLoading && React.createElement(VideoLoadingSpinner, { element }),
isFailed && React.createElement(VideoError, { element }),
React.createElement(Image as any, {
ref: imageRef,
name: 'element',
id: (element as any).id,
image: finalImage,
x: (element as any).a.x,
y: (element as any).a.y,
width: (element as any).a.width || 1,
height: (element as any).a.height || 1,
rotation: (element as any).a.rotation,
opacity,
shadowEnabled: (element as any).shadowEnabled,
shadowBlur: (element as any).shadowBlur,
shadowOffsetX: (element as any).shadowOffsetX,
shadowOffsetY: (element as any).shadowOffsetY,
shadowColor: (element as any).shadowColor,
shadowOpacity: (element as any).shadowOpacity,
customCrop: cropRect,
listening: isListening,
draggable: isTouch ? (element as any).draggable && isSelected : (element as any).draggable,
preventDefault: !isTouch || isSelected,
hideInExport: !(element as any).showInExport,
onDragMove: (e: any) => { (element as any).set({ x: e.target.x(), y: e.target.y() }); },
onDragEnd: (e: any) => { (element as any).set({ x: e.target.x(), y: e.target.y() }); },
onDblClick,
onDblTap: onDblClick,
onTransformStart: () => {
setIsTransforming(true);
prevOpacityRef.current = {
cropX: (element as any).cropX, cropY: (element as any).cropY,
cropWidth: (element as any).cropWidth, cropHeight: (element as any).cropHeight,
};
},
onTransform: (e: any) => {
const node = e.currentTarget;
const scaleX = Math.abs(node.scaleX() - 1) < 1e-7 ? 1 : node.scaleX();
const scaleY = Math.abs(node.scaleY() - 1) < 1e-7 ? 1 : node.scaleY();
node.scaleX(1); node.scaleY(1);
const transformer = e.target.getStage()?.findOne('Transformer');
const maxCropX = 1 - drawW / nativeW;
const newCropX = Math.min(maxCropX, Math.max(0, (element as any).cropX));
const maxCropY = 1 - drawH / nativeH;
const newCropY = Math.min(maxCropY, Math.max(0, (element as any).cropY));
const anchor = transformer?.getActiveAnchor() || '';
const isCorner = !(anchor.includes('middle') || anchor.includes('center'));
const shrinkingH = !isCorner && scaleX < 1 && prevOpacityRef.current.cropHeight > drawH / nativeH;
let newCropW = isCorner ? (element as any).cropWidth : (element as any).cropWidth * scaleX;
if (shrinkingH) newCropW = (element as any).cropWidth;
const shrinkingW = !isCorner && scaleY < 1 && prevOpacityRef.current.cropWidth > drawW / nativeW;
let newCropH = isCorner ? (element as any).cropHeight : (element as any).cropHeight * scaleY;
if (shrinkingW) newCropH = (element as any).cropHeight;
if (isSvg) { newCropW = (element as any).cropWidth; newCropH = (element as any).cropHeight; }
(element as any).set({
cropX: newCropX, cropY: newCropY,
x: node.x(), y: node.y(),
width: node.width() * scaleX, height: node.height() * scaleY,
rotation: e.target.rotation(),
cropWidth: Math.min(newCropW, 1 - newCropX),
cropHeight: Math.min(newCropH, 1 - newCropY),
});
},
onTransformEnd: (e: any) => {
const node = e.currentTarget;
(element as any).set({
width: node.width(), height: node.height(),
x: node.x(), y: node.y(),
rotation: e.target.rotation(),
cropWidth: drawW / nativeW, cropHeight: drawH / nativeH,
});
setIsTransforming(false);
},
}),
React.createElement(Rect, {
x: (element as any).a.x, y: (element as any).a.y,
width: Math.max((element as any).a.width - (element as any).borderSize, 0),
height: Math.max((element as any).a.height - (element as any).borderSize, 0),
opacity,
offsetX: -(element as any).borderSize / 2,
offsetY: -(element as any).borderSize / 2,
stroke: (element as any).borderColor,
strokeWidth: (element as any).borderSize,
listening: false,
visible: !!(element as any).borderSize,
rotation: (element as any).rotation,
cornerRadius: Math.max(0, cornerRadius - (element as any).borderSize),
hideInExport: !(element as any).showInExport,
}),
!(store as any).isPlaying && React.createElement(Image as any, {
image: isPlaying ? (isHovering || isTouch ? pauseIcon : undefined) : playIcon,
x: (element as any).a.x,
y: (element as any).a.y,
offsetX: -(element as any).a.width / 2 + 15 / (store as any).scale,
offsetY: -(element as any).a.height / 2 + 15 / (store as any).scale,
rotation: (element as any).a.rotation,
width: 30 / (store as any).scale,
height: 30 / (store as any).scale,
hideInExport: true,
onClick: () => setIsPlaying(!isPlaying),
onTap: () => setIsPlaying(!isPlaying),
}),
(element as any)._cropModeEnabled && React.createElement(
Portal,
{ selector: '.page-abs-container', enabled: true },
React.createElement(Rect, {
x: -window.innerWidth / (store as any).scale,
y: -window.innerWidth / (store as any).scale,
width: window.innerWidth / (store as any).scale * 3,
height: window.innerWidth / (store as any).scale * 3,
fill: 'rgba(0,0,0,0.3)',
}),
React.createElement(Image as any, {
listening: false, image: finalImage,
x: (element as any).a.x, y: (element as any).a.y,
width: (element as any).a.width, height: (element as any).a.height,
rotation: (element as any).a.rotation,
shadowEnabled: (element as any).shadowEnabled,
shadowBlur: (element as any).shadowBlur,
}),
React.createElement(
Group,
{
x: (element as any).a.x, y: (element as any).a.y,
rotation: (element as any).a.rotation,
scaleX: scaleForCrop, scaleY: scaleForCrop,
},
React.createElement(Image as any, {
image: mediaEl,
ref: cropImageRef,
opacity: 0.4,
draggable: true,
x: -(element as any).cropX * nativeW,
y: -(element as any).cropY * nativeH,
onDragMove: handleCropDragMove,
onTransform: handleCropDragMove,
}),
React.createElement(Transformer, {
ref: outerTransformerRef,
anchorSize: 20,
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
boundBoxFunc: (old: any, nw: any) => (nw.width < 5 || nw.height < 5 ? old : nw),
rotateEnabled: false,
borderEnabled: false,
anchorCornerRadius: 10,
anchorStrokeWidth: 2,
borderStrokeWidth: 2,
}),
React.createElement(Rect, {
width: drawW, height: drawH,
ref: cropRectRef, listening: false,
onTransform: (e: any) => {
if (e.target.x() < -(element as any).cropX * nativeW - 1e-9) { e.target.x(-(element as any).cropX * nativeW); e.target.scaleX(1); }
if (e.target.y() < -(element as any).cropY * nativeH - 1e-9) { e.target.y(-(element as any).cropY * nativeH); e.target.scaleY(1); }
const rw = e.target.width() * e.target.scaleX();
const rh = e.target.height() * e.target.scaleY();
const newCropX = Math.min(1, Math.max(0, (element as any).cropX + e.target.x() / nativeW));
const newCropY = Math.min(1, Math.max(0, e.target.y() / nativeH + (element as any).cropY));
const newCropW = Math.min(1 - newCropX, rw / nativeW);
const newCropH = Math.min(1 - newCropY, rh / nativeH);
const absPos = e.target.getAbsolutePosition(e.target.getParent().getParent());
e.target.scale({ x: 1, y: 1 });
e.target.position({ x: 0, y: 0 });
(element as any).set({
x: absPos.x, y: absPos.y,
cropX: newCropX, cropY: newCropY,
cropWidth: newCropW, cropHeight: newCropH,
width: Math.min(rw * scaleForCrop, nativeW * (1 - newCropX) * scaleForCrop),
height: Math.min(rh * scaleForCrop, nativeH * (1 - newCropY) * scaleForCrop),
});
},
}),
React.createElement(Transformer, {
ref: innerTransformerRef,
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
boundBoxFunc: (old: any, nw: any) => (nw.width < 5 || nw.height < 5 ? old : nw),
keepRatio: false,
rotateEnabled: false,
anchorFill: 'rgb(240, 240, 240)',
anchorStrokeWidth: 2,
borderStrokeWidth: 2,
visible: (element as any).resizable,
}),
),
),
),
);
}) as ((props: Props) => React.JSX.Element) & { displayName: string };
VideoElement.displayName = 'VideoElement';