Merge pull request #47 from tldraw/feature-back-to-content
Closed shape improvements, various
This commit is contained in:
commit
df2acdf884
35 changed files with 1170 additions and 3186 deletions
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -1,5 +0,0 @@
|
|||
name: Bug Report
|
||||
about: Writing and other documentation.
|
||||
title: '[Bug] Bug description'
|
||||
labels: bug
|
||||
assignees: ''
|
28
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
28
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
|
@ -0,0 +1,28 @@
|
|||
name: Bug Report
|
||||
description: File a bug report
|
||||
title: '[Bug]: '
|
||||
labels: [bug, triage]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: Tell us what you see!
|
||||
value: 'A bug happened!'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What browsers are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- Firefox
|
||||
- Chrome
|
||||
- Safari
|
||||
- Microsoft Edge
|
|
@ -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', () => {
|
||||
|
|
|
@ -1,19 +1,61 @@
|
|||
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 { ArrowShape, ShapeType } from 'types'
|
||||
import TestState from '../test-utils'
|
||||
|
||||
describe('arrow shape', () => {
|
||||
it('creates shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
const tt = new TestState()
|
||||
tt.resetDocumentState().save()
|
||||
|
||||
it('cancels shape while creating', () => {
|
||||
// TODO
|
||||
null
|
||||
describe('creating arrows', () => {
|
||||
it('creates shape', () => {
|
||||
tt.reset().restore().send('SELECTED_ARROW_TOOL')
|
||||
|
||||
expect(tt.state.isIn('arrow.creating')).toBe(true)
|
||||
|
||||
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
|
||||
|
||||
const id = tt.getSortedPageShapeIds()[0]
|
||||
|
||||
const shape = tt.getShape<ArrowShape>(id)
|
||||
|
||||
tt.assertShapeType(id, ShapeType.Arrow)
|
||||
|
||||
expect(shape.handles.start.point).toEqual([0, 0])
|
||||
expect(shape.handles.bend.point).toEqual([50.5, 50.5])
|
||||
expect(shape.handles.end.point).toEqual([101, 101])
|
||||
})
|
||||
|
||||
it('creates shapes when pointing a shape', () => {
|
||||
tt.reset().restore().send('SELECTED_ARROW_TOOL').send('TOGGLED_TOOL_LOCK')
|
||||
|
||||
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
|
||||
tt.startClick('canvas').movePointerBy([-200, 100]).stopClick('canvas')
|
||||
|
||||
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('creates shapes when shape locked', () => {
|
||||
tt.reset()
|
||||
.restore()
|
||||
.createShape(
|
||||
{
|
||||
type: ShapeType.Rectangle,
|
||||
point: [0, 0],
|
||||
size: [100, 100],
|
||||
childIndex: 1,
|
||||
},
|
||||
'rect1'
|
||||
)
|
||||
.send('SELECTED_ARROW_TOOL')
|
||||
|
||||
tt.startClick('rect1').movePointerBy([100, 100]).stopClick('canvas')
|
||||
|
||||
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels shape while creating', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
})
|
||||
|
||||
it('moves shape', () => {
|
||||
|
|
101
__tests__/shapes/draw.test.ts
Normal file
101
__tests__/shapes/draw.test.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { ShapeType } from 'types'
|
||||
import TestState from '../test-utils'
|
||||
|
||||
describe('draw shape', () => {
|
||||
const tt = new TestState()
|
||||
tt.resetDocumentState().save()
|
||||
|
||||
describe('creating draws', () => {
|
||||
it('creates shape', () => {
|
||||
tt.reset().restore().send('SELECTED_DRAW_TOOL')
|
||||
|
||||
expect(tt.state.isIn('draw.creating')).toBe(true)
|
||||
|
||||
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
|
||||
|
||||
const id = tt.getSortedPageShapeIds()[0]
|
||||
|
||||
tt.assertShapeType(id, ShapeType.Draw)
|
||||
})
|
||||
|
||||
it('creates shapes when pointing a shape', () => {
|
||||
tt.reset().restore().send('SELECTED_DRAW_TOOL').send('TOGGLED_TOOL_LOCK')
|
||||
|
||||
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
|
||||
tt.startClick('canvas').movePointerBy([-200, 100]).stopClick('canvas')
|
||||
|
||||
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('creates shapes when shape locked', () => {
|
||||
tt.reset()
|
||||
.restore()
|
||||
.createShape(
|
||||
{
|
||||
type: ShapeType.Rectangle,
|
||||
point: [0, 0],
|
||||
size: [100, 100],
|
||||
childIndex: 1,
|
||||
},
|
||||
'rect1'
|
||||
)
|
||||
.send('SELECTED_DRAW_TOOL')
|
||||
|
||||
tt.startClick('rect1').movePointerBy([100, 100]).stopClick('canvas')
|
||||
|
||||
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels shape while creating', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
})
|
||||
|
||||
it('moves shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('rotates shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('rotates shape in a group', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('measures shape bounds', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('measures shape rotated bounds', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('transforms single shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('transforms in a group', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
/* -------------------- Specific -------------------- */
|
||||
|
||||
it('closes the shape when the start and end points are near enough', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('remains closed after resizing up', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
})
|
104
__tests__/shapes/ellipse.test.ts
Normal file
104
__tests__/shapes/ellipse.test.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { ShapeType } from 'types'
|
||||
import TestState from '../test-utils'
|
||||
|
||||
describe('ellipse shape', () => {
|
||||
const tt = new TestState()
|
||||
tt.resetDocumentState().save()
|
||||
|
||||
describe('creating ellipses', () => {
|
||||
it('creates shape', () => {
|
||||
tt.reset().restore().send('SELECTED_ELLIPSE_TOOL')
|
||||
|
||||
expect(tt.state.isIn('ellipse.creating')).toBe(true)
|
||||
|
||||
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
|
||||
|
||||
const id = tt.getSortedPageShapeIds()[0]
|
||||
|
||||
tt.assertShapeType(id, ShapeType.Ellipse)
|
||||
})
|
||||
|
||||
it('creates shapes when pointing a shape', () => {
|
||||
tt.reset()
|
||||
.restore()
|
||||
.send('SELECTED_ELLIPSE_TOOL')
|
||||
.send('TOGGLED_TOOL_LOCK')
|
||||
|
||||
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
|
||||
tt.startClick('canvas').movePointerBy([-200, 100]).stopClick('canvas')
|
||||
|
||||
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('creates shapes when shape locked', () => {
|
||||
tt.reset()
|
||||
.restore()
|
||||
.createShape(
|
||||
{
|
||||
type: ShapeType.Rectangle,
|
||||
point: [0, 0],
|
||||
size: [100, 100],
|
||||
childIndex: 1,
|
||||
},
|
||||
'rect1'
|
||||
)
|
||||
.send('SELECTED_ELLIPSE_TOOL')
|
||||
|
||||
tt.startClick('rect1').movePointerBy([100, 100]).stopClick('canvas')
|
||||
|
||||
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels shape while creating', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
})
|
||||
|
||||
it('moves shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('rotates shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('rotates shape in a group', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('measures shape bounds', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('measures shape rotated bounds', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('transforms single shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('transforms in a group', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
/* -------------------- Specific -------------------- */
|
||||
|
||||
it('creates aspect-ratio-locked shape with shift key', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('resizes aspect-ratio-locked shape with shift key', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
})
|
104
__tests__/shapes/rectangle.test.ts
Normal file
104
__tests__/shapes/rectangle.test.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { ShapeType } from 'types'
|
||||
import TestState from '../test-utils'
|
||||
|
||||
describe('rectangle shape', () => {
|
||||
const tt = new TestState()
|
||||
tt.resetDocumentState().save()
|
||||
|
||||
describe('creating rectangles', () => {
|
||||
it('creates shape', () => {
|
||||
tt.reset().restore().send('SELECTED_RECTANGLE_TOOL')
|
||||
|
||||
expect(tt.state.isIn('rectangle.creating')).toBe(true)
|
||||
|
||||
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
|
||||
|
||||
const id = tt.getSortedPageShapeIds()[0]
|
||||
|
||||
tt.assertShapeType(id, ShapeType.Rectangle)
|
||||
})
|
||||
|
||||
it('creates shapes when pointing a shape', () => {
|
||||
tt.reset()
|
||||
.restore()
|
||||
.send('SELECTED_RECTANGLE_TOOL')
|
||||
.send('TOGGLED_TOOL_LOCK')
|
||||
|
||||
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
|
||||
tt.startClick('canvas').movePointerBy([-200, 100]).stopClick('canvas')
|
||||
|
||||
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('creates shapes when shape locked', () => {
|
||||
tt.reset()
|
||||
.restore()
|
||||
.createShape(
|
||||
{
|
||||
type: ShapeType.Rectangle,
|
||||
point: [0, 0],
|
||||
size: [100, 100],
|
||||
childIndex: 1,
|
||||
},
|
||||
'rect1'
|
||||
)
|
||||
.send('SELECTED_RECTANGLE_TOOL')
|
||||
|
||||
tt.startClick('rect1').movePointerBy([100, 100]).stopClick('canvas')
|
||||
|
||||
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels shape while creating', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
})
|
||||
|
||||
it('moves shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('rotates shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('rotates shape in a group', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('measures shape bounds', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('measures shape rotated bounds', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('transforms single shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('transforms in a group', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
/* -------------------- Specific -------------------- */
|
||||
|
||||
it('creates aspect-ratio-locked shape with shift key', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('resizes aspect-ratio-locked shape with shift key', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
})
|
79
__tests__/shapes/text.test.ts
Normal file
79
__tests__/shapes/text.test.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
import TestState from '../test-utils'
|
||||
|
||||
describe('arrow shape', () => {
|
||||
const tt = new TestState()
|
||||
tt.resetDocumentState()
|
||||
|
||||
it('creates shape', () => {
|
||||
tt.send('SELECTED_TEXT_TOOL')
|
||||
|
||||
expect(tt.state.isIn('text.creating')).toBe(true)
|
||||
|
||||
const id = tt.getSortedPageShapeIds()[0]
|
||||
|
||||
tt.clickCanvas()
|
||||
|
||||
expect(tt.state.isIn('editingShape')).toBe(true)
|
||||
|
||||
tt.send('EDITED_SHAPE', {
|
||||
id,
|
||||
change: { text: 'Hello world' },
|
||||
})
|
||||
|
||||
tt.send('BLURRED_EDITING_SHAPE', { id: id })
|
||||
|
||||
expect(tt.state.isIn('selecting')).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels shape while creating', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('moves shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('rotates shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('rotates shape in a group', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('measures shape bounds', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('measures shape rotated bounds', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('transforms single shape', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('transforms in a group', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
/* -------------------- Specific -------------------- */
|
||||
|
||||
it('scales', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
|
||||
it('selects different text on tap while editing', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
})
|
|
@ -106,7 +106,7 @@ class TestState {
|
|||
*/
|
||||
getSortedPageShapeIds(): string[] {
|
||||
return Object.values(
|
||||
this.data.document.pages[this.data.currentParentId].shapes
|
||||
this.data.document.pages[this.data.currentPageId].shapes
|
||||
)
|
||||
.sort((a, b) => a.childIndex - b.childIndex)
|
||||
.map((shape) => shape.id)
|
||||
|
@ -257,6 +257,14 @@ class TestState {
|
|||
const shape = tld.getShape(this.data, id)
|
||||
const [x, y] = shape ? vec.add(shape.point, [1, 1]) : [0, 0]
|
||||
|
||||
if (id === 'canvas') {
|
||||
this.state.send(
|
||||
'POINTED_CANVAS',
|
||||
inputs.pointerDown(TestState.point({ x, y, ...options }), id)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
this.state.send(
|
||||
'POINTED_SHAPE',
|
||||
inputs.pointerDown(TestState.point({ x, y, ...options }), id)
|
||||
|
|
38
__tests__/tools.test.ts
Normal file
38
__tests__/tools.test.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import TestState from './test-utils'
|
||||
|
||||
const TOOLS = [
|
||||
'draw',
|
||||
'rectangle',
|
||||
'ellipse',
|
||||
'arrow',
|
||||
'text',
|
||||
'line',
|
||||
'ray',
|
||||
'dot',
|
||||
]
|
||||
|
||||
describe('when selecting tools', () => {
|
||||
const tt = new TestState()
|
||||
|
||||
TOOLS.forEach((tool) => {
|
||||
it(`selects ${tool} tool`, () => {
|
||||
tt.reset().send(`SELECTED_${tool.toUpperCase()}_TOOL`)
|
||||
|
||||
expect(tt.data.activeTool).toBe(tool)
|
||||
expect(tt.state.isIn(tool)).toBe(true)
|
||||
})
|
||||
|
||||
TOOLS.forEach((otherTool) => {
|
||||
if (otherTool === tool) return
|
||||
|
||||
it(`selects ${tool} tool from ${otherTool} tool`, () => {
|
||||
tt.reset()
|
||||
.send(`SELECTED_${tool.toUpperCase()}_TOOL`)
|
||||
.send(`SELECTED_${otherTool.toUpperCase()}_TOOL`)
|
||||
|
||||
expect(tt.data.activeTool).toBe(otherTool)
|
||||
expect(tt.state.isIn(otherTool)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
// This is the code library.
|
||||
|
||||
export default `
|
||||
class Circle {
|
||||
greet(): string {
|
||||
return "Hello!"
|
||||
}
|
||||
}
|
||||
`
|
|
@ -1,8 +0,0 @@
|
|||
export default `new Circle({
|
||||
point: [200, 200],
|
||||
})
|
||||
|
||||
new Rectangle({
|
||||
point: [400, 300],
|
||||
})
|
||||
`
|
|
@ -62,6 +62,8 @@ enum FontSize {
|
|||
ExtraLarge = 'ExtraLarge',
|
||||
}
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
type ShapeStyles = {
|
||||
color: ColorStyle
|
||||
size: SizeStyle
|
||||
|
@ -214,6 +216,16 @@ interface CodeResult {
|
|||
error: CodeError
|
||||
}
|
||||
|
||||
interface ShapeTreeNode {
|
||||
shape: Shape
|
||||
children: ShapeTreeNode[]
|
||||
isEditing: boolean
|
||||
isHovered: boolean
|
||||
isSelected: boolean
|
||||
isDarkMode: boolean
|
||||
isCurrentParent: boolean
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Editor UI */
|
||||
/* -------------------------------------------------- */
|
||||
|
@ -545,8 +557,12 @@ interface ShapeUtility<K extends Shape> {
|
|||
render(
|
||||
this: ShapeUtility<K>,
|
||||
shape: K,
|
||||
info: {
|
||||
isEditing: boolean
|
||||
info?: {
|
||||
isEditing?: boolean
|
||||
isHovered?: boolean
|
||||
isSelected?: boolean
|
||||
isCurrentParent?: boolean
|
||||
isDarkMode?: boolean
|
||||
ref?: React.MutableRefObject<HTMLTextAreaElement>
|
||||
}
|
||||
): JSX.Element
|
||||
|
@ -636,6 +652,8 @@ enum FontSize {
|
|||
ExtraLarge = 'ExtraLarge',
|
||||
}
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
type ShapeStyles = {
|
||||
color: ColorStyle
|
||||
size: SizeStyle
|
||||
|
@ -788,6 +806,16 @@ interface CodeResult {
|
|||
error: CodeError
|
||||
}
|
||||
|
||||
interface ShapeTreeNode {
|
||||
shape: Shape
|
||||
children: ShapeTreeNode[]
|
||||
isEditing: boolean
|
||||
isHovered: boolean
|
||||
isSelected: boolean
|
||||
isDarkMode: boolean
|
||||
isCurrentParent: boolean
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Editor UI */
|
||||
/* -------------------------------------------------- */
|
||||
|
@ -1119,8 +1147,12 @@ interface ShapeUtility<K extends Shape> {
|
|||
render(
|
||||
this: ShapeUtility<K>,
|
||||
shape: K,
|
||||
info: {
|
||||
isEditing: boolean
|
||||
info?: {
|
||||
isEditing?: boolean
|
||||
isHovered?: boolean
|
||||
isSelected?: boolean
|
||||
isCurrentParent?: boolean
|
||||
isDarkMode?: boolean
|
||||
ref?: React.MutableRefObject<HTMLTextAreaElement>
|
||||
}
|
||||
): JSX.Element
|
||||
|
@ -1840,6 +1872,14 @@ type RequiredKeys<T> = {
|
|||
return Array.from(new Set(items).values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a set to an array.
|
||||
* @param set
|
||||
*/
|
||||
static setToArray<T>(set: Set<T>): T[] {
|
||||
return Array.from(set.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the outer of between a circle and a point.
|
||||
* @param C The circle's center.
|
||||
|
|
|
@ -9,10 +9,13 @@ export default function StatusBar(): JSX.Element {
|
|||
|
||||
const shapesInView = state.values.shapesToRender.length
|
||||
|
||||
const active = local.active.slice(1).map((s) => {
|
||||
const states = s.split('.')
|
||||
return states[states.length - 1]
|
||||
})
|
||||
const active = local.active
|
||||
.slice(1)
|
||||
.map((s) => {
|
||||
const states = s.split('.')
|
||||
return states[states.length - 1]
|
||||
})
|
||||
.join(' | ')
|
||||
|
||||
const log = local.log[0]
|
||||
|
||||
|
@ -21,7 +24,7 @@ export default function StatusBar(): JSX.Element {
|
|||
return (
|
||||
<StatusBarContainer size={size}>
|
||||
<Section>
|
||||
{active.join(' | ')} - {log}
|
||||
{active} - {log}
|
||||
</Section>
|
||||
<Section>{shapesInView || '0'} Shapes</Section>
|
||||
</StatusBarContainer>
|
||||
|
|
|
@ -21,7 +21,7 @@ export default function IsFilledPicker(): JSX.Element {
|
|||
|
||||
return (
|
||||
selectedShapes.length === 0 ||
|
||||
selectedShapes.every((shape) => getShapeUtils(shape).canStyleFill)
|
||||
selectedShapes.some((shape) => getShapeUtils(shape).canStyleFill)
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -135,7 +135,7 @@ export function PrimaryButton({
|
|||
children,
|
||||
}: PrimaryToolButtonProps): JSX.Element {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Tooltip label={label[0].toUpperCase() + label.slice(1)}>
|
||||
<PrimaryToolButton
|
||||
name={label}
|
||||
bp={{
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { MutableRefObject, useCallback } from 'react'
|
||||
import { MutableRefObject, useCallback, useEffect } from 'react'
|
||||
import state from 'state'
|
||||
import {
|
||||
fastBrushSelect,
|
||||
|
@ -86,6 +86,51 @@ export default function useCanvasEvents(
|
|||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const preventGestureNavigation = (event: TouchEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const preventNavigation = (event: TouchEvent) => {
|
||||
// Center point of the touch area
|
||||
const touchXPosition = event.touches[0].pageX
|
||||
// Size of the touch area
|
||||
const touchXRadius = event.touches[0].radiusX || 0
|
||||
|
||||
// We set a threshold (10px) on both sizes of the screen,
|
||||
// if the touch area overlaps with the screen edges
|
||||
// it's likely to trigger the navigation. We prevent the
|
||||
// touchstart event in that case.
|
||||
if (
|
||||
touchXPosition - touchXRadius < 10 ||
|
||||
touchXPosition + touchXRadius > window.innerWidth - 10
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
rCanvas.current.addEventListener('gestureend', preventGestureNavigation)
|
||||
rCanvas.current.addEventListener('gesturechange', preventGestureNavigation)
|
||||
rCanvas.current.addEventListener('gesturestart', preventGestureNavigation)
|
||||
rCanvas.current.addEventListener('touchstart', preventNavigation)
|
||||
|
||||
return () => {
|
||||
rCanvas.current.removeEventListener(
|
||||
'gestureend',
|
||||
preventGestureNavigation
|
||||
)
|
||||
rCanvas.current.removeEventListener(
|
||||
'gesturechange',
|
||||
preventGestureNavigation
|
||||
)
|
||||
rCanvas.current.removeEventListener(
|
||||
'gesturestart',
|
||||
preventGestureNavigation
|
||||
)
|
||||
rCanvas.current.removeEventListener('touchstart', preventNavigation)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import isMobile from 'ismobilejs'
|
||||
import { isMobile } from 'utils'
|
||||
import { useEffect } from 'react'
|
||||
import state from 'state'
|
||||
|
||||
|
@ -11,7 +11,7 @@ function handleFocusOut() {
|
|||
|
||||
export default function useSafariFocusOutFix(): void {
|
||||
useEffect(() => {
|
||||
if (isMobile().apple) {
|
||||
if (isMobile()) {
|
||||
document.addEventListener('focusout', handleFocusOut)
|
||||
|
||||
return () => {
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
37
state/commands/create-shapes.ts
Normal file
37
state/commands/create-shapes.ts
Normal 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
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
37
state/commands/delete-shapes.ts
Normal file
37
state/commands/delete-shapes.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -125,11 +125,13 @@ export default class DrawSession extends BaseSession {
|
|||
const page = tld.getPage(data)
|
||||
const shape = page.shapes[snapshot.id] as DrawShape
|
||||
|
||||
if (shape.points.length < this.points.length) {
|
||||
getShapeUtils(shape).setProperty(shape, 'points', this.points)
|
||||
if (vec.dist(this.points[0], this.points[this.points.length - 1]) < 8) {
|
||||
this.points.push(this.points[0])
|
||||
}
|
||||
|
||||
getShapeUtils(shape).onSessionComplete(shape)
|
||||
getShapeUtils(shape)
|
||||
.setProperty(shape, 'points', this.points)
|
||||
.onSessionComplete(shape)
|
||||
|
||||
tld.updateParents(data, [shape.id])
|
||||
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -59,17 +59,15 @@ const ellipse = registerShapeUtils<EllipseShape>({
|
|||
|
||||
return (
|
||||
<>
|
||||
{style.isFilled && (
|
||||
<ellipse
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
stroke="none"
|
||||
fill={styles.fill}
|
||||
pointerEvents="fill"
|
||||
/>
|
||||
)}
|
||||
<ellipse
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
stroke="none"
|
||||
fill={style.isFilled ? styles.fill : 'transparent'}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
|
@ -106,7 +104,7 @@ const ellipse = registerShapeUtils<EllipseShape>({
|
|||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
pointerEvents={style.isFilled ? 'all' : 'stroke'}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -40,26 +40,23 @@ const rectangle = registerShapeUtils<RectangleShape>({
|
|||
|
||||
return (
|
||||
<>
|
||||
{style.isFilled && (
|
||||
<rect
|
||||
rx={radius}
|
||||
ry={radius}
|
||||
x={+styles.strokeWidth / 2}
|
||||
y={+styles.strokeWidth / 2}
|
||||
width={Math.max(0, size[0] - strokeWidth)}
|
||||
height={Math.max(0, size[1] - strokeWidth)}
|
||||
strokeWidth={0}
|
||||
fill={styles.fill}
|
||||
stroke={styles.stroke}
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
rx={radius}
|
||||
ry={radius}
|
||||
x={+styles.strokeWidth / 2}
|
||||
y={+styles.strokeWidth / 2}
|
||||
width={Math.max(0, size[0] - strokeWidth)}
|
||||
height={Math.max(0, size[1] - strokeWidth)}
|
||||
fill={style.isFilled ? styles.fill : 'transparent'}
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d={pathData}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={styles.strokeWidth}
|
||||
filter={isHovered ? 'url(#expand)' : 'none'}
|
||||
pointerEvents={style.isFilled ? 'all' : 'stroke'}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
@ -110,7 +107,7 @@ const rectangle = registerShapeUtils<RectangleShape>({
|
|||
fill={styles.fill}
|
||||
stroke="transparent"
|
||||
strokeWidth={sw}
|
||||
pointerEvents={style.isFilled ? 'all' : 'stroke'}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<g filter={isHovered ? 'url(#expand)' : 'none'} pointerEvents="stroke">
|
||||
{paths}
|
||||
|
|
|
@ -93,8 +93,8 @@ const initialData: Data = {
|
|||
const draw = new Draw({
|
||||
points: [
|
||||
...Utils.getPointsBetween([0, 0], [20, 50]),
|
||||
...Utils.getPointsBetween([20, 50], [100, 20], 3),
|
||||
...Utils.getPointsBetween([100, 20], [100, 100], 10),
|
||||
...Utils.getPointsBetween([20, 50], [100, 20], { steps: 3 }),
|
||||
...Utils.getPointsBetween([100, 20], [100, 100], { steps: 10 }),
|
||||
[100, 100],
|
||||
],
|
||||
})
|
||||
|
@ -937,6 +937,9 @@ const state = createState({
|
|||
POINTED_CANVAS: {
|
||||
to: 'ellipse.editing',
|
||||
},
|
||||
POINTED_SHAPE: {
|
||||
to: 'ellipse.editing',
|
||||
},
|
||||
},
|
||||
},
|
||||
editing: {
|
||||
|
@ -1453,7 +1456,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 +1553,8 @@ const state = createState({
|
|||
data,
|
||||
shapeId,
|
||||
handleId,
|
||||
tld.screenToWorld(inputs.pointer.origin, data)
|
||||
tld.screenToWorld(inputs.pointer.origin, data),
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
|
@ -1667,7 +1671,8 @@ const state = createState({
|
|||
data,
|
||||
shapeId,
|
||||
handleId,
|
||||
tld.screenToWorld(inputs.pointer.origin, data)
|
||||
tld.screenToWorld(inputs.pointer.origin, data),
|
||||
true
|
||||
)
|
||||
)
|
||||
},
|
||||
|
@ -1778,7 +1783,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 +2255,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)
|
||||
|
||||
|
|
150
utils/tld.ts
150
utils/tld.ts
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue