Bumps state-designer

This commit is contained in:
Steve Ruiz 2021-07-12 12:17:47 +01:00
parent 0a6e936ca7
commit eca210da6a
16 changed files with 524 additions and 3112 deletions

View file

@ -1,31 +1,142 @@
import { ShapeType } from 'types'
import TestState from '../test-utils'
describe('group command', () => {
const tt = new TestState()
tt.resetDocumentState()
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 1,
isLocked: false,
isHidden: false,
isAspectRatioLocked: false,
},
'rect1'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [400, 0],
size: [100, 100],
childIndex: 2,
isHidden: false,
isLocked: false,
isAspectRatioLocked: false,
},
'rect2'
)
.save()
describe('when one item is selected', () => {
it('does not change anything', () => {
// TODO
null
})
// it('deletes the group if it has only one child', () => {
// tt.restore()
// .clickShape('rect1')
// .clickShape('rect2', { shiftKey: true })
// .send('GROUPED')
// const groupId = tt.getShape('rect1').parentId
// expect(groupId === tt.data.currentPageId).toBe(false)
// tt.doubleClickShape('rect1')
// tt.send('DELETED')
// expect(tt.getShape(groupId)).toBe(undefined)
// expect(tt.getShape('rect2')).toBeTruthy()
// })
it('deletes the group if all children are deleted', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
const groupId = tt.getShape('rect1').parentId
expect(groupId === tt.data.currentPageId).toBe(false)
tt.doubleClickShape('rect1').clickShape('rect2', { shiftKey: true })
tt.send('DELETED')
expect(tt.getShape(groupId)).toBe(undefined)
})
describe('when multiple items are selected', () => {
it('does command', () => {
// TODO
null
})
it('creates a group', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
it('un-does command', () => {
// TODO
null
})
const groupId = tt.getShape('rect1').parentId
it('re-does command', () => {
// TODO
null
})
expect(groupId === tt.data.currentPageId).toBe(false)
})
it('selects the group on single click', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
.clickShape('rect1')
const groupId = tt.getShape('rect1').parentId
expect(tt.selectedIds).toEqual([groupId])
})
it('selects the item on double click', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
.doubleClickShape('rect1')
const groupId = tt.getShape('rect1').parentId
expect(tt.data.currentParentId).toBe(groupId)
expect(tt.selectedIds).toEqual(['rect1'])
})
it('resets currentPageId when clicking the canvas', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
.doubleClickShape('rect1')
.clickCanvas()
.clickShape('rect1')
const groupId = tt.getShape('rect1').parentId
expect(tt.data.currentParentId).toBe(tt.data.currentPageId)
expect(tt.selectedIds).toEqual([groupId])
})
it('creates a group and undoes and redoes', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
const groupId = tt.getShape('rect1').parentId
expect(groupId === tt.data.currentPageId).toBe(false)
tt.undo()
expect(tt.getShape('rect1').parentId === tt.data.currentPageId).toBe(true)
expect(tt.getShape(groupId)).toBe(undefined)
tt.redo()
expect(tt.getShape('rect1').parentId === tt.data.currentPageId).toBe(false)
expect(tt.getShape(groupId)).toBeTruthy()
})
it('groups shapes with different parents', () => {

View file

@ -1,11 +1,9 @@
import state from 'state'
import * as json from '../__mocks__/document.json'
state.reset()
state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
state.send('CLEARED_PAGE')
import TestState from '../test-utils'
describe('arrow shape', () => {
const tt = new TestState()
tt.resetDocumentState().send('SELECTED_ARROW_TOOL').save()
it('creates shape', () => {
// TODO
null

View file

@ -19,11 +19,7 @@ export default function Bounds(): JSX.Element {
const bounds = useSelector((s) => s.values.selectedBounds)
const rotation = useSelector((s) =>
s.values.selectedIds.length === 1
? tld.getSelectedShapes(s.data)[0].rotation
: 0
)
const rotation = useSelector((s) => s.values.selectedRotation)
const isAllLocked = useSelector((s) => {
const page = tld.getPage(s.data)

View file

@ -33,18 +33,7 @@ export default function BoundsBg(): JSX.Element {
s.isInAny('selecting', 'selectPinching')
)
const rotation = useSelector((s) => {
const selectedIds = s.values.selectedIds
if (selectedIds.length === 1) {
const selected = selectedIds[0]
const page = tld.getPage(s.data)
return page.shapes[selected]?.rotation
} else {
return 0
}
})
const rotation = useSelector((s) => s.values.selectedRotation)
const isAllHandles = useSelector((s) => {
const selectedIds = s.values.selectedIds

View file

@ -1,7 +1,9 @@
module.exports = {
testEnvironment: 'jsdom',
testPathIgnorePatterns: ['node_modules', '.next'],
transformIgnorePatterns: ['node_modules/(?!(sucrase|browser-fs-access)/)'],
transformIgnorePatterns: [
'node_modules/(?!(sucrase|@state-designer/core|@state-designer/react|browser-fs-access)/)',
],
transform: {
'^.+\\.(ts|tsx|mjs)$': 'babel-jest',
},

View file

@ -46,13 +46,14 @@
"@sentry/react": "^6.8.0",
"@sentry/tracing": "^6.8.0",
"@sentry/webpack-plugin": "^1.15.1",
"@state-designer/react": "^1.7.4",
"@state-designer/react": "^2.0.3",
"@stitches/react": "^0.2.2",
"@types/uuid": "^8.3.0",
"browser-fs-access": "^0.17.3",
"framer-motion": "^4.1.17",
"gtag": "^1.0.1",
"idb-keyval": "^5.0.6",
"immer": "^9.0.5",
"ismobilejs": "^1.1.1",
"monaco-editor": "^0.25.2",
"next": "^11.0.1",

View file

@ -55,13 +55,10 @@ export class BaseCommand<T extends any> {
redo = (data: T, initial = false): void => {
if (this.manualSelection) {
this.doFn(data, initial)
return
}
if (initial) {
this.restoreBeforeSelectionState = this.saveSelectionState(data)
} else {
if (!initial) {
this.restoreBeforeSelectionState(data)
}

View file

@ -0,0 +1,37 @@
import Command from './command'
import history from '../history'
import { Data, Shape } from 'types'
import tld from 'utils/tld'
import { deepClone } from 'utils'
// Used when creating new shapes.
export default function createShapesCommand(
data: Data,
shapes: Shape[],
name = 'create_shapes'
): void {
const snapshot = deepClone(shapes)
const shapeIds = snapshot.map((shape) => shape.id)
history.execute(
data,
new Command({
name,
category: 'canvas',
manualSelection: true,
do(data) {
tld.createShapes(data, snapshot)
tld.setSelectedIds(data, shapeIds)
data.hoveredId = undefined
data.currentParentId = undefined
},
undo(data) {
tld.deleteShapes(data, shapeIds)
tld.setSelectedIds(data, [])
data.hoveredId = undefined
data.currentParentId = undefined
},
})
)
}

View file

@ -1,111 +0,0 @@
import Command from './command'
import history from '../history'
import { Data, Shape } from 'types'
import { deepClone } from 'utils'
import tld from 'utils/tld'
import { getShapeUtils } from 'state/shape-utils'
export default function deleteSelected(data: Data): void {
const selectedShapes = tld.getSelectedShapes(data)
const selectedIdsArr = selectedShapes
.filter((shape) => !shape.isLocked)
.map((shape) => shape.id)
const shapeIdsToDelete = selectedIdsArr.flatMap((id) =>
tld.getDocumentBranch(data, id)
)
const remainingIds = selectedShapes
.filter((shape) => shape.isLocked)
.map((shape) => shape.id)
let deletedShapes: Shape[] = []
history.execute(
data,
new Command({
name: 'delete_selection',
category: 'canvas',
manualSelection: true,
do(data) {
// Update selected ids
tld.setSelectedIds(data, remainingIds)
// Recursively delete shapes (and maybe their parents too)
deletedShapes = deleteShapes(data, shapeIdsToDelete)
},
undo(data) {
const page = tld.getPage(data)
// Update selected ids
tld.setSelectedIds(data, selectedIdsArr)
// Restore deleted shapes
deletedShapes.forEach((shape) => (page.shapes[shape.id] = shape))
// Update parents
deletedShapes.forEach((shape) => {
if (shape.parentId === data.currentPageId) return
const parent = page.shapes[shape.parentId]
getShapeUtils(parent)
.setProperty(parent, 'children', [...parent.children, shape.id])
.onChildrenChange(
parent,
parent.children.map((id) => page.shapes[id])
)
})
},
})
)
}
/** Recursively delete shapes and their parents */
function deleteShapes(
data: Data,
shapeIds: string[],
shapesDeleted: Shape[] = []
): Shape[] {
const parentsToDelete: string[] = []
const page = tld.getPage(data)
const parentIds = new Set(shapeIds.map((id) => page.shapes[id].parentId))
// Delete shapes
shapeIds.forEach((id) => {
shapesDeleted.push(deepClone(page.shapes[id]))
delete page.shapes[id]
})
// Update parents
parentIds.forEach((id) => {
const parent = page.shapes[id]
if (!parent || id === page.id) return
getShapeUtils(parent)
.setProperty(
parent,
'children',
parent.children.filter((childId) => !shapeIds.includes(childId))
)
.onChildrenChange(
parent,
parent.children.map((id) => page.shapes[id])
)
if (getShapeUtils(parent).shouldDelete(parent)) {
parentsToDelete.push(parent.id)
}
})
if (parentsToDelete.length > 0) {
return deleteShapes(data, parentsToDelete, shapesDeleted)
}
return shapesDeleted
}

View file

@ -0,0 +1,37 @@
import Command from './command'
import history from '../history'
import { Data, Shape } from 'types'
import tld from 'utils/tld'
export default function deleteShapes(data: Data, shapes: Shape[]): void {
const initialSelectedIds = [...tld.getSelectedIds(data)]
const shapeIdsToDelete = shapes.flatMap((shape) =>
shape.isLocked ? [] : tld.getDocumentBranch(data, shape.id)
)
const remainingIds = initialSelectedIds.filter(
(id) => !shapeIdsToDelete.includes(id)
)
// We're going to delete the shapes and their children, too; and possibly
// their parents, if we delete all of a group shape's children.
let deletedShapes: Shape[] = []
history.execute(
data,
new Command({
name: 'delete_selection',
category: 'canvas',
manualSelection: true,
do(data) {
deletedShapes = tld.deleteShapes(data, shapeIdsToDelete)
tld.setSelectedIds(data, remainingIds)
},
undo(data) {
tld.createShapes(data, deletedShapes)
tld.setSelectedIds(data, initialSelectedIds)
},
})
)
}

View file

@ -2,7 +2,8 @@ import align from './align'
import changePage from './change-page'
import createPage from './create-page'
import deletePage from './delete-page'
import deleteSelected from './delete-selected'
import deleteShapes from './delete-shapes'
import createShapes from './create-shapes'
import distribute from './distribute'
import doublePointHandle from './double-point-handle'
import draw from './draw'
@ -30,8 +31,9 @@ const commands = {
align,
changePage,
createPage,
createShapes,
deletePage,
deleteSelected,
deleteShapes,
distribute,
doublePointHandle,
draw,

View file

@ -12,12 +12,20 @@ export default class HandleSession extends BaseSession {
shiftKey: boolean
initialShape: Shape
handleId: string
isCreating: boolean
constructor(data: Data, shapeId: string, handleId: string, point: number[]) {
constructor(
data: Data,
shapeId: string,
handleId: string,
point: number[],
isCreating: boolean
) {
super(data)
this.origin = point
this.handleId = handleId
this.initialShape = deepClone(tld.getShape(data, shapeId))
this.isCreating = isCreating
}
update(
@ -48,13 +56,21 @@ export default class HandleSession extends BaseSession {
}
cancel(data: Data): void {
tld.getPage(data).shapes[this.initialShape.id] = this.initialShape
if (this.isCreating) {
tld.deleteShapes(data, [this.initialShape])
} else {
tld.getPage(data).shapes[this.initialShape.id] = this.initialShape
}
}
complete(data: Data): void {
const before = this.initialShape
const after = deepClone(tld.getShape(data, before.id))
commands.mutate(data, [before], [after])
if (this.isCreating) {
commands.createShapes(data, [after])
} else {
commands.mutate(data, [before], [after])
}
}
}

View file

@ -98,6 +98,10 @@ const group = registerShapeUtils<GroupShape>({
return this
},
shouldDelete(shape) {
return shape.children.length === 0 // should be <= 1
},
onChildrenChange(shape, children) {
if (shape.children.length === 0) return

View file

@ -884,9 +884,9 @@ const state = createState({
},
arrow: {
onEnter: 'setActiveToolArrow',
initial: 'creating',
initial: 'idle',
states: {
creating: {
idle: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_SHAPE: {
@ -1453,7 +1453,7 @@ const state = createState({
breakSession(data) {
session.cancel(data)
history.disable()
commands.deleteSelected(data)
commands.deleteShapes(data, tld.getSelectedShapes(data))
history.enable()
},
cancelSession(data) {
@ -1550,7 +1550,8 @@ const state = createState({
data,
shapeId,
handleId,
tld.screenToWorld(inputs.pointer.origin, data)
tld.screenToWorld(inputs.pointer.origin, data),
false
)
)
},
@ -1667,7 +1668,8 @@ const state = createState({
data,
shapeId,
handleId,
tld.screenToWorld(inputs.pointer.origin, data)
tld.screenToWorld(inputs.pointer.origin, data),
true
)
)
},
@ -1778,7 +1780,7 @@ const state = createState({
commands.toggle(data, 'isAspectRatioLocked')
},
deleteSelection(data) {
commands.deleteSelected(data)
commands.deleteShapes(data, tld.getSelectedShapes(data))
},
rotateSelectionCcw(data) {
commands.rotateCcw(data)
@ -2250,7 +2252,18 @@ const state = createState({
return commonStyle
},
selectedRotation(data) {
const selectedIds = tld.getSelectedIds(data)
if (selectedIds.length === 1) {
const selected = selectedIds[0]
const page = tld.getPage(data)
return page.shapes[selected]?.rotation
} else {
return 0
}
},
shapesToRender(data) {
const viewport = tld.getViewport(data)

View file

@ -15,6 +15,7 @@ import {
ShapeTreeNode,
} from 'types'
import { AssertionError } from 'assert'
import { lerp } from './utils'
export default class StateUtils {
static getCameraZoom(zoom: number): number {
@ -93,6 +94,155 @@ export default class StateUtils {
return Object.values(page.shapes)
}
/**
* Add the shapes to the current page.
*
* ### Example
*
*```ts
* tld.createShape(data, [shape1])
* tld.createShape(data, [shape1, shape2, shape3])
*```
*/
static createShapes(data: Data, shapes: Shape[]): void {
const page = this.getPage(data)
const shapeIds = shapes.map((shape) => shape.id)
// Update selected ids
this.setSelectedIds(data, shapeIds)
// Restore deleted shapes
shapes.forEach((shape) => {
const newShape = { ...shape }
page.shapes[shape.id] = newShape
})
// Update parents
shapes.forEach((shape) => {
if (shape.parentId === data.currentPageId) return
const parent = page.shapes[shape.parentId]
getShapeUtils(parent)
.setProperty(
parent,
'children',
parent.children.includes(shape.id)
? parent.children
: [...parent.children, shape.id]
)
.onChildrenChange(
parent,
parent.children.map((id) => page.shapes[id])
)
})
}
/**
* Delete the shapes from the current page.
*
* ### Example
*
*```ts
* tld.deleteShape(data, [shape1])
* tld.deleteShape(data, [shape1, shape1, shape1])
*```
*/
static deleteShapes(
data: Data,
shapeIds: string[] | Shape[],
shapesDeleted: Shape[] = []
): Shape[] {
const ids =
typeof shapeIds[0] === 'string'
? (shapeIds as string[])
: (shapeIds as Shape[]).map((shape) => shape.id)
const parentsToDelete: string[] = []
const page = this.getPage(data)
const parentIds = new Set(ids.map((id) => page.shapes[id].parentId))
// Delete shapes
ids.forEach((id) => {
shapesDeleted.push(deepClone(page.shapes[id]))
delete page.shapes[id]
})
// Update parents
parentIds.forEach((id) => {
const parent = page.shapes[id]
// The parent was either deleted or a is a page.
if (!parent) return
const utils = getShapeUtils(parent)
// Remove deleted ids from the parent's children and update the parent
utils
.setProperty(
parent,
'children',
parent.children.filter((childId) => !ids.includes(childId))
)
.onChildrenChange(
parent,
parent.children.map((id) => page.shapes[id])
)
if (utils.shouldDelete(parent)) {
// If the parent decides it should delete, then we need to reparent
// the parent's remaining children to the parent's parent, and
// assign them correct child indices, and then delete the parent on
// the next recursive step.
const nextIndex = this.getChildIndexAbove(data, parent.id)
const len = parent.children.length
// Reparent the children and assign them new child indices
parent.children.forEach((childId, i) => {
const child = this.getShape(data, childId)
getShapeUtils(child)
.setProperty(child, 'parentId', parent.parentId)
.setProperty(
child,
'childIndex',
lerp(parent.childIndex, nextIndex, i / len)
)
})
if (parent.parentId !== page.id) {
// If the parent is not a page, then we add the parent's children
// to the parent's parent shape before emptying that array. If the
// parent is a page, then we don't need to do this step.
// TODO: Consider adding explicit children array to page shapes.
const grandParent = page.shapes[parent.parentId]
getShapeUtils(grandParent)
.setProperty(grandParent, 'children', [...parent.children])
.onChildrenChange(
grandParent,
grandParent.children.map((id) => page.shapes[id])
)
}
// Empty the parent's children array and delete the parent on the next
// iteration step.
getShapeUtils(parent).setProperty(parent, 'children', [])
parentsToDelete.push(parent.id)
}
})
if (parentsToDelete.length > 0) {
return this.deleteShapes(data, parentsToDelete, shapesDeleted)
}
return shapesDeleted
}
/**
* Get the current selected shapes as an array.
* @param data

3056
yarn.lock

File diff suppressed because it is too large Load diff