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:
parent
16e696ed03
commit
eabb0d52f8
13 changed files with 195 additions and 166 deletions
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
this._setInstancePageState({ erasingShapeIds: ids }, { ephemeral: true })
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,55 +2937,58 @@ 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)) {
|
||||
isShapeErasing = true
|
||||
opacity *= 0.32
|
||||
if (useEditorState) {
|
||||
if (!isAncestorErasing && erasingShapeIds.includes(id)) {
|
||||
isShapeErasing = true
|
||||
opacity *= 0.32
|
||||
}
|
||||
|
||||
isCulled =
|
||||
isCullingOffScreenShapes &&
|
||||
// only cull shapes that allow unmounting, i.e. not stateful components
|
||||
util.canUnmount(shape) &&
|
||||
// never cull editingg shapes
|
||||
editingShapeId !== id &&
|
||||
// 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)))
|
||||
}
|
||||
|
||||
// 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
|
||||
util.canUnmount(shape) &&
|
||||
// editing shapes can't be culled
|
||||
editingShapeId !== id &&
|
||||
// selected shapes can't be culled
|
||||
!selectedShapeIds?.includes(id) &&
|
||||
// shapes outside of the viewport are culled
|
||||
!!(renderingBoundsExpanded && !renderingBoundsExpanded.includes(maskedPageBounds))
|
||||
|
||||
renderingShapes.push({
|
||||
id,
|
||||
shape,
|
||||
|
@ -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())
|
||||
this._renderingBoundsExpanded.set(
|
||||
viewportPageBounds.clone().expandBy(this.renderingBoundsMargin / this.zoomLevel)
|
||||
)
|
||||
|
||||
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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
27
packages/tldraw/src/lib/useSideEffects.ts
Normal file
27
packages/tldraw/src/lib/useSideEffects.ts
Normal 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])
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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],
|
||||
])
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in a new issue