tldraw
Version:
A tiny little drawing editor.
710 lines (617 loc) • 20.2 kB
text/typescript
import {
Box,
ShapeWithCrop,
TLCropInfo,
TLImageShape,
TLShapeCrop,
TLShapeId,
Vec,
clamp,
isEqual,
} from '@tldraw/editor'
/** @internal */
export const MIN_CROP_SIZE = 8
/** @public */
export interface CropBoxOptions {
minWidth?: number
minHeight?: number
}
/** @public */
export function getDefaultCrop(): TLShapeCrop {
return {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 },
}
}
/** @public */
export type ASPECT_RATIO_OPTION =
| 'original'
| 'square'
| 'circle'
| 'landscape'
| 'portrait'
| 'wide'
/** @public */
export const ASPECT_RATIO_OPTIONS: ASPECT_RATIO_OPTION[] = [
'original',
'square',
'circle',
'landscape',
'portrait',
'wide',
]
/** @public */
export const ASPECT_RATIO_TO_VALUE: Record<ASPECT_RATIO_OPTION, number> = {
original: 0,
square: 1,
circle: 1,
landscape: 4 / 3,
portrait: 3 / 4,
wide: 16 / 9,
}
/**
* Original (uncropped) width and height of shape.
*
* @public
*/
export function getUncroppedSize(
shapeSize: { w: number; h: number },
crop: TLShapeCrop | null
): { w: number; h: number } {
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 }
}
// Utility function to get crop dimensions
function getCropDimensions(crop: TLShapeCrop) {
return {
width: crop.bottomRight.x - crop.topLeft.x,
height: crop.bottomRight.y - crop.topLeft.y,
}
}
// Utility function to get crop center
function getCropCenter(crop: TLShapeCrop) {
const { width, height } = getCropDimensions(crop)
return {
x: crop.topLeft.x + width / 2,
y: crop.topLeft.y + height / 2,
}
}
// Utility function to create crop with specified dimensions centered on given point
function createCropAroundCenter(
centerX: number,
centerY: number,
width: number,
height: number,
isCircle?: boolean
) {
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,
}
}
/** @public */
export function getCropBox<T extends ShapeWithCrop>(
shape: T,
info: TLCropInfo<T>,
opts = {} as CropBoxOptions
):
| {
id: TLShapeId
type: T['type']
x: number
y: number
props: ShapeWithCrop['props']
}
| undefined {
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
}
// Lets get a box here in pixel space. For simplicity, we'll do all the math in
// pixel space, then convert to normalized space at the end.
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()
// Basic resizing logic based on the handles
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
}
// Aspect ratio locked resizing logic
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': {
// preserve the bottom right corner
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': {
// preserve the bottom left corner
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': {
// preserve the top right corner
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': {
// preserve the top left corner
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': {
// preserve the bottom edge center
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': {
// preserve the left edge center
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': {
// preserve the top edge center
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': {
// preserve the right edge center
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
}
}
}
// Convert the box back to normalized space
const newCrop: TLShapeCrop = {
topLeft: { x: tempBox.x / w, y: tempBox.y / h },
bottomRight: { x: tempBox.maxX / w, y: tempBox.maxY / h },
isCircle: crop.isCircle,
}
// If the crop hasn't changed, we can return early
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
}
// Adjust the shape's position to keep the crop's absolute coordinates correct
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,
},
}
}
interface CropChange {
crop: {
topLeft: { x: number; y: number }
bottomRight: { x: number; y: number }
isCircle?: boolean
}
w: number
h: number
x: number
y: number
}
// Base function for calculating crop changes
function calculateCropChange(
imageShape: TLImageShape,
newCropWidth: number,
newCropHeight: number,
centerOnCurrentCrop: boolean = true,
isCircle: boolean = false
): CropChange {
const { w, h } = getUncroppedSize(imageShape.props, imageShape.props.crop ?? getDefaultCrop())
const currentCrop = imageShape.props.crop || getDefaultCrop()
// Calculate image and crop centers
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
}
// Create new crop
const newCrop = createCropAroundCenter(
cropCenterX,
cropCenterY,
newCropWidth,
newCropHeight,
isCircle
)
// Calculate new dimensions
const croppedW = newCropWidth * w
const croppedH = newCropHeight * h
return {
crop: newCrop,
w: croppedW,
h: croppedH,
x: imageCenterX - croppedW / 2,
y: imageCenterY - croppedH / 2,
}
}
/** @internal */
export const MAX_ZOOM = 3
/**
* Calculate new crop dimensions and position when zooming
*/
export function getCroppedImageDataWhenZooming(
zoom: number,
imageShape: TLImageShape,
maxZoom?: number
): CropChange {
const oldCrop = imageShape.props.crop || getDefaultCrop()
const { width: oldWidth, height: oldHeight } = getCropDimensions(oldCrop)
const aspectRatio = oldWidth / oldHeight
// Calculate new crop size with zoom scale
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
}
// Calculate result with base function
const result = calculateCropChange(imageShape, newWidth, newHeight, true, oldCrop.isCircle)
// Apply zoom factor to display dimensions
const scaleFactor = Math.min(MAX_ZOOM, oldWidth / newWidth)
result.w *= scaleFactor
result.h *= scaleFactor
// Recenter
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
}
/**
* Calculate new crop dimensions and position when replacing an image
*/
export function getCroppedImageDataForReplacedImage(
imageShape: TLImageShape,
newImageWidth: number,
newImageHeight: number
): CropChange {
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: number
let newRelativeHeight: number
const currentCropCenter = getCropCenter(currentCrop)
// Adjust the new crop dimensions to match the current crop zoom
newRelativeWidth = cropW
const ratioConversion = newImageAspectRatio / oldImageAspectRatio / targetRatio
newRelativeHeight = newRelativeWidth * ratioConversion
// Check that our new crop dimensions are within the MAX_ZOOM bounds
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
}
}
// Ensure dimensions are within [0, 1] bounds after adjustment
newRelativeWidth = Math.max(0, Math.min(1, newRelativeWidth))
newRelativeHeight = Math.max(0, Math.min(1, newRelativeHeight))
// Create the new crop object, centered around the CURRENT crop's center
crop = createCropAroundCenter(
currentCropCenter.x,
currentCropCenter.y,
newRelativeWidth,
newRelativeHeight,
currentCrop.isCircle
)
}
// Position so visual center stays put
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,
}
}
/**
* Calculate new crop dimensions and position when changing aspect ratio
*/
export function getCroppedImageDataForAspectRatio(
aspectRatioOption: ASPECT_RATIO_OPTION,
imageShape: TLImageShape
): CropChange {
// If original aspect ratio is requested, use default crop
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,
}
}
// Get target ratio and uncropped image properties
const targetRatio = ASPECT_RATIO_TO_VALUE[aspectRatioOption] // Assume valid option
const isCircle = aspectRatioOption === 'circle'
// Use default crop to get uncropped size relative to the *original* image bounds
const { w: uncroppedW, h: uncroppedH } = getUncroppedSize(
imageShape.props,
imageShape.props.crop || getDefaultCrop() // Use the ACTUAL current crop to correctly infer uncropped size
)
// Calculate the original image aspect ratio
const imageAspectRatio = uncroppedW / uncroppedH
// Get the current crop and its relative dimensions
const currentCrop = imageShape.props.crop || getDefaultCrop()
const { width: cropW, height: cropH } = getCropDimensions(currentCrop)
const currentCropCenter = getCropCenter(currentCrop)
// Calculate the current crop zoom level
const currentCropZoom = Math.min(1 / cropW, 1 / cropH)
// Calculate the relative width and height of the crop rectangle (0-1 scale)
// Try to preserve the longest dimension of the current crop when changing aspect ratios
let newRelativeWidth: number
let newRelativeHeight: number
if (imageAspectRatio === 0 || !Number.isFinite(imageAspectRatio) || targetRatio === 0) {
// Avoid division by zero or NaN issues if image dimensions are invalid or target ratio is 0
newRelativeWidth = 1
newRelativeHeight = 1
} else {
// Get current crop dimensions in absolute units
const currentAbsoluteWidth = cropW * uncroppedW
const currentAbsoluteHeight = cropH * uncroppedH
// Find the longest current dimension to preserve
const longestCurrentDimension = Math.max(currentAbsoluteWidth, currentAbsoluteHeight)
const isWidthLongest = currentAbsoluteWidth >= currentAbsoluteHeight
// Calculate new dimensions preserving the longest dimension
let newAbsoluteWidth: number
let newAbsoluteHeight: number
if (isWidthLongest) {
// Preserve width, calculate height based on target ratio
newAbsoluteWidth = longestCurrentDimension
newAbsoluteHeight = newAbsoluteWidth / targetRatio
} else {
// Preserve height, calculate width based on target ratio
newAbsoluteHeight = longestCurrentDimension
newAbsoluteWidth = newAbsoluteHeight * targetRatio
}
// Convert back to relative coordinates
newRelativeWidth = newAbsoluteWidth / uncroppedW
newRelativeHeight = newAbsoluteHeight / uncroppedH
// Clamp to image bounds and adjust if necessary
if (newRelativeWidth > 1) {
// Width exceeds bounds, clamp and recalculate height
newRelativeWidth = 1
newRelativeHeight = imageAspectRatio / targetRatio
}
if (newRelativeHeight > 1) {
// Height exceeds bounds, clamp and recalculate width
newRelativeHeight = 1
newRelativeWidth = targetRatio / imageAspectRatio
}
// Final clamp to ensure we stay within bounds
newRelativeWidth = Math.max(0, Math.min(1, newRelativeWidth))
newRelativeHeight = Math.max(0, Math.min(1, newRelativeHeight))
}
const newCropZoom = Math.min(1 / newRelativeWidth, 1 / newRelativeHeight)
// Adjust the new crop dimensions to match the current crop zoom
newRelativeWidth *= newCropZoom / currentCropZoom
newRelativeHeight *= newCropZoom / currentCropZoom
// Ensure dimensions are within [0, 1] bounds after adjustment
newRelativeWidth = Math.max(0, Math.min(1, newRelativeWidth))
newRelativeHeight = Math.max(0, Math.min(1, newRelativeHeight))
// Create the new crop object, centered around the CURRENT crop's center
const newCrop = createCropAroundCenter(
currentCropCenter.x,
currentCropCenter.y,
newRelativeWidth,
newRelativeHeight,
isCircle
)
// Get the actual relative dimensions from the new crop (after potential clamping)
const finalRelativeWidth = newCrop.bottomRight.x - newCrop.topLeft.x
const finalRelativeHeight = newCrop.bottomRight.y - newCrop.topLeft.y
// Calculate the base dimensions (as if applying the new crop to the uncropped image at scale 1)
const baseW = finalRelativeWidth * uncroppedW
const baseH = finalRelativeHeight * uncroppedH
// Determine the current effective scale of the shape
// This preserves the visual size when the crop changes
let currentScale = 1.0
if (cropW > 0) {
currentScale = imageShape.props.w / (cropW * uncroppedW)
} else if (cropH > 0) {
// Fallback to height if width relative dimension is zero
currentScale = imageShape.props.h / (cropH * uncroppedH)
}
// Apply the current scale to the base dimensions to get the final dimensions
const newW = baseW * currentScale
const newH = baseH * currentScale
// Calculate the new top-left position (x, y) for the shape
// to keep the visual center of the cropped area fixed on the page.
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,
}
}