feat: image dimension lock logic update (#34)

This commit is contained in:
Ben Warzeski
2022-03-22 10:03:26 -04:00
committed by GitHub
parent 3b8a7780ac
commit 284601d6d2
5 changed files with 184 additions and 170 deletions

View File

@@ -14,7 +14,7 @@ import hooks from './hooks';
/**
* Wrapper for image dimension inputs and the lock checkbox.
* @param {obj} locked - locked dimension object
* @param {bool} isLocked - are dimensions locked
* @param {func} lock - lock dimensions
* @param {func} setHeight - updates dimensions based on new height
* @param {func} setWidth - updates dimensions based on new width
@@ -23,7 +23,7 @@ import hooks from './hooks';
* @param {obj} value - local dimension values { height, width }
*/
export const DimensionControls = ({
locked,
isLocked,
lock,
setHeight,
setWidth,
@@ -54,16 +54,15 @@ export const DimensionControls = ({
/>
<IconButton
className="d-inline-block"
alt={locked ? 'unlock dimensions' : 'lock dimensions'}
alt={isLocked ? 'unlock dimensions' : 'lock dimensions'}
iconAs={Icon}
src={locked ? Locked : Unlocked}
onClick={locked ? unlock : lock}
src={isLocked ? Locked : Unlocked}
onClick={isLocked ? unlock : lock}
/>
</div>
</Form.Group>
));
DimensionControls.defaultProps = {
locked: null,
value: {
height: 100,
width: 100,
@@ -76,10 +75,7 @@ DimensionControls.propTypes = ({
}),
setHeight: PropTypes.func.isRequired,
setWidth: PropTypes.func.isRequired,
locked: PropTypes.shape({
width: PropTypes.number,
height: PropTypes.number,
}),
isLocked: PropTypes.bool.isRequired,
lock: PropTypes.func.isRequired,
unlock: PropTypes.func.isRequired,
updateDimensions: PropTypes.func.isRequired,

View File

@@ -8,7 +8,9 @@ jest.mock('./hooks', () => ({
describe('DimensionControls', () => {
const props = {
lockDims: { width: 12, height: 15 },
locked: { 'props.locked': 'lockedValue' },
isLocked: true,
value: { width: 20, height: 40 },
};
beforeEach(() => {
@@ -27,5 +29,9 @@ describe('DimensionControls', () => {
expect(el).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(true);
});
test('unlocked dimensions', () => {
const el = shallow(<DimensionControls {...props} isLocked={false} />);
expect(el).toMatchSnapshot();
});
});
});

View File

@@ -48,3 +48,50 @@ exports[`DimensionControls render snapshot 1`] = `
</div>
</Form.Group>
`;
exports[`DimensionControls render unlocked dimensions 1`] = `
<Form.Group>
<Form.Label
as="h4"
>
Image Dimensions
</Form.Label>
<div
className="mt-4.5"
>
<Form.Control
className="dimension-input"
floatingLabel="Width"
min={1}
onBlur={[MockFunction props.updateDimensions]}
onChange={
Object {
"hooks.onInputChange": [MockFunction props.setWidth],
}
}
type="number"
value={20}
/>
<Form.Control
className="dimension-input"
floatingLabel="Height"
min={1}
onBlur={[MockFunction props.updateDimensions]}
onChange={
Object {
"hooks.onInputChange": [MockFunction props.setHeight],
}
}
type="number"
value={40}
/>
<IconButton
alt="lock dimensions"
className="d-inline-block"
iconAs="Icon"
onClick={[MockFunction props.lock]}
src={[MockFunction icons.Unlocked]}
/>
</div>
</Form.Group>
`;

View File

@@ -5,12 +5,13 @@ import * as module from './hooks';
// Simple wrappers for useState to allow easy mocking for tests.
export const state = {
dimensions: (val) => React.useState(val),
locked: (val) => React.useState(val),
local: (val) => React.useState(val),
lockInitialized: (val) => React.useState(val),
altText: (val) => React.useState(val),
dimensions: (val) => React.useState(val),
isDecorative: (val) => React.useState(val),
isLocked: (val) => React.useState(val),
local: (val) => React.useState(val),
lockDims: (val) => React.useState(val),
lockInitialized: (val) => React.useState(val),
};
export const dimKeys = StrictDict({
@@ -39,12 +40,15 @@ const checkEqual = (d1, d2) => (d1.height === d2.height && d1.width === d2.width
export const getValidDimensions = ({
dimensions,
local,
locked,
isLocked,
lockDims,
}) => {
if (!isLocked || checkEqual(local, dimensions)) {
return local;
}
const out = {};
let iter;
const { minInc } = locked;
const isMin = dimensions.height === minInc.height;
const isMin = dimensions.height === lockDims.height;
const keys = (local.height !== dimensions.height)
? { changed: dimKeys.height, other: dimKeys.width }
@@ -55,51 +59,16 @@ export const getValidDimensions = ({
// don't move down if already at minimum size
if (direction < 0 && isMin) { return dimensions; }
// find closest valid iteration of the changed field
iter = Math.max(Math.round(local[keys.changed] / minInc[keys.changed]), 1);
iter = Math.max(Math.round(local[keys.changed] / lockDims[keys.changed]), 1);
// if closest valid iteration is current iteration, move one iteration in the change direction
if (iter === (dimensions[keys.changed] / minInc[keys.changed])) { iter += direction; }
if (iter === (dimensions[keys.changed] / lockDims[keys.changed])) { iter += direction; }
out[keys.changed] = Math.round(iter * minInc[keys.changed]);
out[keys.other] = Math.round(out[keys.changed] * (locked[keys.other] / locked[keys.changed]));
out[keys.changed] = Math.round(iter * lockDims[keys.changed]);
out[keys.other] = Math.round(out[keys.changed] * (lockDims[keys.other] / lockDims[keys.changed]));
return out;
};
/**
* newDimensions({ dimensions, local, locked })
* Returns the local dimensions if unlocked or unchanged, and otherwise returns new valid
* dimensions.
* @param {obj} dimensions - current stored dimensions
* @param {obj} local - local (active) dimensions in the inputs
* @param {obj} locked - locked dimensions
* @return {obj} - output dimensions after attempted move ({ height, width })
*/
export const newDimensions = ({ dimensions, local, locked }) => (
(!locked || checkEqual(local, dimensions))
? local
: module.getValidDimensions({ dimensions, local, locked })
);
/**
* lockDimensions({ dimensions, lockInitialized, setLocked })
* Lock dimensions if lock initialized. Store minimum valid increment on lock so
* that we don't have re-compute.
* @param {obj} dimensions - current stored dimensions
* @param {bool} lockInitialized - has the lock state initialized?
* @param {func} setLocked - set lock state
*/
export const lockDimensions = ({ dimensions, lockInitialized, setLocked }) => {
if (!lockInitialized) { return; }
// find minimum viable increment
let gcd = findGcd(dimensions.width, dimensions.height);
if ([dimensions.width, dimensions.height].some(v => !Number.isInteger(v / gcd))) {
gcd = 1;
}
const minInc = { width: dimensions.width / gcd, height: dimensions.height / gcd, gcd };
setLocked({ ...dimensions, minInc });
};
/**
* dimensionLockHooks({ dimensions })
* Returns a set of hooks pertaining to the dimension locks.
@@ -107,22 +76,30 @@ export const lockDimensions = ({ dimensions, lockInitialized, setLocked }) => {
* @param {obj} dimensions - current stored dimensions
* @return {obj} - dimension lock hooks
* {func} initializeLock - enable the lock mechanism
* {obj} locked - current locked state
* {func} lock - lock the current dimensions
* {bool} isLocked - are dimensions locked?
* {obj} lockDims - image dimensions ({ height, width })
* {func} lock - lock the dimensions
* {func} unlock - unlock the dimensions
*/
export const dimensionLockHooks = ({ dimensions }) => {
const [locked, setLocked] = module.state.locked(null);
const [lockInitialized, setLockInitialized] = module.state.lockInitialized(null);
const lock = () => module.lockDimensions({ lockInitialized, dimensions, setLocked });
export const dimensionLockHooks = () => {
const [lockDims, setLockDims] = module.state.lockDims(null);
const [isLocked, setIsLocked] = module.state.isLocked(true);
React.useEffect(lock, [lockInitialized]);
const initializeLock = ({ width, height }) => {
// find minimum viable increment
let gcd = findGcd(width, height);
if ([width, height].some(v => !Number.isInteger(v / gcd))) {
gcd = 1;
}
setLockDims({ width: width / gcd, height: height / gcd });
};
return {
initializeLock: () => setLockInitialized(true),
locked,
lock,
unlock: () => setLocked(null),
initializeLock,
isLocked,
lock: () => setIsLocked(true),
lockDims,
unlock: () => setIsLocked(false),
};
};
@@ -146,31 +123,36 @@ export const dimensionLockHooks = ({ dimensions }) => {
export const dimensionHooks = () => {
const [dimensions, setDimensions] = module.state.dimensions(null);
const [local, setLocal] = module.state.local(null);
const setAll = (value) => {
setDimensions(value);
setLocal(value);
const setAll = ({ height, width }) => {
setDimensions({ height, width });
setLocal({ height, width });
};
const {
initializeLock,
isLocked,
lock,
locked,
lockDims,
unlock,
} = module.dimensionLockHooks({ dimensions });
return {
onImgLoad: (selection) => ({ target: img }) => {
setAll({
height: selection.height || img.naturalHeight,
width: selection.width || img.naturalWidth,
});
initializeLock();
const imageDims = { height: img.naturalHeight, width: img.naturalWidth };
setAll(selection.height ? selection : imageDims);
initializeLock(imageDims);
},
locked,
isLocked,
lock,
unlock,
value: local,
setHeight: (height) => setLocal({ ...local, height: parseInt(height, 10) }),
setWidth: (width) => setLocal({ ...local, width: parseInt(width, 10) }),
updateDimensions: () => setAll(module.newDimensions({ dimensions, local, locked })),
updateDimensions: () => setAll(module.getValidDimensions({
dimensions,
local,
isLocked,
lockDims,
})),
};
};

View File

@@ -1,3 +1,4 @@
import React from 'react';
import { StrictDict } from '../../../../utils';
import { MockUseState } from '../../../../../testUtils';
import * as hooks from './hooks';
@@ -5,6 +6,7 @@ import * as hooks from './hooks';
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
useState: (val) => ({ useState: val }),
}));
const simpleDims = { width: 3, height: 4 };
@@ -24,29 +26,69 @@ const hookKeys = StrictDict(Object.keys(hooks).reduce(
let hook;
const testVal = 'MY test VALUE';
describe('state values', () => {
const testStateMethod = (key) => {
expect(hooks.state[key](testVal)).toEqual(React.useState(testVal));
};
test('provides altText state value', () => testStateMethod(state.keys.altText));
test('provides dimensions state value', () => testStateMethod(state.keys.dimensions));
test('provides isDecorative state value', () => testStateMethod(state.keys.isDecorative));
test('provides isLocked state value', () => testStateMethod(state.keys.isLocked));
test('provides local state value', () => testStateMethod(state.keys.local));
test('provides lockDims state value', () => testStateMethod(state.keys.lockDims));
test('provides lockInitialized state value', () => testStateMethod(state.keys.lockInitialized));
});
describe('ImageSettingsModal hooks', () => {
describe('dimensions-related hooks', () => {
describe('getValidDimensions', () => {
it('returns local dimensions if not locked', () => {
expect(hooks.getValidDimensions({
dimensions: simpleDims,
local: reducedDims,
isLocked: false,
lockDims: simpleDims,
})).toEqual(reducedDims);
});
it('returns local dimensions if the same as stored', () => {
expect(hooks.getValidDimensions({
dimensions: simpleDims,
local: simpleDims,
isLocked: true,
lockDims: reducedDims,
})).toEqual(simpleDims);
});
describe('decreasing change when at minimum valid increment', () => {
it('returns current dimensions', () => {
const dimensions = { ...reducedDims };
const locked = { minInc: { ...dimensions, gcd } };
const lockDims = { ...dimensions };
let local = { ...dimensions, width: dimensions.width - 1 };
expect(
hooks.getValidDimensions({ dimensions, local, locked }),
hooks.getValidDimensions({
dimensions,
isLocked: true,
local,
lockDims,
}),
).toEqual(dimensions);
local = { ...dimensions, height: dimensions.height - 1 };
expect(
hooks.getValidDimensions({ dimensions, local, locked }),
hooks.getValidDimensions({
dimensions,
isLocked: true,
local,
lockDims,
}),
).toEqual(dimensions);
});
});
describe('valid change', () => {
it(
'returns the nearest valid pair of dimensions in the change direciton',
'returns the nearest valid pair of dimensions in the change direction',
() => {
const w = 7;
const h = 13;
const [w, h] = [7, 13];
const values = [
// bumps up if direction is up but nearest is current
[[w + 1, h], [w * 2, h * 2]],
@@ -66,75 +108,14 @@ describe('ImageSettingsModal hooks', () => {
expect(hooks.getValidDimensions({
dimensions,
local: { width: local[0], height: local[1] },
locked: { ...dimensions, minInc: { ...dimensions, gcd: 1 } },
lockDims: { ...dimensions },
isLocked: true,
})).toEqual({ width: expected[0], height: expected[1] });
});
},
);
});
});
describe('newDimensions', () => {
it('returns the local values if not locked, or if local is equal to dimensions', () => {
expect(hooks.newDimensions({
dimensions: { ...simpleDims },
local: { ...simpleDims },
locked: { ...simpleDims },
})).toEqual({ ...simpleDims });
expect(hooks.newDimensions({
dimensions: { ...simpleDims },
local: { ...reducedDims },
locked: null,
})).toEqual({ ...reducedDims });
});
it('returns getValidDimensions if locked and local has changed', () => {
const getValidDimensions = (args) => ({ getValidDimensions: args });
jest.spyOn(hooks, hookKeys.getValidDimensions).mockImplementationOnce(getValidDimensions);
const args = {
dimensions: { ...simpleDims },
local: { ...multiDims },
locked: { ...reducedDims },
};
expect(hooks.newDimensions(args)).toEqual(getValidDimensions(args));
});
});
describe('lockDimensions', () => {
it('does not call setLocked if lockInitialized is false', () => {
state.setState.locked = jest.fn();
hooks.lockDimensions({
dimensions: simpleDims,
setLocked: state.setState.locked,
lockInitialized: false,
});
expect(state.setState.locked).not.toHaveBeenCalled();
});
it(
'calls setLocked with the given dimensions and minInc, including gcd',
() => {
state.setState.locked = jest.fn();
hooks.lockDimensions({
dimensions: simpleDims,
setLocked: state.setState.locked,
lockInitialized: true,
});
expect(state.setState.locked).toHaveBeenCalledWith({
...simpleDims,
minInc: { gcd: 1, ...simpleDims },
});
state.setState.locked.mockClear();
hooks.lockDimensions({
dimensions: multiDims,
setLocked: state.setState.locked,
lockInitialized: true,
});
expect(hooks.findGcd(multiDims.width, multiDims.height)).toEqual(7);
expect(state.setState.locked).toHaveBeenCalledWith({
...multiDims,
minInc: { gcd, ...reducedDims },
});
},
);
});
describe('dimensionLockHooks', () => {
beforeEach(() => {
state.mock();
@@ -143,29 +124,27 @@ describe('ImageSettingsModal hooks', () => {
afterEach(() => {
state.restore();
});
test('locked is initially null', () => {
expect(hook.locked).toEqual(null);
test('lockDims defaults to null', () => {
expect(hook.lockDims).toEqual(null);
});
test('initializeLock calls setLockInitialized with true', () => {
hook.initializeLock();
expect(state.setState.lockInitialized).toHaveBeenCalledWith(true);
test('isLocked defaults to true', () => {
expect(hook.isLocked).toEqual(true);
});
test('lock calls lockDimensions with lockInitialized, dimensions, and setLocked', () => {
state.mockVal(state.keys.lockInitialized, true, state.setState.lockInitialized);
hook = hooks.dimensionLockHooks({ dimensions: simpleDims });
const lockDimensionsSpy = jest.spyOn(hooks, hookKeys.lockDimensions);
hook.lock();
expect(lockDimensionsSpy).toHaveBeenCalledWith({
dimensions: simpleDims,
setLocked: state.setState.locked,
lockInitialized: true,
describe('initializeLock', () => {
it('calls setLockDims with the passed dimensions divided by their gcd', () => {
hook.initializeLock(multiDims);
expect(state.setState.lockDims).toHaveBeenCalledWith(reducedDims);
});
});
test('lock sets isLocked to true', () => {
hook = hooks.dimensionLockHooks({ dimensions: simpleDims });
hook.lock();
expect(state.setState.isLocked).toHaveBeenCalledWith(true);
});
test('unlock sets locked to null', () => {
hook = hooks.dimensionLockHooks({ dimensions: simpleDims });
hook.unlock();
expect(state.setState.locked).toHaveBeenCalledWith(null);
expect(state.setState.isLocked).toHaveBeenCalledWith(false);
});
});
describe('dimensionHooks', () => {
@@ -230,17 +209,21 @@ describe('ImageSettingsModal hooks', () => {
});
describe('updateDimensions', () => {
it('sets local and stored dimensions to newDimensions output', () => {
const newDimensions = (args) => ({ newDimensions: args });
state.mockVal(state.keys.dimensions, simpleDims, state.setState.dimensions);
state.mockVal(state.keys.locked, reducedDims, state.setState.locked);
state.mockVal(state.keys.local, multiDims, state.setState.local);
jest.spyOn(hooks, hookKeys.newDimensions).mockImplementationOnce(newDimensions);
// store values we care about under height or width, and add junk data to be stripped out.
const testDims = (args) => ({ ...simpleDims, height: args });
const getValidDimensions = (args) => ({ ...testDims(args), junk: 'data' });
state.mockVal(state.keys.isLocked, true);
state.mockVal(state.keys.dimensions, simpleDims);
state.mockVal(state.keys.lockDims, reducedDims);
state.mockVal(state.keys.local, multiDims);
jest.spyOn(hooks, hookKeys.getValidDimensions).mockImplementationOnce(getValidDimensions);
hook = hooks.dimensionHooks();
hook.updateDimensions();
const expected = newDimensions({
const expected = testDims({
dimensions: simpleDims,
locked: reducedDims,
lockDims: reducedDims,
local: multiDims,
isLocked: true,
});
expect(state.setState.local).toHaveBeenCalledWith(expected);
expect(state.setState.dimensions).toHaveBeenCalledWith(expected);