tldraw
Version:
A tiny little drawing editor.
480 lines (479 loc) • 16.7 kB
JavaScript
import {
Box,
Vec,
clamp,
isEqual
} from "@tldraw/editor";
const MIN_CROP_SIZE = 8;
function getDefaultCrop() {
return {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 }
};
}
const ASPECT_RATIO_OPTIONS = [
"original",
"square",
"circle",
"landscape",
"portrait",
"wide"
];
const ASPECT_RATIO_TO_VALUE = {
original: 0,
square: 1,
circle: 1,
landscape: 4 / 3,
portrait: 3 / 4,
wide: 16 / 9
};
function getUncroppedSize(shapeSize, crop) {
if (!crop) return { w: shapeSize.w, h: shapeSize.h };
const w = shapeSize.w / (crop.bottomRight.x - crop.topLeft.x);
const h = shapeSize.h / (crop.bottomRight.y - crop.topLeft.y);
return { w, h };
}
function getCropDimensions(crop) {
return {
width: crop.bottomRight.x - crop.topLeft.x,
height: crop.bottomRight.y - crop.topLeft.y
};
}
function getCropCenter(crop) {
const { width, height } = getCropDimensions(crop);
return {
x: crop.topLeft.x + width / 2,
y: crop.topLeft.y + height / 2
};
}
function createCropAroundCenter(centerX, centerY, width, height, isCircle) {
const topLeftX = Math.max(0, Math.min(1 - width, centerX - width / 2));
const topLeftY = Math.max(0, Math.min(1 - height, centerY - height / 2));
return {
topLeft: { x: topLeftX, y: topLeftY },
bottomRight: { x: topLeftX + width, y: topLeftY + height },
isCircle
};
}
function getCropBox(shape, info, opts = {}) {
const { handle, change, crop, aspectRatioLocked } = info;
const { w, h } = info.uncroppedSize;
const { minWidth = MIN_CROP_SIZE, minHeight = MIN_CROP_SIZE } = opts;
if (w < minWidth || h < minHeight || change.x === 0 && change.y === 0) {
return;
}
const prevCropBox = new Box(
crop.topLeft.x * w,
crop.topLeft.y * h,
(crop.bottomRight.x - crop.topLeft.x) * w,
(crop.bottomRight.y - crop.topLeft.y) * h
);
const targetRatio = prevCropBox.aspectRatio;
const tempBox = prevCropBox.clone();
if (handle === "top_left" || handle === "bottom_left" || handle === "left") {
tempBox.x = clamp(tempBox.x + change.x, 0, prevCropBox.maxX - minWidth);
tempBox.w = prevCropBox.maxX - tempBox.x;
} else if (handle === "top_right" || handle === "bottom_right" || handle === "right") {
const tempRight = clamp(tempBox.maxX + change.x, prevCropBox.x + minWidth, w);
tempBox.w = tempRight - tempBox.x;
}
if (handle === "top_left" || handle === "top_right" || handle === "top") {
tempBox.y = clamp(tempBox.y + change.y, 0, prevCropBox.maxY - minHeight);
tempBox.h = prevCropBox.maxY - tempBox.y;
} else if (handle === "bottom_left" || handle === "bottom_right" || handle === "bottom") {
const tempBottom = clamp(tempBox.maxY + change.y, prevCropBox.y + minHeight, h);
tempBox.h = tempBottom - tempBox.y;
}
if (aspectRatioLocked) {
const isXLimiting = tempBox.aspectRatio > targetRatio;
if (isXLimiting) {
tempBox.h = tempBox.w / targetRatio;
} else {
tempBox.w = tempBox.h * targetRatio;
}
switch (handle) {
case "top_left": {
tempBox.x = prevCropBox.maxX - tempBox.w;
tempBox.y = prevCropBox.maxY - tempBox.h;
if (tempBox.x <= 0) {
tempBox.x = 0;
tempBox.w = prevCropBox.maxX - tempBox.x;
tempBox.h = tempBox.w / targetRatio;
tempBox.y = prevCropBox.maxY - tempBox.h;
}
if (tempBox.y <= 0) {
tempBox.y = 0;
tempBox.h = prevCropBox.maxY - tempBox.y;
tempBox.w = tempBox.h * targetRatio;
tempBox.x = prevCropBox.maxX - tempBox.w;
}
break;
}
case "top_right": {
tempBox.x = prevCropBox.x;
tempBox.y = prevCropBox.maxY - tempBox.h;
if (tempBox.maxX >= w) {
tempBox.w = w - prevCropBox.x;
tempBox.h = tempBox.w / targetRatio;
tempBox.y = prevCropBox.maxY - tempBox.h;
}
if (tempBox.y <= 0) {
tempBox.y = 0;
tempBox.h = prevCropBox.maxY - tempBox.y;
tempBox.w = tempBox.h * targetRatio;
}
break;
}
case "bottom_left": {
tempBox.x = prevCropBox.maxX - tempBox.w;
tempBox.y = prevCropBox.y;
if (tempBox.x <= 0) {
tempBox.x = 0;
tempBox.w = prevCropBox.maxX - tempBox.x;
tempBox.h = tempBox.w / targetRatio;
}
if (tempBox.maxY >= h) {
tempBox.h = h - prevCropBox.y;
tempBox.w = tempBox.h * targetRatio;
tempBox.x = prevCropBox.maxX - tempBox.w;
}
break;
}
case "bottom_right": {
tempBox.x = prevCropBox.x;
tempBox.y = prevCropBox.y;
if (tempBox.maxX >= w) {
tempBox.w = w - prevCropBox.x;
tempBox.h = tempBox.w / targetRatio;
}
if (tempBox.maxY >= h) {
tempBox.h = h - prevCropBox.y;
tempBox.w = tempBox.h * targetRatio;
}
break;
}
case "top": {
tempBox.h = prevCropBox.maxY - tempBox.y;
tempBox.w = tempBox.h * targetRatio;
tempBox.x -= (tempBox.w - prevCropBox.w) / 2;
if (tempBox.x <= 0) {
const leftSide = prevCropBox.midX;
tempBox.w = leftSide * 2;
tempBox.h = tempBox.w / targetRatio;
tempBox.x = 0;
}
if (tempBox.maxX >= w) {
const rightSide = w - prevCropBox.midX;
tempBox.w = rightSide * 2;
tempBox.h = tempBox.w / targetRatio;
tempBox.x = w - tempBox.w;
}
tempBox.y = prevCropBox.maxY - tempBox.h;
break;
}
case "right": {
tempBox.w = tempBox.maxX - prevCropBox.x;
tempBox.h = tempBox.w / targetRatio;
tempBox.y -= (tempBox.h - prevCropBox.h) / 2;
if (tempBox.y <= 0) {
const topSide = prevCropBox.midY;
tempBox.h = topSide * 2;
tempBox.w = tempBox.h * targetRatio;
tempBox.y = 0;
}
if (tempBox.maxY >= h) {
const bottomSide = h - prevCropBox.midY;
tempBox.h = bottomSide * 2;
tempBox.w = tempBox.h * targetRatio;
tempBox.y = h - tempBox.h;
}
break;
}
case "bottom": {
tempBox.h = tempBox.maxY - prevCropBox.y;
tempBox.w = tempBox.h * targetRatio;
tempBox.x -= (tempBox.w - prevCropBox.w) / 2;
if (tempBox.x <= 0) {
const leftSide = prevCropBox.midX;
tempBox.w = leftSide * 2;
tempBox.h = tempBox.w / targetRatio;
tempBox.x = 0;
}
if (tempBox.maxX >= w) {
const rightSide = w - prevCropBox.midX;
tempBox.w = rightSide * 2;
tempBox.h = tempBox.w / targetRatio;
tempBox.x = w - tempBox.w;
}
break;
}
case "left": {
tempBox.w = prevCropBox.maxX - tempBox.x;
tempBox.h = tempBox.w / targetRatio;
tempBox.y -= (tempBox.h - prevCropBox.h) / 2;
if (tempBox.y <= 0) {
const topSide = prevCropBox.midY;
tempBox.h = topSide * 2;
tempBox.w = tempBox.h * targetRatio;
tempBox.y = 0;
}
if (tempBox.maxY >= h) {
const bottomSide = h - prevCropBox.midY;
tempBox.h = bottomSide * 2;
tempBox.w = tempBox.h * targetRatio;
tempBox.y = h - tempBox.h;
}
tempBox.x = prevCropBox.maxX - tempBox.w;
break;
}
}
}
const newCrop = {
topLeft: { x: tempBox.x / w, y: tempBox.y / h },
bottomRight: { x: tempBox.maxX / w, y: tempBox.maxY / h }
};
if (crop.isCircle != null) {
newCrop.isCircle = crop.isCircle;
}
if (newCrop.topLeft.x === crop.topLeft.x && newCrop.topLeft.y === crop.topLeft.y && newCrop.bottomRight.x === crop.bottomRight.x && newCrop.bottomRight.y === crop.bottomRight.y) {
return;
}
const newPoint = new Vec(tempBox.x - crop.topLeft.x * w, tempBox.y - crop.topLeft.y * h).rot(shape.rotation).add(shape);
return {
id: shape.id,
type: shape.type,
x: newPoint.x,
y: newPoint.y,
props: {
...shape.props,
w: tempBox.w,
h: tempBox.h,
crop: newCrop
}
};
}
function calculateCropChange(imageShape, newCropWidth, newCropHeight, centerOnCurrentCrop = true, isCircle = false) {
const { w, h } = getUncroppedSize(imageShape.props, imageShape.props.crop ?? getDefaultCrop());
const currentCrop = imageShape.props.crop || getDefaultCrop();
const imageCenterX = imageShape.x + imageShape.props.w / 2;
const imageCenterY = imageShape.y + imageShape.props.h / 2;
let cropCenterX, cropCenterY;
if (centerOnCurrentCrop) {
const { x, y } = getCropCenter(currentCrop);
cropCenterX = x;
cropCenterY = y;
} else {
cropCenterX = 0.5;
cropCenterY = 0.5;
}
const newCrop = createCropAroundCenter(
cropCenterX,
cropCenterY,
newCropWidth,
newCropHeight,
isCircle
);
const croppedW = newCropWidth * w;
const croppedH = newCropHeight * h;
return {
crop: newCrop,
w: croppedW,
h: croppedH,
x: imageCenterX - croppedW / 2,
y: imageCenterY - croppedH / 2
};
}
const MAX_ZOOM = 3;
function getCroppedImageDataWhenZooming(zoom, imageShape, maxZoom) {
const oldCrop = imageShape.props.crop || getDefaultCrop();
const { width: oldWidth, height: oldHeight } = getCropDimensions(oldCrop);
const aspectRatio = oldWidth / oldHeight;
const derivedMaxZoom = maxZoom ? 1 / (1 - maxZoom) : MAX_ZOOM;
const zoomScale = 1 + zoom * (derivedMaxZoom - 1);
let newWidth, newHeight;
if (aspectRatio > 1) {
newWidth = Math.min(1, 1 / zoomScale);
newHeight = newWidth / aspectRatio;
} else {
newHeight = Math.min(1, 1 / zoomScale);
newWidth = newHeight * aspectRatio;
}
const result = calculateCropChange(imageShape, newWidth, newHeight, true, oldCrop.isCircle);
const scaleFactor = Math.min(MAX_ZOOM, oldWidth / newWidth);
result.w *= scaleFactor;
result.h *= scaleFactor;
const imageCenterX = imageShape.x + imageShape.props.w / 2;
const imageCenterY = imageShape.y + imageShape.props.h / 2;
result.x = imageCenterX - result.w / 2;
result.y = imageCenterY - result.h / 2;
return result;
}
function getCroppedImageDataForReplacedImage(imageShape, newImageWidth, newImageHeight) {
const defaultCrop = getDefaultCrop();
const currentCrop = imageShape.props.crop || defaultCrop;
const origDisplayW = imageShape.props.w;
const origDisplayH = imageShape.props.h;
const newImageAspectRatio = newImageWidth / newImageHeight;
let crop = defaultCrop;
let newDisplayW = origDisplayW;
let newDisplayH = origDisplayH;
const isOriginalCrop = isEqual(imageShape.props.crop, defaultCrop);
if (isOriginalCrop) {
newDisplayW = origDisplayW;
newDisplayH = origDisplayW * newImageHeight / newImageWidth;
} else {
const { w: uncroppedW, h: uncroppedH } = getUncroppedSize(
imageShape.props,
imageShape.props.crop || getDefaultCrop()
// Use the ACTUAL current crop to correctly infer uncropped size
);
const { width: cropW, height: cropH } = getCropDimensions(currentCrop);
const targetRatio = cropW / cropH;
const oldImageAspectRatio = uncroppedW / uncroppedH;
let newRelativeWidth;
let newRelativeHeight;
const currentCropCenter = getCropCenter(currentCrop);
newRelativeWidth = cropW;
const ratioConversion = newImageAspectRatio / oldImageAspectRatio / targetRatio;
newRelativeHeight = newRelativeWidth * ratioConversion;
const maxRatioConversion = MAX_ZOOM / (MAX_ZOOM - 1);
if (ratioConversion > maxRatioConversion) {
const minDimension = 1 / MAX_ZOOM;
if (1 / newRelativeHeight < 1 / newRelativeWidth) {
const scale = newRelativeHeight / minDimension;
newRelativeHeight = newRelativeHeight / scale;
newRelativeWidth = newRelativeWidth / scale;
} else {
const scale = newRelativeWidth / minDimension;
newRelativeWidth = newRelativeWidth / scale;
newRelativeHeight = newRelativeHeight / scale;
}
}
newRelativeWidth = Math.max(0, Math.min(1, newRelativeWidth));
newRelativeHeight = Math.max(0, Math.min(1, newRelativeHeight));
crop = createCropAroundCenter(
currentCropCenter.x,
currentCropCenter.y,
newRelativeWidth,
newRelativeHeight,
currentCrop.isCircle
);
}
const pageCenterX = imageShape.x + origDisplayW / 2;
const pageCenterY = imageShape.y + origDisplayH / 2;
const newX = pageCenterX - newDisplayW / 2;
const newY = pageCenterY - newDisplayH / 2;
return {
crop,
w: newDisplayW,
h: newDisplayH,
x: newX,
y: newY
};
}
function getCroppedImageDataForAspectRatio(aspectRatioOption, imageShape) {
if (aspectRatioOption === "original") {
const { w, h } = getUncroppedSize(imageShape.props, imageShape.props.crop ?? getDefaultCrop());
const imageCenterX = imageShape.x + imageShape.props.w / 2;
const imageCenterY = imageShape.y + imageShape.props.h / 2;
return {
crop: getDefaultCrop(),
w,
h,
x: imageCenterX - w / 2,
y: imageCenterY - h / 2
};
}
const targetRatio = ASPECT_RATIO_TO_VALUE[aspectRatioOption];
const isCircle = aspectRatioOption === "circle";
const { w: uncroppedW, h: uncroppedH } = getUncroppedSize(
imageShape.props,
imageShape.props.crop || getDefaultCrop()
// Use the ACTUAL current crop to correctly infer uncropped size
);
const imageAspectRatio = uncroppedW / uncroppedH;
const currentCrop = imageShape.props.crop || getDefaultCrop();
const { width: cropW, height: cropH } = getCropDimensions(currentCrop);
const currentCropCenter = getCropCenter(currentCrop);
const currentCropZoom = Math.min(1 / cropW, 1 / cropH);
let newRelativeWidth;
let newRelativeHeight;
if (imageAspectRatio === 0 || !Number.isFinite(imageAspectRatio) || targetRatio === 0) {
newRelativeWidth = 1;
newRelativeHeight = 1;
} else {
const currentAbsoluteWidth = cropW * uncroppedW;
const currentAbsoluteHeight = cropH * uncroppedH;
const longestCurrentDimension = Math.max(currentAbsoluteWidth, currentAbsoluteHeight);
const isWidthLongest = currentAbsoluteWidth >= currentAbsoluteHeight;
let newAbsoluteWidth;
let newAbsoluteHeight;
if (isWidthLongest) {
newAbsoluteWidth = longestCurrentDimension;
newAbsoluteHeight = newAbsoluteWidth / targetRatio;
} else {
newAbsoluteHeight = longestCurrentDimension;
newAbsoluteWidth = newAbsoluteHeight * targetRatio;
}
newRelativeWidth = newAbsoluteWidth / uncroppedW;
newRelativeHeight = newAbsoluteHeight / uncroppedH;
if (newRelativeWidth > 1) {
newRelativeWidth = 1;
newRelativeHeight = imageAspectRatio / targetRatio;
}
if (newRelativeHeight > 1) {
newRelativeHeight = 1;
newRelativeWidth = targetRatio / imageAspectRatio;
}
newRelativeWidth = Math.max(0, Math.min(1, newRelativeWidth));
newRelativeHeight = Math.max(0, Math.min(1, newRelativeHeight));
}
const newCropZoom = Math.min(1 / newRelativeWidth, 1 / newRelativeHeight);
newRelativeWidth *= newCropZoom / currentCropZoom;
newRelativeHeight *= newCropZoom / currentCropZoom;
newRelativeWidth = Math.max(0, Math.min(1, newRelativeWidth));
newRelativeHeight = Math.max(0, Math.min(1, newRelativeHeight));
const newCrop = createCropAroundCenter(
currentCropCenter.x,
currentCropCenter.y,
newRelativeWidth,
newRelativeHeight,
isCircle
);
const finalRelativeWidth = newCrop.bottomRight.x - newCrop.topLeft.x;
const finalRelativeHeight = newCrop.bottomRight.y - newCrop.topLeft.y;
const baseW = finalRelativeWidth * uncroppedW;
const baseH = finalRelativeHeight * uncroppedH;
let currentScale = 1;
if (cropW > 0) {
currentScale = imageShape.props.w / (cropW * uncroppedW);
} else if (cropH > 0) {
currentScale = imageShape.props.h / (cropH * uncroppedH);
}
const newW = baseW * currentScale;
const newH = baseH * currentScale;
const currentCenterXPage = imageShape.x + imageShape.props.w / 2;
const currentCenterYPage = imageShape.y + imageShape.props.h / 2;
const newX = currentCenterXPage - newW / 2;
const newY = currentCenterYPage - newH / 2;
return {
crop: newCrop,
w: newW,
h: newH,
x: newX,
y: newY
};
}
export {
ASPECT_RATIO_OPTIONS,
ASPECT_RATIO_TO_VALUE,
MAX_ZOOM,
MIN_CROP_SIZE,
getCropBox,
getCroppedImageDataForAspectRatio,
getCroppedImageDataForReplacedImage,
getCroppedImageDataWhenZooming,
getDefaultCrop,
getUncroppedSize
};
//# sourceMappingURL=crop.mjs.map