Flip images (#4113)

This PR adds the ability to flip images.

### Change type

- [x] `improvement`

### Test plan

1. Resize an image shape
2. Select an image shape and use the flip X / flip Y options in the
context menu.

- [x] Unit tests

### Release notes

- Adds the ability to flip images.
This commit is contained in:
Steve Ruiz 2024-07-09 12:01:03 +01:00 committed by GitHub
parent 7e561e54e5
commit 9a3afa2e2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 89 additions and 15 deletions

View file

@ -1926,10 +1926,10 @@ export function releasePointerCapture(element: Element, event: PointerEvent | Re
export type RequiredKeys<T, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
// @public (undocumented)
export function resizeBox(shape: TLBaseBoxShape, info: {
export function resizeBox<T extends TLBaseBoxShape>(shape: T, info: {
handle: TLResizeHandle;
initialBounds: Box;
initialShape: TLBaseBoxShape;
initialShape: T;
mode: TLResizeMode;
newPoint: VecModel;
scaleX: number;
@ -1939,14 +1939,7 @@ export function resizeBox(shape: TLBaseBoxShape, info: {
maxWidth: number;
minHeight: number;
minWidth: number;
}>): {
props: {
h: number;
w: number;
};
x: number;
y: number;
};
}>): T;
// @public (undocumented)
export type ResizeBoxOptions = Partial<{

View file

@ -608,6 +608,16 @@ input,
border-radius: var(--radius-1);
}
.tl-flip-x {
transform: scale(-1, 1);
}
.tl-flip-y {
transform: scale(1, -1);
}
.tl-flip-xy {
transform: scale(-1, -1);
}
/* --------------------- Nametag -------------------- */
.tl-collaborator-cursor {

View file

@ -14,8 +14,8 @@ export type ResizeBoxOptions = Partial<{
}>
/** @public */
export function resizeBox(
shape: TLBaseBoxShape,
export function resizeBox<T extends TLBaseBoxShape>(
shape: T,
info: {
newPoint: VecModel
handle: TLResizeHandle
@ -23,10 +23,10 @@ export function resizeBox(
scaleX: number
scaleY: number
initialBounds: Box
initialShape: TLBaseBoxShape
initialShape: T
},
opts = {} as ResizeBoxOptions
) {
): T {
const { newPoint, handle, scaleX, scaleY } = info
const { minWidth = 1, maxWidth = Infinity, minHeight = 1, maxHeight = Infinity } = opts
@ -118,6 +118,7 @@ export function resizeBox(
const { x, y } = offset.rot(shape.rotation).add(newPoint)
return {
...shape,
x,
y,
props: {

View file

@ -1022,12 +1022,16 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
// (undocumented)
onDoubleClickEdge: TLOnDoubleClickHandler<TLImageShape>;
// (undocumented)
onResize: TLOnResizeHandler<any>;
// (undocumented)
static props: {
assetId: Validator<TLAssetId | null>;
crop: Validator< {
bottomRight: VecModel;
topLeft: VecModel;
} | null>;
flipX: Validator<boolean>;
flipY: Validator<boolean>;
h: Validator<number>;
playing: Validator<boolean>;
url: Validator<string>;

View file

@ -7,14 +7,17 @@ import {
MediaHelpers,
TLImageShape,
TLOnDoubleClickHandler,
TLOnResizeHandler,
TLShapePartial,
Vec,
fetch,
imageShapeMigrations,
imageShapeProps,
resizeBox,
structuredClone,
toDomPrecision,
} from '@tldraw/editor'
import classNames from 'classnames'
import { useEffect, useState } from 'react'
import { BrokenAssetIcon } from '../shared/BrokenAssetIcon'
import { HyperlinkButton } from '../shared/HyperlinkButton'
@ -44,9 +47,26 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
playing: true,
url: '',
crop: null,
flipX: false,
flipY: false,
}
}
override onResize: TLOnResizeHandler<any> = (shape: TLImageShape, info) => {
let resized: TLImageShape = resizeBox(shape, info)
const { flipX, flipY } = info.initialShape.props
resized = {
...resized,
props: {
...resized.props,
flipX: info.scaleX < 0 !== flipX,
flipY: info.scaleY < 0 !== flipY,
},
}
return resized
}
isAnimated(shape: TLImageShape) {
const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : undefined
@ -177,7 +197,11 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
>
<div className="tl-image-container" style={containerStyle}>
<img
className="tl-image"
className={classNames('tl-image', {
'tl-flip-x': shape.props.flipX && !shape.props.flipY,
'tl-flip-y': shape.props.flipY && !shape.props.flipX,
'tl-flip-xy': shape.props.flipY && shape.props.flipX,
})}
// We don't set crossOrigin for non-animated images because
// for Cloudflare we don't currenly have that set up.
crossOrigin={this.isAnimated(shape) ? 'anonymous' : undefined}

View file

@ -3,6 +3,7 @@ import {
TLArrowShape,
TLDrawShape,
TLGroupShape,
TLImageShape,
TLLineShape,
TLTextShape,
useEditor,
@ -195,6 +196,7 @@ export function useOnlyFlippableShape() {
return (
shape &&
(editor.isShapeOfType<TLGroupShape>(shape, 'group') ||
editor.isShapeOfType<TLImageShape>(shape, 'image') ||
editor.isShapeOfType<TLArrowShape>(shape, 'arrow') ||
editor.isShapeOfType<TLLineShape>(shape, 'line') ||
editor.isShapeOfType<TLDrawShape>(shape, 'draw'))

View file

@ -4,6 +4,7 @@ import {
TLArrowShape,
TLArrowShapeProps,
TLBindingCreate,
TLImageShape,
TLShapeId,
TLShapePartial,
createBindingId,
@ -624,3 +625,14 @@ describe('When flipping shapes that include arrows', () => {
expect(editor.getSelectionRotatedPageBounds()).toCloselyMatchObject(boundsBefore)
})
})
it('Updates the image shape flip properties when flipped', () => {
editor.createShape({
type: 'image',
})
editor.select(editor.getLastCreatedShape())
editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal')
expect(editor.getLastCreatedShape<TLImageShape>().props.flipX).toBe(true)
editor.flipShapes(editor.getSelectedShapeIds(), 'vertical')
expect(editor.getLastCreatedShape<TLImageShape>().props.flipY).toBe(true)
})

View file

@ -600,6 +600,8 @@ export const imageShapeProps: {
bottomRight: VecModel;
topLeft: VecModel;
} | null>;
flipX: T.Validator<boolean>;
flipY: T.Validator<boolean>;
h: T.Validator<number>;
playing: T.Validator<boolean>;
url: T.Validator<string>;

View file

@ -1974,6 +1974,18 @@ describe('Add scale to line shape', () => {
})
})
describe('Add flipX, flipY to image shape', () => {
const { up, down } = getTestMigration(imageShapeVersions.AddFlipProps)
test('up works as expected', () => {
expect(up({ props: {} })).toEqual({ props: { flipX: false, flipY: false } })
})
test('down works as expected', () => {
expect(down({ props: { flipX: false, flipY: false } })).toEqual({ props: {} })
})
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
// check that all migrator fns were called at least once

View file

@ -21,6 +21,8 @@ export const imageShapeProps = {
url: T.linkUrl,
assetId: assetIdValidator.nullable(),
crop: ImageShapeCrop.nullable(),
flipX: T.boolean,
flipY: T.boolean,
}
/** @public */
@ -33,6 +35,7 @@ const Versions = createShapePropsMigrationIds('image', {
AddUrlProp: 1,
AddCropProp: 2,
MakeUrlsValid: 3,
AddFlipProps: 4,
})
export { Versions as imageShapeVersions }
@ -67,5 +70,16 @@ export const imageShapeMigrations = createShapePropsMigrationSequence({
// noop
},
},
{
id: Versions.AddFlipProps,
up: (props) => {
props.flipX = false
props.flipY = false
},
down: (props) => {
delete props.flipX
delete props.flipY
},
},
],
})