Cropping undo/redo UX (#3891)

This PR aims to improve the UX around undo/redo and cropping. Before the
PR if you do some cropping, then stop cropping, then hit `undo`, you
will end up back in the cropping state and it will undo each of your
resize/translate cropping operations individually. This is weird 🙅🏼 It
should just undo the whole sequence of changes that happened during
cropping.

To achieve that, this PR introduces a new history method called
`squashToMark`, which strips out all the marks between the current head
of the undo stack and the mark id you pass in.

This PR also makes the default history record mode of
`updateCurrentPageState` to `ignore` like it already was for
`updateInstanceState`. The fact that it was recording changes to the
`croppingShapeId` was the reason that hitting undo would put you back
into the cropping state.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
David Sheldrick 2024-06-11 07:13:03 +01:00 committed by GitHub
parent 23f8b3fd60
commit 25dcc29803
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 201 additions and 116 deletions

View file

@ -1535,7 +1535,9 @@ export class HistoryManager<R extends UnknownRecord> {
// (undocumented) // (undocumented)
onBatchComplete: () => void; onBatchComplete: () => void;
// (undocumented) // (undocumented)
redo: () => this | undefined; redo: () => this;
// (undocumented)
squashToMark: (id: string) => this;
// (undocumented) // (undocumented)
undo: () => this; undo: () => this;
} }

View file

@ -892,6 +892,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*/ */
undo(): this { undo(): this {
this._flushEventsForTick(0) this._flushEventsForTick(0)
this.complete()
this.history.undo() this.history.undo()
return this return this
} }
@ -917,6 +918,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*/ */
redo(): this { redo(): this {
this._flushEventsForTick(0) this._flushEventsForTick(0)
this.complete()
this.history.redo() this.history.redo()
return this return this
} }
@ -1440,12 +1442,18 @@ export class Editor extends EventEmitter<TLEventMap> {
partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>, partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>,
historyOptions?: TLHistoryBatchOptions historyOptions?: TLHistoryBatchOptions
) => { ) => {
this.batch(() => { this.batch(
this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({ () => {
...state, this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({
...partial, ...state,
})) ...partial,
}, historyOptions) }))
},
{
history: 'ignore',
...historyOptions,
}
)
} }
/** /**

View file

@ -430,4 +430,45 @@ describe('history options', () => {
manager.redo() manager.redo()
expect(getState()).toMatchObject({ a: 3, b: 2 }) expect(getState()).toMatchObject({ a: 3, b: 2 })
}) })
it('squashToMark works', () => {
const a = manager.mark()
setA(1)
const b = manager.mark()
setB(1)
setB(2)
setB(3)
manager.mark()
setA(2)
setB(4)
manager.mark()
setB(5)
setB(6)
expect(getState()).toMatchObject({ a: 2, b: 6 })
manager.squashToMark(b)
// does not affect state
expect(getState()).toMatchObject({ a: 2, b: 6 })
// but now undoing should take us back to a
manager.undo()
expect(getState()).toMatchObject({ a: 1, b: 0 })
// and redoing should take us back to the end
manager.redo()
expect(getState()).toMatchObject({ a: 2, b: 6 })
// and we can get back to the start with two undos
manager.undo().undo()
expect(getState()).toMatchObject({ a: 0, b: 0 })
manager.redo().redo()
manager.squashToMark(a)
expect(getState()).toMatchObject({ a: 2, b: 6 })
manager.undo()
expect(getState()).toMatchObject({ a: 0, b: 0 })
})
}) })

View file

@ -206,7 +206,7 @@ export class HistoryManager<R extends UnknownRecord> {
let { undos, redos } = this.stacks.get() let { undos, redos } = this.stacks.get()
if (redos.length === 0) { if (redos.length === 0) {
return return this
} }
// ignore any intermediate marks - this should take us to the first `diff` entry // ignore any intermediate marks - this should take us to the first `diff` entry
@ -252,6 +252,41 @@ export class HistoryManager<R extends UnknownRecord> {
return this return this
} }
squashToMark = (id: string) => {
// remove marks between head and the mark
let top = this.stacks.get().undos
const popped: Array<RecordsDiff<R>> = []
while (top.head && !(top.head.type === 'stop' && top.head.id === id)) {
if (top.head.type === 'diff') {
popped.push(top.head.diff)
}
top = top.tail
}
if (!top.head || top.head?.id !== id) {
console.error('Could not find mark to squash to: ', id)
return this
}
if (popped.length === 0) {
return this
}
const diff = createEmptyRecordsDiff<R>()
squashRecordDiffsMutable(diff, popped.reverse())
this.stacks.update(({ redos }) => ({
undos: top.push({
type: 'diff',
diff,
}),
redos,
}))
return this
}
mark = (id = uniqueId()) => { mark = (id = uniqueId()) => {
transact(() => { transact(() => {
this.flushPendingDiff() this.flushPendingDiff()

View file

@ -90,8 +90,8 @@ export const TldrawSelectionForeground = track(function TldrawSelectionForegroun
'select.pointing_shape', 'select.pointing_shape',
'select.crop.idle', 'select.crop.idle',
'select.crop.pointing_crop', 'select.crop.pointing_crop',
'select.pointing_resize_handle', 'select.crop.pointing_crop_handle',
'select.pointing_crop_handle' 'select.pointing_resize_handle'
)) || )) ||
(showSelectionBounds && (showSelectionBounds &&
editor.isIn('select.resizing') && editor.isIn('select.resizing') &&
@ -106,9 +106,9 @@ export const TldrawSelectionForeground = track(function TldrawSelectionForegroun
const showCropHandles = const showCropHandles =
editor.isInAny( editor.isInAny(
'select.pointing_crop_handle',
'select.crop.idle', 'select.crop.idle',
'select.crop.pointing_crop' 'select.crop.pointing_crop',
'select.crop.pointing_crop_handle'
) && ) &&
!isChangingStyle && !isChangingStyle &&
!isReadonlyMode !isReadonlyMode

View file

@ -4,11 +4,7 @@ export function registerDefaultSideEffects(editor: Editor) {
return [ return [
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => { editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) { if (prev.croppingShapeId !== next.croppingShapeId) {
const isInCroppingState = editor.isInAny( const isInCroppingState = editor.isIn('select.crop')
'select.crop',
'select.pointing_crop_handle',
'select.cropping'
)
if (!prev.croppingShapeId && next.croppingShapeId) { if (!prev.croppingShapeId && next.croppingShapeId) {
if (!isInCroppingState) { if (!isInCroppingState) {
editor.setCurrentTool('select.crop.idle') editor.setCurrentTool('select.crop.idle')

View file

@ -98,10 +98,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
throw Error("Bookmark assets can't be rendered as images") throw Error("Bookmark assets can't be rendered as images")
} }
const showCropPreview = const showCropPreview = isSelected && isCropping && this.editor.isIn('select.crop')
isSelected &&
isCropping &&
this.editor.isInAny('select.crop', 'select.cropping', 'select.pointing_crop_handle')
// We only want to reduce motion for mimeTypes that have motion // We only want to reduce motion for mimeTypes that have motion
const reduceMotion = const reduceMotion =

View file

@ -1,13 +1,13 @@
import { StateNode, TLStateNodeConstructor, react } from '@tldraw/editor' import { StateNode, TLStateNodeConstructor, react } from '@tldraw/editor'
import { Brushing } from './childStates/Brushing' import { Brushing } from './childStates/Brushing'
import { Crop } from './childStates/Crop/Crop' import { Crop } from './childStates/Crop/Crop'
import { Cropping } from './childStates/Cropping' import { Cropping } from './childStates/Crop/children/Cropping'
import { PointingCropHandle } from './childStates/Crop/children/PointingCropHandle'
import { DraggingHandle } from './childStates/DraggingHandle' import { DraggingHandle } from './childStates/DraggingHandle'
import { EditingShape } from './childStates/EditingShape' import { EditingShape } from './childStates/EditingShape'
import { Idle } from './childStates/Idle' import { Idle } from './childStates/Idle'
import { PointingArrowLabel } from './childStates/PointingArrowLabel' import { PointingArrowLabel } from './childStates/PointingArrowLabel'
import { PointingCanvas } from './childStates/PointingCanvas' import { PointingCanvas } from './childStates/PointingCanvas'
import { PointingCropHandle } from './childStates/PointingCropHandle'
import { PointingHandle } from './childStates/PointingHandle' import { PointingHandle } from './childStates/PointingHandle'
import { PointingResizeHandle } from './childStates/PointingResizeHandle' import { PointingResizeHandle } from './childStates/PointingResizeHandle'
import { PointingRotateHandle } from './childStates/PointingRotateHandle' import { PointingRotateHandle } from './childStates/PointingRotateHandle'

View file

@ -1,10 +1,35 @@
import { StateNode } from '@tldraw/editor' import { StateNode } from '@tldraw/editor'
import { Cropping } from './children/Cropping'
import { Idle } from './children/Idle' import { Idle } from './children/Idle'
import { PointingCrop } from './children/PointingCrop' import { PointingCrop } from './children/PointingCrop'
import { PointingCropHandle } from './children/PointingCropHandle'
import { TranslatingCrop } from './children/TranslatingCrop' import { TranslatingCrop } from './children/TranslatingCrop'
export class Crop extends StateNode { export class Crop extends StateNode {
static override id = 'crop' static override id = 'crop'
static override initial = 'idle' static override initial = 'idle'
static override children = () => [Idle, TranslatingCrop, PointingCrop] static override children = () => [
Idle,
TranslatingCrop,
PointingCrop,
PointingCropHandle,
Cropping,
]
markId = ''
override onEnter = () => {
this.didCancel = false
this.markId = this.editor.history.mark()
}
didCancel = false
override onExit = () => {
if (this.didCancel) {
this.editor.bailToMark(this.markId)
} else {
this.editor.history.squashToMark(this.markId)
}
}
override onCancel = () => {
this.didCancel = true
}
} }

View file

@ -11,9 +11,9 @@ import {
Vec, Vec,
structuredClone, structuredClone,
} from '@tldraw/editor' } from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers' import { kickoutOccludedShapes } from '../../../selectHelpers'
import { MIN_CROP_SIZE } from './Crop/crop-constants' import { CursorTypeMap } from '../../PointingResizeHandle'
import { CursorTypeMap } from './PointingResizeHandle' import { MIN_CROP_SIZE } from '../crop-constants'
type Snapshot = ReturnType<Cropping['createSnapshot']> type Snapshot = ReturnType<Cropping['createSnapshot']>
@ -207,7 +207,7 @@ export class Cropping extends StateNode {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else { } else {
this.editor.setCroppingShape(null) this.editor.setCroppingShape(null)
this.parent.transition('idle') this.editor.setCurrentTool('select.idle')
} }
} }
@ -217,7 +217,7 @@ export class Cropping extends StateNode {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else { } else {
this.editor.setCroppingShape(null) this.editor.setCroppingShape(null)
this.parent.transition('idle') this.editor.setCurrentTool('select.idle')
} }
} }

View file

@ -10,21 +10,13 @@ export class Idle extends StateNode {
const onlySelectedShape = this.editor.getOnlySelectedShape() const onlySelectedShape = this.editor.getOnlySelectedShape()
// well this fucking sucks. what the fuck.
// it's possible for a user to enter cropping, then undo
// (which clears the cropping id) but still remain in this state.
this.editor.on('tick', this.cleanupCroppingState)
if (onlySelectedShape) { if (onlySelectedShape) {
this.editor.mark('crop')
this.editor.setCroppingShape(onlySelectedShape.id) this.editor.setCroppingShape(onlySelectedShape.id)
} }
} }
override onExit: TLExitEventHandler = () => { override onExit: TLExitEventHandler = () => {
this.editor.setCursor({ type: 'default', rotation: 0 }) this.editor.setCursor({ type: 'default', rotation: 0 })
this.editor.off('tick', this.cleanupCroppingState)
} }
override onCancel: TLEventHandlers['onCancel'] = () => { override onCancel: TLEventHandlers['onCancel'] = () => {
@ -85,27 +77,21 @@ export class Idle extends StateNode {
case 'bottom_right_rotate': { case 'bottom_right_rotate': {
this.editor.setCurrentTool('select.pointing_rotate_handle', { this.editor.setCurrentTool('select.pointing_rotate_handle', {
...info, ...info,
onInteractionEnd: 'select.crop', onInteractionEnd: 'select.crop.idle',
}) })
break break
} }
case 'top': case 'top':
case 'right': case 'right':
case 'bottom': case 'bottom':
case 'left': { case 'left':
this.editor.setCurrentTool('select.pointing_crop_handle', {
...info,
onInteractionEnd: 'select.crop',
})
break
}
case 'top_left': case 'top_left':
case 'top_right': case 'top_right':
case 'bottom_left': case 'bottom_left':
case 'bottom_right': { case 'bottom_right': {
this.editor.setCurrentTool('select.pointing_crop_handle', { this.editor.setCurrentTool('select.crop.pointing_crop_handle', {
...info, ...info,
onInteractionEnd: 'select.crop', onInteractionEnd: 'select.crop.idle',
}) })
break break
} }
@ -165,12 +151,6 @@ export class Idle extends StateNode {
this.editor.setCurrentTool('select.idle', {}) this.editor.setCurrentTool('select.idle', {})
} }
private cleanupCroppingState = () => {
if (!this.editor.getCroppingShapeId()) {
this.editor.setCurrentTool('select.idle', {})
}
}
private nudgeCroppingImage(ephemeral = false) { private nudgeCroppingImage(ephemeral = false) {
const { const {
editor: { editor: {

View file

@ -1,5 +1,5 @@
import { StateNode, TLEventHandlers, TLPointerEventInfo } from '@tldraw/editor' import { StateNode, TLEventHandlers, TLPointerEventInfo } from '@tldraw/editor'
import { CursorTypeMap } from './PointingResizeHandle' import { CursorTypeMap } from '../../PointingResizeHandle'
type TLPointingCropHandleInfo = TLPointerEventInfo & { type TLPointingCropHandleInfo = TLPointerEventInfo & {
target: 'selection' target: 'selection'
@ -51,7 +51,7 @@ export class PointingCropHandle extends StateNode {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else { } else {
this.editor.setCroppingShape(null) this.editor.setCroppingShape(null)
this.parent.transition('idle') this.editor.setCurrentTool('select.idle')
} }
} }
@ -72,7 +72,7 @@ export class PointingCropHandle extends StateNode {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else { } else {
this.editor.setCroppingShape(null) this.editor.setCroppingShape(null)
this.parent.transition('idle') this.editor.setCurrentTool('select.idle')
} }
} }
} }

View file

@ -137,20 +137,13 @@ export class Idle extends StateNode {
case 'top': case 'top':
case 'right': case 'right':
case 'bottom': case 'bottom':
case 'left': { case 'left':
if (shouldEnterCropMode) {
this.parent.transition('pointing_crop_handle', info)
} else {
this.parent.transition('pointing_resize_handle', info)
}
break
}
case 'top_left': case 'top_left':
case 'top_right': case 'top_right':
case 'bottom_left': case 'bottom_left':
case 'bottom_right': { case 'bottom_right': {
if (shouldEnterCropMode) { if (shouldEnterCropMode) {
this.parent.transition('pointing_crop_handle', info) this.parent.transition('crop.pointing_crop_handle', info)
} else { } else {
this.parent.transition('pointing_resize_handle', info) this.parent.transition('pointing_resize_handle', info)
} }

View file

@ -33,11 +33,7 @@ beforeEach(() => {
// this side effect is normally added via a hook // this side effect is normally added via a hook
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => { editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) { if (prev.croppingShapeId !== next.croppingShapeId) {
const isInCroppingState = editor.isInAny( const isInCroppingState = editor.isIn('select.crop')
'select.crop',
'select.pointing_crop_handle',
'select.cropping'
)
if (!prev.croppingShapeId && next.croppingShapeId) { if (!prev.croppingShapeId && next.croppingShapeId) {
if (!isInCroppingState) { if (!isInCroppingState) {
editor.setCurrentTool('select.crop.idle') editor.setCurrentTool('select.crop.idle')
@ -100,12 +96,20 @@ describe('When in the select.idle state', () => {
expect(editor.getSelectedShapeIds()).toMatchObject([ids.imageB]) expect(editor.getSelectedShapeIds()).toMatchObject([ids.imageB])
expect(editor.getCroppingShapeId()).toBe(ids.imageB) expect(editor.getCroppingShapeId()).toBe(ids.imageB)
editor.updateShape({
id: ids.imageB,
type: 'image',
props: {
crop: { topLeft: { x: 0.1, y: 0.1 }, bottomRight: { x: 0.9, y: 0.9 } },
},
})
editor.undo() editor.undo()
// first selection should have been a mark // back to the start of the crop (undo the crop)
editor.expectToBeIn('select.idle') editor.expectToBeIn('select.crop.idle')
expect(editor.getSelectedShapeIds()).toMatchObject([ids.imageB]) expect(editor.getSelectedShapeIds()).toMatchObject([ids.imageB])
expect(editor.getCroppingShapeId()).toBe(null) expect(editor.getCroppingShapeId()).toBe(ids.imageB)
editor.undo() editor.undo()
@ -118,9 +122,13 @@ describe('When in the select.idle state', () => {
.redo() // select again .redo() // select again
.redo() // crop again .redo() // crop again
editor.expectToBeIn('select.crop.idle') // does not start copping again, but will redo the crop operation
editor.expectToBeIn('select.idle')
expect(editor.getSelectedShapeIds()).toMatchObject([ids.imageB]) expect(editor.getSelectedShapeIds()).toMatchObject([ids.imageB])
expect(editor.getCroppingShapeId()).toBe(ids.imageB) expect(editor.getCroppingShapeId()).toBe(null)
expect(editor.getOnlySelectedShape()!.props).toMatchObject({
crop: { topLeft: { x: 0.1, y: 0.1 }, bottomRight: { x: 0.9, y: 0.9 } },
})
}) })
it('when ONLY ONE image is selected double clicking a selection handle should transition to select.crop', () => { it('when ONLY ONE image is selected double clicking a selection handle should transition to select.crop', () => {
@ -195,7 +203,7 @@ describe('When in the select.idle state', () => {
expect(editor.getCroppingShapeId()).toBe(ids.imageB) expect(editor.getCroppingShapeId()).toBe(ids.imageB)
}) })
it('when only an image is selected control-pointing a selection handle should transition to select.pointing_crop_handle', () => { it('when only an image is selected control-pointing a selection handle should transition to select.crop.pointing_crop_handle', () => {
// two shapes / edge // two shapes / edge
editor editor
.cancel() .cancel()
@ -218,7 +226,7 @@ describe('When in the select.idle state', () => {
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
.select(ids.imageB) .select(ids.imageB)
.pointerDown(500, 550, { target: 'selection', handle: 'bottom', ctrlKey: true }) .pointerDown(500, 550, { target: 'selection', handle: 'bottom', ctrlKey: true })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
// one shape / corner // one shape / corner
editor editor
@ -226,7 +234,7 @@ describe('When in the select.idle state', () => {
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
.select(ids.imageB) .select(ids.imageB)
.pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
}) })
}) })
@ -266,14 +274,14 @@ describe('When in the crop.idle state', () => {
.expectToBeIn('select.pointing_shape') .expectToBeIn('select.pointing_shape')
}) })
it('pointing a selection handle should enter the select.pointing_crop_handle state', () => { it('pointing a selection handle should enter the select.crop.pointing_crop_handle state', () => {
// corner // corner
editor editor
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
.doubleClick(550, 550, ids.imageB) .doubleClick(550, 550, ids.imageB)
.expectToBeIn('select.crop.idle') .expectToBeIn('select.crop.idle')
.pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: false }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: false })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
//reset //reset
editor.cancel().cancel() editor.cancel().cancel()
@ -284,7 +292,7 @@ describe('When in the crop.idle state', () => {
.doubleClick(550, 550, ids.imageB) .doubleClick(550, 550, ids.imageB)
.expectToBeIn('select.crop.idle') .expectToBeIn('select.crop.idle')
.pointerDown(500, 600, { target: 'selection', handle: 'bottom', ctrlKey: false }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom', ctrlKey: false })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
}) })
it('pointing the cropping image should enter the select.crop.translating_crop state', () => { it('pointing the cropping image should enter the select.crop.translating_crop state', () => {
@ -522,16 +530,16 @@ describe('When in the select.crop.translating_crop state', () => {
}) })
}) })
describe('When in the select.pointing_crop_handle state', () => { describe('When in the select.crop.pointing_crop_handle state', () => {
it('moving the pointer should transition to select.cropping', () => { it('moving the pointer should transition to select.crop.cropping', () => {
editor editor
.cancel() .cancel()
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
.select(ids.imageB) .select(ids.imageB)
.pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.pointerMove(510, 590) .pointerMove(510, 590)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
}) })
it('when entered from select.idle, pressing escape / cancel should return to idle and clear cropping idle', () => { it('when entered from select.idle, pressing escape / cancel should return to idle and clear cropping idle', () => {
@ -541,7 +549,7 @@ describe('When in the select.pointing_crop_handle state', () => {
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
.select(ids.imageB) .select(ids.imageB)
.pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.cancel() .cancel()
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
@ -556,7 +564,7 @@ describe('When in the select.pointing_crop_handle state', () => {
.doubleClick(550, 550, ids.imageB) .doubleClick(550, 550, ids.imageB)
.expectToBeIn('select.crop.idle') .expectToBeIn('select.crop.idle')
.pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: false }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: false })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.cancel() .cancel()
.expectToBeIn('select.crop.idle') .expectToBeIn('select.crop.idle')
@ -564,7 +572,7 @@ describe('When in the select.pointing_crop_handle state', () => {
}) })
}) })
describe('When in the select.cropping state', () => { describe('When in the select.crop.cropping state', () => {
it('moving the pointer should adjust the crop', () => { it('moving the pointer should adjust the crop', () => {
const before = editor.getShape<TLImageShape>(ids.imageB)!.props.crop! const before = editor.getShape<TLImageShape>(ids.imageB)!.props.crop!
@ -573,9 +581,9 @@ describe('When in the select.cropping state', () => {
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
.select(ids.imageB) .select(ids.imageB)
.pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.pointerMove(510, 590) .pointerMove(510, 590)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
expect(editor.getShape<TLImageShape>(ids.imageB)!.props.crop!).not.toMatchObject(before) expect(editor.getShape<TLImageShape>(ids.imageB)!.props.crop!).not.toMatchObject(before)
}) })
@ -588,9 +596,9 @@ describe('When in the select.cropping state', () => {
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
.select(ids.imageB) .select(ids.imageB)
.pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.pointerMove(510, 590) .pointerMove(510, 590)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.cancel() .cancel()
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
@ -606,9 +614,9 @@ describe('When in the select.cropping state', () => {
.doubleClick(550, 550, ids.imageB) .doubleClick(550, 550, ids.imageB)
.expectToBeIn('select.crop.idle') .expectToBeIn('select.crop.idle')
.pointerDown(500, 600, { target: 'selection', handle: 'bottom', ctrlKey: false }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom', ctrlKey: false })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.pointerMove(510, 590) .pointerMove(510, 590)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.cancel() .cancel()
.expectToBeIn('select.crop.idle') .expectToBeIn('select.crop.idle')
@ -624,9 +632,9 @@ describe('When in the select.cropping state', () => {
.doubleClick(550, 550, ids.imageB) .doubleClick(550, 550, ids.imageB)
.expectToBeIn('select.crop.idle') .expectToBeIn('select.crop.idle')
.pointerDown(500, 600, { target: 'selection', handle: 'bottom', ctrlKey: false }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom', ctrlKey: false })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.pointerMove(510, 590) .pointerMove(510, 590)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.pointerUp() .pointerUp()
.expectToBeIn('select.crop.idle') .expectToBeIn('select.crop.idle')
@ -645,9 +653,9 @@ describe('When in the select.cropping state', () => {
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
.select(ids.imageB) .select(ids.imageB)
.pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true }) .pointerDown(500, 600, { target: 'selection', handle: 'bottom_left', ctrlKey: true })
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.pointerMove(510, 590) .pointerMove(510, 590)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.pointerUp() .pointerUp()
.expectToBeIn('select.idle') .expectToBeIn('select.idle')
@ -680,10 +688,10 @@ describe('When cropping...', () => {
}, },
{ ctrlKey: true } { ctrlKey: true }
) )
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps }) .expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps })
.pointerMove(imageX + moveX, imageY + moveY) .pointerMove(imageX + moveX, imageY + moveY)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.expectShapeToMatch({ .expectShapeToMatch({
id: ids.imageA, id: ids.imageA,
x: imageX + (imageWidth - MIN_CROP_SIZE), x: imageX + (imageWidth - MIN_CROP_SIZE),
@ -752,10 +760,10 @@ describe('When cropping...', () => {
}, },
{ ctrlKey: true } { ctrlKey: true }
) )
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps }) .expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps })
.pointerMove(imageX + moveX, imageY + moveY) .pointerMove(imageX + moveX, imageY + moveY)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.expectShapeToMatch({ .expectShapeToMatch({
id: ids.imageA, id: ids.imageA,
x: imageX + moveX, x: imageX + moveX,
@ -790,10 +798,10 @@ describe('When cropping...', () => {
}, },
{ ctrlKey: true } { ctrlKey: true }
) )
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps }) .expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps })
.pointerMove(imageX + imageWidth - moveX, imageY + moveY) .pointerMove(imageX + imageWidth - moveX, imageY + moveY)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.expectShapeToMatch({ .expectShapeToMatch({
id: ids.imageA, id: ids.imageA,
x: imageX, x: imageX,
@ -828,10 +836,10 @@ describe('When cropping...', () => {
}, },
{ ctrlKey: true } { ctrlKey: true }
) )
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps }) .expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps })
.pointerMove(imageX + moveX, imageY + imageHeight - moveY) .pointerMove(imageX + moveX, imageY + imageHeight - moveY)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.expectShapeToMatch({ .expectShapeToMatch({
id: ids.imageA, id: ids.imageA,
x: imageX + moveX, x: imageX + moveX,
@ -866,10 +874,10 @@ describe('When cropping...', () => {
}, },
{ ctrlKey: true } { ctrlKey: true }
) )
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps }) .expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps })
.pointerMove(imageX + imageWidth - moveX, imageY + imageHeight - moveY) .pointerMove(imageX + imageWidth - moveX, imageY + imageHeight - moveY)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.expectShapeToMatch({ .expectShapeToMatch({
id: ids.imageA, id: ids.imageA,
x: imageX, x: imageX,
@ -902,10 +910,10 @@ describe('When cropping...', () => {
}, },
{ ctrlKey: true } { ctrlKey: true }
) )
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps }) .expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps })
.pointerMove(imageX + moveX, imageY + imageHeight / 2) .pointerMove(imageX + moveX, imageY + imageHeight / 2)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.expectShapeToMatch({ .expectShapeToMatch({
id: ids.imageA, id: ids.imageA,
x: imageX + moveX, x: imageX + moveX,
@ -938,10 +946,10 @@ describe('When cropping...', () => {
}, },
{ ctrlKey: true } { ctrlKey: true }
) )
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps }) .expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps })
.pointerMove(imageX + imageWidth / 2, imageY + moveY) .pointerMove(imageX + imageWidth / 2, imageY + moveY)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.expectShapeToMatch({ .expectShapeToMatch({
id: ids.imageA, id: ids.imageA,
x: imageX, x: imageX,
@ -974,10 +982,10 @@ describe('When cropping...', () => {
}, },
{ ctrlKey: true } { ctrlKey: true }
) )
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps }) .expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps })
.pointerMove(150, 150) .pointerMove(150, 150)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.pointerMove(imageX + imageWidth - moveX, imageY + imageHeight / 2) .pointerMove(imageX + imageWidth - moveX, imageY + imageHeight / 2)
.expectShapeToMatch({ .expectShapeToMatch({
id: ids.imageA, id: ids.imageA,
@ -1011,10 +1019,10 @@ describe('When cropping...', () => {
}, },
{ ctrlKey: true } { ctrlKey: true }
) )
.expectToBeIn('select.pointing_crop_handle') .expectToBeIn('select.crop.pointing_crop_handle')
.expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps }) .expectShapeToMatch({ id: ids.imageA, x: imageX, y: imageY, props: imageProps })
.pointerMove(imageX + imageWidth / 2, imageY + imageHeight - moveY) .pointerMove(imageX + imageWidth / 2, imageY + imageHeight - moveY)
.expectToBeIn('select.cropping') .expectToBeIn('select.crop.cropping')
.expectShapeToMatch({ .expectShapeToMatch({
id: ids.imageA, id: ids.imageA,
x: imageX, x: imageX,