Rendering / cropping side-effects (#1799)

This PR:
- improves the logic for computing `renderingShapes`
- improves the handling of side effects related to cropping

We might use the same side effect logic to edit / re-edit shapes, though
this may be more complicated with inputs that steal focus.

### Change Type

- [x] `major` — Breaking change

### Test Plan

1. Crop an image
2. Change the crop
3. Stop cropping
4. Undo — you should be cropping again!
5. Undo until you're not cropping anymore
6. Redo until you're cropping again
7. etc.

- [x] Unit Tests
This commit is contained in:
Steve Ruiz 2023-08-06 12:23:16 +01:00 committed by GitHub
parent 16e696ed03
commit eabb0d52f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 195 additions and 166 deletions

View file

@ -614,7 +614,7 @@ export class Editor extends EventEmitter<TLEventMap> {
deselect(...ids: TLShapeId[]): this;
// (undocumented)
deselect(...shapes: TLShape[]): this;
dispatch(info: TLEventInfo): this;
dispatch: (info: TLEventInfo) => this;
readonly disposables: Set<() => void>;
dispose(): void;
distributeShapes(shapes: TLShape[], operation: 'horizontal' | 'vertical'): this;
@ -630,7 +630,6 @@ export class Editor extends EventEmitter<TLEventMap> {
get editingShapeId(): null | TLShapeId;
readonly environment: EnvironmentManager;
get erasingShapeIds(): TLShapeId[];
get erasingShapeIdsSet(): Set<TLShapeId>;
// @internal (undocumented)
externalAssetContentHandlers: {
[K in TLExternalAssetContent_2['type']]: {
@ -873,7 +872,6 @@ export class Editor extends EventEmitter<TLEventMap> {
backgroundIndex: number;
opacity: number;
isCulled: boolean;
isInViewport: boolean;
maskedPageBounds: Box2d | undefined;
}[];
reparentShapes(shapes: TLShape[], parentId: TLParentId, insertIndex?: string): this;

View file

@ -1863,20 +1863,24 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
setErasingShapeIds(ids: TLShapeId[]): this {
const erasingShapeIds = this.erasingShapeIdsSet
if (ids.length === erasingShapeIds.size && ids.every((id) => erasingShapeIds.has(id)))
return this
ids.sort() // sort the incoming ids
const { erasingShapeIds } = this
if (ids.length === erasingShapeIds.length) {
// if the new ids are the same length as the current ids, they might be the same.
// presuming the current ids are also sorted, check each item to see if it's the same;
// if we find any unequal, then we know the new ids are different.
for (let i = 0; i < ids.length; i++) {
if (ids[i] !== erasingShapeIds[i]) {
this._setInstancePageState({ erasingShapeIds: ids }, { ephemeral: true })
break
}
}
} else {
// if the ids are a different length, then we know they're different.
this._setInstancePageState({ erasingShapeIds: ids }, { ephemeral: true })
return this
}
/**
* A derived set containing the current erasing ids.
*
* @public
*/
@computed get erasingShapeIdsSet() {
return new Set<TLShapeId>(this.erasingShapeIds)
return this
}
/**
@ -1899,9 +1903,6 @@ export class Editor extends EventEmitter<TLEventMap> {
if (id !== this.croppingShapeId) {
if (!id) {
this.updateCurrentPageState({ croppingShapeId: null })
if (this.isInAny('select.crop', 'select.pointing_crop_handle', 'select.cropping')) {
this.setCurrentTool('select.idle')
}
} else {
const shape = this.getShape(id)!
const util = this.getShapeUtil(shape)
@ -2913,20 +2914,10 @@ export class Editor extends EventEmitter<TLEventMap> {
}
private getUnorderedRenderingShapes(
ids: TLParentId[],
{
renderingBounds,
renderingBoundsExpanded,
erasingShapeIdsSet,
editingShapeId,
selectedShapeIds,
}: {
renderingBounds?: Box2d
renderingBoundsExpanded?: Box2d
erasingShapeIdsSet?: Set<TLShapeId>
editingShapeId?: TLShapeId | null
selectedShapeIds?: TLShapeId[]
} = {}
// The rendering state. We use this method both for rendering, which
// is based on other state, and for computing order for SVG export,
// which should work even when things are for example off-screen.
useEditorState: boolean
) {
// Here we get the shape as well as any of its children, as well as their
// opacities. If the shape is being erased, and none of its ancestors are
@ -2946,54 +2937,57 @@ export class Editor extends EventEmitter<TLEventMap> {
backgroundIndex: number
opacity: number
isCulled: boolean
isInViewport: boolean
maskedPageBounds: Box2d | undefined
}[] = []
let nextIndex = MAX_SHAPES_PER_PAGE
let nextBackgroundIndex = 0
const addShapeById = (id: TLParentId, parentOpacity: number, isAncestorErasing: boolean) => {
if (PageRecordType.isId(id)) {
for (const childId of this.getSortedChildIdsForParent(id)) {
addShapeById(childId, parentOpacity, isAncestorErasing)
}
return
}
// We only really need these if we're using editor state, but that's ok
const editingShapeId = this.editingShapeId
const selectedShapeIds = this.selectedShapeIds
const erasingShapeIds = this.erasingShapeIds
const renderingBoundsExpanded = this.renderingBoundsExpanded
const shape = this.getShape(id)
// If renderingBoundsMargin is set to Infinity, then we won't cull offscreen shapes
const isCullingOffScreenShapes = Number.isFinite(this.renderingBoundsMargin)
let shape: TLShape | undefined
let opacity: number
let isShapeErasing: boolean
let isCulled: boolean
let util: ShapeUtil
let maskedPageBounds: Box2d | undefined
const addShapeById = (id: TLShapeId, parentOpacity: number, isAncestorErasing: boolean) => {
shape = this.getShape(id)
if (!shape) return
let opacity = shape.opacity * parentOpacity
let isShapeErasing = false
opacity = shape.opacity * parentOpacity
isShapeErasing = false
isCulled = false
util = this.getShapeUtil(shape)
maskedPageBounds = this.getShapeMaskedPageBounds(id)
if (!isAncestorErasing && erasingShapeIdsSet?.has(id)) {
if (useEditorState) {
if (!isAncestorErasing && erasingShapeIds.includes(id)) {
isShapeErasing = true
opacity *= 0.32
}
// If a child is outside of its parent's clipping bounds, then bounds will be undefined.
const maskedPageBounds = this.getShapeMaskedPageBounds(id)
// Whether the shape is on screen. Use the "strict" viewport here.
const isInViewport = maskedPageBounds
? renderingBounds?.includes(maskedPageBounds) ?? true
: false
const util = this.getShapeUtil(shape)
const isCulled =
// shapes completely clipped by parent are always culled
maskedPageBounds === undefined
? true
: // some shapes can't be unmounted / culled
isCulled =
isCullingOffScreenShapes &&
// only cull shapes that allow unmounting, i.e. not stateful components
util.canUnmount(shape) &&
// editing shapes can't be culled
// never cull editingg shapes
editingShapeId !== id &&
// selected shapes can't be culled
!selectedShapeIds?.includes(id) &&
// shapes outside of the viewport are culled
!!(renderingBoundsExpanded && !renderingBoundsExpanded.includes(maskedPageBounds))
// if the shape is fully outside of its parent's clipping bounds...
(maskedPageBounds === undefined ||
// ...or if the shape is outside of the expanded viewport bounds...
(!renderingBoundsExpanded.includes(maskedPageBounds) &&
// ...and if it's not selected... then cull it
!selectedShapeIds.includes(id)))
}
renderingShapes.push({
id,
@ -3003,7 +2997,6 @@ export class Editor extends EventEmitter<TLEventMap> {
backgroundIndex: nextBackgroundIndex,
opacity,
isCulled,
isInViewport,
maskedPageBounds,
})
@ -3029,8 +3022,8 @@ export class Editor extends EventEmitter<TLEventMap> {
}
}
for (const id of ids) {
addShapeById(id, 1, false)
for (const childId of this.getSortedChildIdsForParent(this.currentPageId)) {
addShapeById(childId, 1, false)
}
return renderingShapes
@ -3042,13 +3035,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
@computed get renderingShapes() {
const renderingShapes = this.getUnorderedRenderingShapes([this.currentPageId], {
renderingBounds: this.renderingBounds,
renderingBoundsExpanded: this.renderingBoundsExpanded,
erasingShapeIdsSet: this.erasingShapeIdsSet,
editingShapeId: this.editingShapeId,
selectedShapeIds: this.selectedShapeIds,
})
const renderingShapes = this.getUnorderedRenderingShapes(true)
// Its IMPORTANT that the result be sorted by id AND include the index
// that the shape should be displayed at. Steve, this is the past you
@ -3104,9 +3091,14 @@ export class Editor extends EventEmitter<TLEventMap> {
const { viewportPageBounds } = this
if (viewportPageBounds.equals(this._renderingBounds.__unsafe__getWithoutCapture())) return this
this._renderingBounds.set(viewportPageBounds.clone())
if (Number.isFinite(this.renderingBoundsMargin)) {
this._renderingBoundsExpanded.set(
viewportPageBounds.clone().expandBy(this.renderingBoundsMargin / this.zoomLevel)
)
} else {
this._renderingBoundsExpanded.set(viewportPageBounds)
}
return this
}
@ -7944,8 +7936,8 @@ export class Editor extends EventEmitter<TLEventMap> {
// ---Figure out which shapes we need to include
const shapeIdsToInclude = this.getShapeAndDescendantIds(ids)
const renderingShapes = this.getUnorderedRenderingShapes([this.currentPageId]).filter(
({ id }) => shapeIdsToInclude.has(id)
const renderingShapes = this.getUnorderedRenderingShapes(false).filter(({ id }) =>
shapeIdsToInclude.has(id)
)
// --- Common bounding box of all shapes
@ -8374,7 +8366,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
dispatch(info: TLEventInfo): this {
dispatch = (info: TLEventInfo): this => {
// prevent us from spamming similar event errors if we're crashed.
// todo: replace with new readonly mode?
if (this.crashingError) return this

View file

@ -168,6 +168,7 @@ export class SideEffectManager<
const handlers = this._beforeCreateHandlers[typeName] as TLBeforeCreateHandler<any>[]
if (!handlers) this._beforeCreateHandlers[typeName] = []
this._beforeCreateHandlers[typeName]!.push(handler)
return () => remove(this._beforeCreateHandlers[typeName]!, handler)
}
registerAfterCreateHandler<T extends TLRecord['typeName']>(
@ -177,6 +178,7 @@ export class SideEffectManager<
const handlers = this._afterCreateHandlers[typeName] as TLAfterCreateHandler<any>[]
if (!handlers) this._afterCreateHandlers[typeName] = []
this._afterCreateHandlers[typeName]!.push(handler)
return () => remove(this._afterCreateHandlers[typeName]!, handler)
}
registerBeforeChangeHandler<T extends TLRecord['typeName']>(
@ -186,6 +188,7 @@ export class SideEffectManager<
const handlers = this._beforeChangeHandlers[typeName] as TLBeforeChangeHandler<any>[]
if (!handlers) this._beforeChangeHandlers[typeName] = []
this._beforeChangeHandlers[typeName]!.push(handler)
return () => remove(this._beforeChangeHandlers[typeName]!, handler)
}
registerAfterChangeHandler<T extends TLRecord['typeName']>(
@ -195,6 +198,7 @@ export class SideEffectManager<
const handlers = this._afterChangeHandlers[typeName] as TLAfterChangeHandler<any>[]
if (!handlers) this._afterChangeHandlers[typeName] = []
this._afterChangeHandlers[typeName]!.push(handler as TLAfterChangeHandler<any>)
return () => remove(this._afterChangeHandlers[typeName]!, handler)
}
registerBeforeDeleteHandler<T extends TLRecord['typeName']>(
@ -204,6 +208,7 @@ export class SideEffectManager<
const handlers = this._beforeDeleteHandlers[typeName] as TLBeforeDeleteHandler<any>[]
if (!handlers) this._beforeDeleteHandlers[typeName] = []
this._beforeDeleteHandlers[typeName]!.push(handler as TLBeforeDeleteHandler<any>)
return () => remove(this._beforeDeleteHandlers[typeName]!, handler)
}
registerAfterDeleteHandler<T extends TLRecord['typeName']>(
@ -213,6 +218,7 @@ export class SideEffectManager<
const handlers = this._afterDeleteHandlers[typeName] as TLAfterDeleteHandler<any>[]
if (!handlers) this._afterDeleteHandlers[typeName] = []
this._afterDeleteHandlers[typeName]!.push(handler as TLAfterDeleteHandler<any>)
return () => remove(this._afterDeleteHandlers[typeName]!, handler)
}
/**
@ -241,5 +247,13 @@ export class SideEffectManager<
*/
registerBatchCompleteHandler(handler: TLBatchCompleteHandler) {
this._batchCompleteHandlers.push(handler)
return () => remove(this._batchCompleteHandlers, handler)
}
}
function remove(array: any[], item: any) {
const index = array.indexOf(item)
if (index >= 0) {
array.splice(index, 1)
}
}

View file

@ -52,12 +52,12 @@ export class GroupShapeUtil extends ShapeUtil<TLGroupShape> {
component(shape: TLGroupShape) {
// Not a class component, but eslint can't tell that :(
const {
erasingShapeIdsSet,
erasingShapeIds,
currentPageState: { hintingShapeIds, focusedGroupId },
zoomLevel,
} = this.editor
const isErasing = erasingShapeIdsSet.has(shape.id)
const isErasing = erasingShapeIds.includes(shape.id)
const isHintingOtherGroup =
hintingShapeIds.length > 0 &&

View file

@ -18,6 +18,7 @@ import { defaultTools } from './defaultTools'
import { TldrawUi, TldrawUiProps } from './ui/TldrawUi'
import { ContextMenu } from './ui/components/ContextMenu'
import { useRegisterExternalContentHandlers } from './useRegisterExternalContentHandlers'
import { useSideEffects } from './useSideEffects'
import { TLEditorAssetUrls, useDefaultEditorAssetsWithOverrides } from './utils/assetUrls'
import { usePreloadAssets } from './utils/usePreloadAssets'
@ -84,6 +85,7 @@ export function Tldraw(
function Hacks() {
useRegisterExternalContentHandlers()
useSideEffects()
return null
}

View file

@ -97,7 +97,7 @@ export class Erasing extends StateNode {
const {
zoomLevel,
currentPageShapes: currentPageShapes,
erasingShapeIdsSet,
erasingShapeIds,
inputs: { currentPagePoint, previousPagePoint },
} = this.editor
@ -105,7 +105,7 @@ export class Erasing extends StateNode {
this.pushPointToScribble()
const erasing = new Set<TLShapeId>(erasingShapeIdsSet)
const erasing = new Set<TLShapeId>(erasingShapeIds)
for (const shape of currentPageShapes) {
if (this.editor.isShapeOfType<TLGroupShape>(shape, 'group')) continue

View file

@ -1,4 +1,5 @@
import { StateNode, TLEventHandlers, TLExitEventHandler, TLGroupShape, Vec2d } from '@tldraw/editor'
import { getHitShapeOnCanvasPointerDown } from '../../../../selection-logic/getHitShapeOnCanvasPointerDown'
import { ShapeWithCrop, getTranslateCroppedImageChange } from './crop_helpers'
export class Idle extends StateNode {
@ -17,9 +18,8 @@ export class Idle extends StateNode {
// (which clears the cropping id) but still remain in this state.
this.editor.on('change-history', this.cleanupCroppingState)
this.editor.mark('crop')
if (onlySelectedShape) {
this.editor.mark('crop')
this.editor.setCroppingShapeId(onlySelectedShape.id)
}
}
@ -42,19 +42,16 @@ export class Idle extends StateNode {
if (this.editor.isMenuOpen) return
if (info.ctrlKey) {
this.editor.setCroppingShapeId(null)
this.editor.setCurrentTool('select.brushing', info)
this.cancel()
// feed the event back into the statechart
this.editor.root.handleEvent(info)
return
}
switch (info.target) {
case 'canvas': {
const { hoveredShape } = this.editor
const hitShape =
hoveredShape && !this.editor.isShapeOfType<TLGroupShape>(hoveredShape, 'group')
? hoveredShape
: this.editor.getShapeAtPoint(this.editor.inputs.currentPagePoint)
if (hitShape) {
const hitShape = getHitShapeOnCanvasPointerDown(this.editor)
if (hitShape && !this.editor.isShapeOfType<TLGroupShape>(hitShape, 'group')) {
this.onPointerDown({
...info,
shape: hitShape,
@ -64,6 +61,8 @@ export class Idle extends StateNode {
}
this.cancel()
// feed the event back into the statechart
this.editor.root.handleEvent(info)
break
}
case 'shape': {
@ -77,6 +76,8 @@ export class Idle extends StateNode {
this.editor.setCurrentTool('select.crop.pointing_crop', info)
} else {
this.cancel()
// feed the event back into the statechart
this.editor.root.handleEvent(info)
}
}
break

View file

@ -15,13 +15,15 @@ export function BackToContent() {
let showBackToContentPrev = false
const interval = setInterval(() => {
const { renderingShapes } = editor
const { renderingShapes, renderingBounds } = editor
// renderingShapes will also include shapes that have the canUnmount flag
// set to true. These shapes will be on the canvas but may not be in the
// viewport... so we also need to narrow down the list to only shapes that
// are ALSO in the viewport.
const visibleShapes = renderingShapes.filter((s) => s.isInViewport)
const visibleShapes = renderingShapes.filter(
(s) => s.maskedPageBounds && renderingBounds.includes(s.maskedPageBounds)
)
const showBackToContentNow = visibleShapes.length === 0 && editor.currentPageShapes.length > 0
if (showBackToContentPrev !== showBackToContentNow) {

View file

@ -0,0 +1,27 @@
import { useEditor } from '@tldraw/editor'
import { useEffect } from 'react'
export function useSideEffects() {
const editor = useEditor()
useEffect(() => {
return editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) {
const isInCroppingState = editor.isInAny(
'select.crop',
'select.pointing_crop_handle',
'select.cropping'
)
if (!prev.croppingShapeId && next.croppingShapeId) {
if (!isInCroppingState) {
editor.setCurrentTool('select.crop.idle')
}
} else if (prev.croppingShapeId && !next.croppingShapeId) {
if (isInCroppingState) {
editor.setCurrentTool('select.idle')
}
}
}
})
}, [editor])
}

View file

@ -106,9 +106,8 @@ describe('When clicking', () => {
// Enters the pointing state
editor.expectPathToBe('root.eraser.pointing')
// Sets the erasingShapeIds array / erasingShapeIdsSet
// Sets the erasingShapeIds array
expect(editor.erasingShapeIds).toEqual([ids.box1])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
editor.pointerUp()
@ -118,9 +117,8 @@ describe('When clicking', () => {
expect(editor.getShape(ids.box1)).toBeUndefined()
expect(shapesAfterCount).toBe(shapesBeforeCount - 1)
// Also empties the erasingShapeIds array / erasingShapeIdsSet
// Also empties the erasingShapeIds array
expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
// Returns to idle
editor.expectPathToBe('root.eraser.idle')
@ -144,7 +142,6 @@ describe('When clicking', () => {
editor.pointerDown(99, 99) // next to box1 AND in box2
expect(new Set(editor.erasingShapeIds)).toEqual(new Set([ids.box1, ids.box2]))
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1, ids.box2]))
editor.pointerUp()
@ -164,7 +161,6 @@ describe('When clicking', () => {
editor.pointerDown(350, 350) // in box3
expect(new Set(editor.erasingShapeIds)).toEqual(new Set([ids.group1]))
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.group1]))
editor.pointerUp()
@ -184,7 +180,6 @@ describe('When clicking', () => {
const shapesBeforeCount = editor.currentPageShapes.length
editor.pointerDown(275, 275) // in between box2 AND box3, so over of the new group
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
editor.pointerUp()
@ -198,7 +193,6 @@ describe('When clicking', () => {
const shapesBeforeCount = editor.currentPageShapes.length
editor.pointerDown(375, 75) // inside of the box4 shape inside of box3
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box4]))
editor.pointerUp()
@ -216,7 +210,6 @@ describe('When clicking', () => {
const shapesBeforeCount = editor.currentPageShapes.length
editor.pointerDown(325, 25) // directly on frame1, not its children
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
editor.pointerUp() // without dragging!
@ -234,7 +227,6 @@ describe('When clicking', () => {
const shapesBeforeCount = editor.currentPageShapes.length
editor.pointerDown(425, 125) // inside of box4's bounds, but outside of its parent's mask
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
editor.pointerUp() // without dragging!
@ -256,7 +248,6 @@ describe('When clicking', () => {
editor.expectPathToBe('root.eraser.pointing')
expect(editor.erasingShapeIds).toEqual([ids.box1])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
editor.cancel()
@ -268,7 +259,6 @@ describe('When clicking', () => {
// Does NOT erase the shape
expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
expect(editor.getShape(ids.box1)).toBeDefined()
expect(shapesAfterCount).toBe(shapesBeforeCount)
})
@ -283,7 +273,6 @@ describe('When clicking', () => {
editor.expectPathToBe('root.eraser.pointing')
expect(editor.erasingShapeIds).toEqual([ids.box1])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
editor.interrupt()
@ -295,14 +284,13 @@ describe('When clicking', () => {
// Does NOT erase the shape
expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
expect(editor.getShape(ids.box1)).toBeDefined()
expect(shapesAfterCount).toBe(shapesBeforeCount)
})
})
describe('When clicking and dragging', () => {
it('Enters erasing state on pointer move, adds contacted shapes to the apps.erasingShapeIds array / apps.erasingShapeIdsSet, deletes them and clears erasingShapeIds / erasingShapeIdsSet on pointer up, restores shapes on undo and deletes again on redo', () => {
it('Enters erasing state on pointer move, adds contacted shapes to the apps.erasingShapeIds array, deletes them and clears erasingShapeIds on pointer up, restores shapes on undo and deletes again on redo', () => {
editor.setCurrentTool('eraser')
editor.expectPathToBe('root.eraser.idle')
@ -320,24 +308,20 @@ describe('When clicking and dragging', () => {
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([ids.box1])
// expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
// editor.pointerUp()
// editor.expectPathToBe('root.eraser.idle')
// expect(editor.erasingShapeIds).toEqual([])
// expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
// expect(editor.getShape(ids.box1)).not.toBeDefined()
// editor.undo()
// expect(editor.erasingShapeIds).toEqual([])
// expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
// expect(editor.getShape(ids.box1)).toBeDefined()
// editor.redo()
// expect(editor.erasingShapeIds).toEqual([])
// expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
// expect(editor.getShape(ids.box1)).not.toBeDefined()
})
@ -349,11 +333,9 @@ describe('When clicking and dragging', () => {
jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([ids.box1])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
editor.cancel()
editor.expectPathToBe('root.eraser.idle')
expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
expect(editor.getShape(ids.box1)).toBeDefined()
})
@ -366,10 +348,8 @@ describe('When clicking and dragging', () => {
jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
editor.pointerMove(0, 0)
expect(editor.erasingShapeIds).toEqual([ids.box1])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
expect(editor.getShape(ids.box1)).toBeDefined()
editor.pointerUp()
expect(editor.getShape(ids.group1)).toBeDefined()
@ -383,7 +363,6 @@ describe('When clicking and dragging', () => {
jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([ids.box3])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box3]))
editor.pointerUp()
expect(editor.getShape(ids.frame1)).toBeDefined()
expect(editor.getShape(ids.box3)).not.toBeDefined()
@ -397,7 +376,6 @@ describe('When clicking and dragging', () => {
jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
editor.pointerUp()
expect(editor.getShape(ids.box3)).toBeDefined()
@ -405,7 +383,6 @@ describe('When clicking and dragging', () => {
editor.pointerMove(375, 500) // Through the masked part of box3
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([ids.box3])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box3]))
editor.pointerUp()
expect(editor.getShape(ids.box3)).not.toBeDefined()
})

View file

@ -30,6 +30,26 @@ const imageProps = {
beforeEach(() => {
editor = new TestEditor()
// this side effect is normally added via a hook
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) {
const isInCroppingState = editor.isInAny(
'select.crop',
'select.pointing_crop_handle',
'select.cropping'
)
if (!prev.croppingShapeId && next.croppingShapeId) {
if (!isInCroppingState) {
editor.setCurrentTool('select.crop.idle')
}
} else if (prev.croppingShapeId && !next.croppingShapeId) {
if (isInCroppingState) {
editor.setCurrentTool('select.idle')
}
}
}
})
editor.createShapes([
{
id: ids.imageA,
@ -94,11 +114,13 @@ describe('When in the select.idle state', () => {
expect(editor.selectedShapeIds).toMatchObject([ids.boxA])
expect(editor.croppingShapeId).toBe(null)
editor.redo().redo()
editor
.redo() // select again
.redo() // crop again
editor.expectPathToBe('root.select.idle')
editor.expectPathToBe('root.select.crop.idle')
expect(editor.selectedShapeIds).toMatchObject([ids.imageB])
expect(editor.croppingShapeId).toBe(ids.imageB) // todo: fix this! we shouldn't set this on redo
expect(editor.croppingShapeId).toBe(ids.imageB)
})
it('when ONLY ONE image is selected double clicking a selection handle should transition to select.crop', () => {
@ -228,18 +250,20 @@ describe('When in the crop.idle state', () => {
.expectPathToBe('root.select.idle')
})
it('pointing the canvas should return to select.idle', () => {
it('pointing the canvas should point canvas', () => {
editor
.expectPathToBe('root.select.idle')
.click(550, 550, { target: 'canvas' })
.expectPathToBe('root.select.idle')
.pointerMove(-100, -100)
.pointerDown()
.expectPathToBe('root.select.pointing_canvas')
})
it('pointing some other shape should return to select.idle', () => {
it('pointing some other shape should start pointing the shape', () => {
editor
.expectPathToBe('root.select.idle')
.click(550, 550, { target: 'shape', shape: editor.getShape(ids.boxA) })
.expectPathToBe('root.select.idle')
.pointerMove(550, 500)
.pointerDown()
.expectPathToBe('root.select.pointing_shape')
})
it('pointing a selection handle should enter the select.pointing_crop_handle state', () => {

View file

@ -1499,7 +1499,7 @@ describe('erasing', () => {
// move to group B
editor.pointerMove(65, 5)
expect(editor.erasingShapeIdsSet.size).toBe(2)
expect(editor.erasingShapeIds.length).toBe(2)
})
})

View file

@ -63,49 +63,41 @@ it('updates the rendering viewport when the camera stops moving', () => {
it('lists shapes in viewport', () => {
const ids = createShapes()
editor.selectNone()
expect(
editor.renderingShapes.map(({ id, isCulled, isInViewport }) => [id, isCulled, isInViewport])
).toStrictEqual([
[ids.A, false, true], // A is within the expanded rendering bounds, so should not be culled; and it's in the regular viewport too, so it's on screen.
[ids.B, false, true],
[ids.C, false, true],
[ids.D, true, false], // D is clipped and so should always be culled / outside of viewport
expect(editor.renderingShapes.map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([
[ids.A, false], // A is within the expanded rendering bounds, so should not be culled; and it's in the regular viewport too, so it's on screen.
[ids.B, false],
[ids.C, false],
[ids.D, true], // D is clipped and so should always be culled / outside of viewport
])
// Move the camera 201 pixels to the right and 201 pixels down
editor.pan({ x: -201, y: -201 })
jest.advanceTimersByTime(500)
expect(
editor.renderingShapes.map(({ id, isCulled, isInViewport }) => [id, isCulled, isInViewport])
).toStrictEqual([
[ids.A, false, false], // A should not be culled, even though it's no longer in the viewport (because it's still in the EXPANDED viewport)
[ids.B, false, true],
[ids.C, false, true],
[ids.D, true, false], // D is clipped and so should always be culled / outside of viewport
expect(editor.renderingShapes.map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([
[ids.A, false], // A should not be culled, even though it's no longer in the viewport (because it's still in the EXPANDED viewport)
[ids.B, false],
[ids.C, false],
[ids.D, true], // D is clipped and so should always be culled / outside of viewport
])
editor.pan({ x: -100, y: -100 })
jest.advanceTimersByTime(500)
expect(
editor.renderingShapes.map(({ id, isCulled, isInViewport }) => [id, isCulled, isInViewport])
).toStrictEqual([
[ids.A, true, false], // A should be culled now that it's outside of the expanded viewport too
[ids.B, false, true],
[ids.C, false, true],
[ids.D, true, false], // D is clipped and so should always be culled / outside of viewport
expect(editor.renderingShapes.map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([
[ids.A, true], // A should be culled now that it's outside of the expanded viewport too
[ids.B, false],
[ids.C, false],
[ids.D, true], // D is clipped and so should always be culled, even if it's in the viewport
])
editor.pan({ x: -900, y: -900 })
jest.advanceTimersByTime(500)
expect(
editor.renderingShapes.map(({ id, isCulled, isInViewport }) => [id, isCulled, isInViewport])
).toStrictEqual([
[ids.A, true, false],
[ids.B, true, false],
[ids.C, true, false],
[ids.D, true, false],
expect(editor.renderingShapes.map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([
[ids.A, true],
[ids.B, true],
[ids.C, true],
[ids.D, true],
])
})