diff --git a/__tests__/aspectConstraints.test.tsx b/__tests__/aspectConstraints.test.tsx new file mode 100644 index 00000000..63994bd4 --- /dev/null +++ b/__tests__/aspectConstraints.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import Resizable from '../lib/Resizable'; + +// Bounds can come from the available container space rather than the image ratio. +describe('locked aspect ratio with independent size constraints', () => { + test.each([ + ['width maximum', [100, 50], [420, 400], [300, 150], [420, 210]], + ['height maximum', [100, 50], [600, 210], [300, 150], [420, 210]], + ['height minimum', [100, 130], [900, 450], [-200, -100], [260, 130]], + ['width minimum', [260, 50], [900, 450], [-200, -100], [260, 130]], + ])('%s preserves the ratio through overshoot, return and the next drag', (_label, min, max, delta, bound) => { + const ref = React.createRef(); + let size = {width: 300, height: 150}; + const observe = (_event, data) => { size = data.size; }; + const element = () =>
; + const {rerender} = render(element()); + const node = document.createElement('span'); + const send = (name, x, y) => { + ref.current.resizeHandler(name, 'se')({} as React.SyntheticEvent, {node, deltaX: x, deltaY: y}); + rerender(element()); + expect(size.width).toBeCloseTo(2 * size.height); + expect(size.width).toBeGreaterThanOrEqual(min[0]); + expect(size.height).toBeGreaterThanOrEqual(min[1]); + expect(size.width).toBeLessThanOrEqual(max[0]); + expect(size.height).toBeLessThanOrEqual(max[1]); + }; + send('onResizeStart', 0, 0); + send('onResize', ...delta); + expect(size).toEqual({width: bound[0], height: bound[1]}); + send('onResize', ...delta); + send('onResize', 60 - 2 * delta[0], 30 - 2 * delta[1]); + expect(size).toEqual({width: 360, height: 180}); + send('onResizeStop', 0, 0); + send('onResizeStart', 0, 0); + send('onResize', 20, 10); + send('onResizeStop', 0, 0); + expect(size).toEqual({width: 380, height: 190}); + }); + + test('uses both bounds for a non-integer ratio', () => { + const ref = React.createRef(); + render(
); + expect(ref.current.runConstraints(600, 400)).toEqual([300, 200]); + ref.current.resetData(); + expect(ref.current.runConstraints(30, 20)).toEqual([135, 90]); + }); + + test('keeps size bounds authoritative if they cannot satisfy the ratio', () => { + const ref = React.createRef(); + render(
); + expect(ref.current.runConstraints(600, 300)).toEqual([300, 250]); + }); +}); diff --git a/lib/Resizable.tsx b/lib/Resizable.tsx index e3dee340..735f408f 100644 --- a/lib/Resizable.tsx +++ b/lib/Resizable.tsx @@ -35,13 +35,12 @@ export default class Resizable extends React.Component { // Clamp width and height within provided constraints runConstraints(width: number, height: number): [number, number] { const {minConstraints, maxConstraints, lockAspectRatio} = this.props; + const ratio = this.props.width / this.props.height; // short circuit if (!minConstraints && !maxConstraints && !lockAspectRatio) return [width, height]; // If constraining to min and max, we need to also fit width and height to aspect ratio. if (lockAspectRatio) { - const ratio = this.props.width / this.props.height; - // Project (width, height) onto the line w = ratio * h. // Distributes tracking error across both axes instead of forcing one to overshoot. // t = (w * ratio + h) / (ratio^2 + 1), new_w = t * ratio, new_h = t @@ -58,13 +57,23 @@ export default class Resizable extends React.Component { width += slackW; height += slackH; - if (minConstraints) { - width = Math.max(minConstraints[0], width); - height = Math.max(minConstraints[1], height); - } - if (maxConstraints) { - width = Math.min(maxConstraints[0], width); - height = Math.min(maxConstraints[1], height); + // Intersect both dimensions' bounds on the aspect-ratio line. Clamping + // each dimension independently would change the ratio at either bound. + const minWidth = minConstraints ? Math.max(minConstraints[0], minConstraints[1] * ratio) : -Infinity; + const maxWidth = maxConstraints ? Math.min(maxConstraints[0], maxConstraints[1] * ratio) : Infinity; + if (lockAspectRatio && ratio > 0 && Number.isFinite(ratio) && minWidth <= maxWidth) { + width = Math.max(minWidth, Math.min(maxWidth, width)); + height = width / ratio; + } else { + // Preserve size-constraint precedence when no size can satisfy the ratio. + if (minConstraints) { + width = Math.max(minConstraints[0], width); + height = Math.max(minConstraints[1], height); + } + if (maxConstraints) { + width = Math.min(maxConstraints[0], width); + height = Math.min(maxConstraints[1], height); + } } // If the width or height changed, we must have introduced some slack. Record it for the next iteration.