UNPKG

tldraw

Version:

A tiny little drawing editor.

480 lines (479 loc) • 16.7 kB
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