Merge pull request #47 from tldraw/feature-back-to-content

Closed shape improvements, various
This commit is contained in:
Steve Ruiz 2021-07-13 20:41:40 +01:00 committed by GitHub
commit df2acdf884
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1170 additions and 3186 deletions

View file

@ -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
View 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

View file

@ -1,31 +1,142 @@
import { ShapeType } from 'types'
import TestState from '../test-utils' import TestState from '../test-utils'
describe('group command', () => { describe('group command', () => {
const tt = new TestState() const tt = new TestState()
tt.resetDocumentState() 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('deletes the group if it has only one child', () => {
it('does not change anything', () => { // tt.restore()
// TODO // .clickShape('rect1')
null // .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('creates a group', () => {
it('does command', () => { tt.restore()
// TODO .clickShape('rect1')
null .clickShape('rect2', { shiftKey: true })
}) .send('GROUPED')
it('un-does command', () => { const groupId = tt.getShape('rect1').parentId
// TODO
null
})
it('re-does command', () => { expect(groupId === tt.data.currentPageId).toBe(false)
// TODO })
null
}) 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', () => { it('groups shapes with different parents', () => {

View file

@ -1,19 +1,61 @@
import state from 'state' import { ArrowShape, ShapeType } from 'types'
import * as json from '../__mocks__/document.json' import TestState from '../test-utils'
state.reset()
state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
state.send('CLEARED_PAGE')
describe('arrow shape', () => { describe('arrow shape', () => {
it('creates shape', () => { const tt = new TestState()
// TODO tt.resetDocumentState().save()
null
})
it('cancels shape while creating', () => { describe('creating arrows', () => {
// TODO it('creates shape', () => {
null 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', () => { it('moves shape', () => {

View 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
})
})

View 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
})
})

View 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
})
})

View 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
})
})

View file

@ -106,7 +106,7 @@ class TestState {
*/ */
getSortedPageShapeIds(): string[] { getSortedPageShapeIds(): string[] {
return Object.values( 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) .sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id) .map((shape) => shape.id)
@ -257,6 +257,14 @@ class TestState {
const shape = tld.getShape(this.data, id) const shape = tld.getShape(this.data, id)
const [x, y] = shape ? vec.add(shape.point, [1, 1]) : [0, 0] 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( this.state.send(
'POINTED_SHAPE', 'POINTED_SHAPE',
inputs.pointerDown(TestState.point({ x, y, ...options }), id) inputs.pointerDown(TestState.point({ x, y, ...options }), id)

38
__tests__/tools.test.ts Normal file
View 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)
})
})
})
})

View file

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

View file

@ -33,18 +33,7 @@ export default function BoundsBg(): JSX.Element {
s.isInAny('selecting', 'selectPinching') s.isInAny('selecting', 'selectPinching')
) )
const rotation = useSelector((s) => { const rotation = useSelector((s) => s.values.selectedRotation)
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 isAllHandles = useSelector((s) => { const isAllHandles = useSelector((s) => {
const selectedIds = s.values.selectedIds const selectedIds = s.values.selectedIds

View file

@ -1,9 +0,0 @@
// This is the code library.
export default `
class Circle {
greet(): string {
return "Hello!"
}
}
`

View file

@ -1,8 +0,0 @@
export default `new Circle({
point: [200, 200],
})
new Rectangle({
point: [400, 300],
})
`

View file

@ -62,6 +62,8 @@ enum FontSize {
ExtraLarge = 'ExtraLarge', ExtraLarge = 'ExtraLarge',
} }
type Theme = 'dark' | 'light'
type ShapeStyles = { type ShapeStyles = {
color: ColorStyle color: ColorStyle
size: SizeStyle size: SizeStyle
@ -214,6 +216,16 @@ interface CodeResult {
error: CodeError error: CodeError
} }
interface ShapeTreeNode {
shape: Shape
children: ShapeTreeNode[]
isEditing: boolean
isHovered: boolean
isSelected: boolean
isDarkMode: boolean
isCurrentParent: boolean
}
/* -------------------------------------------------- */ /* -------------------------------------------------- */
/* Editor UI */ /* Editor UI */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
@ -545,8 +557,12 @@ interface ShapeUtility<K extends Shape> {
render( render(
this: ShapeUtility<K>, this: ShapeUtility<K>,
shape: K, shape: K,
info: { info?: {
isEditing: boolean isEditing?: boolean
isHovered?: boolean
isSelected?: boolean
isCurrentParent?: boolean
isDarkMode?: boolean
ref?: React.MutableRefObject<HTMLTextAreaElement> ref?: React.MutableRefObject<HTMLTextAreaElement>
} }
): JSX.Element ): JSX.Element
@ -636,6 +652,8 @@ enum FontSize {
ExtraLarge = 'ExtraLarge', ExtraLarge = 'ExtraLarge',
} }
type Theme = 'dark' | 'light'
type ShapeStyles = { type ShapeStyles = {
color: ColorStyle color: ColorStyle
size: SizeStyle size: SizeStyle
@ -788,6 +806,16 @@ interface CodeResult {
error: CodeError error: CodeError
} }
interface ShapeTreeNode {
shape: Shape
children: ShapeTreeNode[]
isEditing: boolean
isHovered: boolean
isSelected: boolean
isDarkMode: boolean
isCurrentParent: boolean
}
/* -------------------------------------------------- */ /* -------------------------------------------------- */
/* Editor UI */ /* Editor UI */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
@ -1119,8 +1147,12 @@ interface ShapeUtility<K extends Shape> {
render( render(
this: ShapeUtility<K>, this: ShapeUtility<K>,
shape: K, shape: K,
info: { info?: {
isEditing: boolean isEditing?: boolean
isHovered?: boolean
isSelected?: boolean
isCurrentParent?: boolean
isDarkMode?: boolean
ref?: React.MutableRefObject<HTMLTextAreaElement> ref?: React.MutableRefObject<HTMLTextAreaElement>
} }
): JSX.Element ): JSX.Element
@ -1840,6 +1872,14 @@ type RequiredKeys<T> = {
return Array.from(new Set(items).values()) 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. * Get the outer of between a circle and a point.
* @param C The circle's center. * @param C The circle's center.

View file

@ -9,10 +9,13 @@ export default function StatusBar(): JSX.Element {
const shapesInView = state.values.shapesToRender.length const shapesInView = state.values.shapesToRender.length
const active = local.active.slice(1).map((s) => { const active = local.active
const states = s.split('.') .slice(1)
return states[states.length - 1] .map((s) => {
}) const states = s.split('.')
return states[states.length - 1]
})
.join(' | ')
const log = local.log[0] const log = local.log[0]
@ -21,7 +24,7 @@ export default function StatusBar(): JSX.Element {
return ( return (
<StatusBarContainer size={size}> <StatusBarContainer size={size}>
<Section> <Section>
{active.join(' | ')} - {log} {active} - {log}
</Section> </Section>
<Section>{shapesInView || '0'} Shapes</Section> <Section>{shapesInView || '0'} Shapes</Section>
</StatusBarContainer> </StatusBarContainer>

View file

@ -21,7 +21,7 @@ export default function IsFilledPicker(): JSX.Element {
return ( return (
selectedShapes.length === 0 || selectedShapes.length === 0 ||
selectedShapes.every((shape) => getShapeUtils(shape).canStyleFill) selectedShapes.some((shape) => getShapeUtils(shape).canStyleFill)
) )
}) })

View file

@ -135,7 +135,7 @@ export function PrimaryButton({
children, children,
}: PrimaryToolButtonProps): JSX.Element { }: PrimaryToolButtonProps): JSX.Element {
return ( return (
<Tooltip label={label}> <Tooltip label={label[0].toUpperCase() + label.slice(1)}>
<PrimaryToolButton <PrimaryToolButton
name={label} name={label}
bp={{ bp={{

View file

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { MutableRefObject, useCallback } from 'react' import { MutableRefObject, useCallback, useEffect } from 'react'
import state from 'state' import state from 'state'
import { import {
fastBrushSelect, 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 { return {
onPointerDown: handlePointerDown, onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove, onPointerMove: handlePointerMove,

View file

@ -1,4 +1,4 @@
import isMobile from 'ismobilejs' import { isMobile } from 'utils'
import { useEffect } from 'react' import { useEffect } from 'react'
import state from 'state' import state from 'state'
@ -11,7 +11,7 @@ function handleFocusOut() {
export default function useSafariFocusOutFix(): void { export default function useSafariFocusOutFix(): void {
useEffect(() => { useEffect(() => {
if (isMobile().apple) { if (isMobile()) {
document.addEventListener('focusout', handleFocusOut) document.addEventListener('focusout', handleFocusOut)
return () => { return () => {

View file

@ -1,7 +1,9 @@
module.exports = { module.exports = {
testEnvironment: 'jsdom', testEnvironment: 'jsdom',
testPathIgnorePatterns: ['node_modules', '.next'], 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: { transform: {
'^.+\\.(ts|tsx|mjs)$': 'babel-jest', '^.+\\.(ts|tsx|mjs)$': 'babel-jest',
}, },

View file

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

View file

@ -55,13 +55,10 @@ export class BaseCommand<T extends any> {
redo = (data: T, initial = false): void => { redo = (data: T, initial = false): void => {
if (this.manualSelection) { if (this.manualSelection) {
this.doFn(data, initial) this.doFn(data, initial)
return return
} }
if (initial) { if (!initial) {
this.restoreBeforeSelectionState = this.saveSelectionState(data)
} else {
this.restoreBeforeSelectionState(data) 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 changePage from './change-page'
import createPage from './create-page' import createPage from './create-page'
import deletePage from './delete-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 distribute from './distribute'
import doublePointHandle from './double-point-handle' import doublePointHandle from './double-point-handle'
import draw from './draw' import draw from './draw'
@ -30,8 +31,9 @@ const commands = {
align, align,
changePage, changePage,
createPage, createPage,
createShapes,
deletePage, deletePage,
deleteSelected, deleteShapes,
distribute, distribute,
doublePointHandle, doublePointHandle,
draw, draw,

View file

@ -125,11 +125,13 @@ export default class DrawSession extends BaseSession {
const page = tld.getPage(data) const page = tld.getPage(data)
const shape = page.shapes[snapshot.id] as DrawShape const shape = page.shapes[snapshot.id] as DrawShape
if (shape.points.length < this.points.length) { if (vec.dist(this.points[0], this.points[this.points.length - 1]) < 8) {
getShapeUtils(shape).setProperty(shape, 'points', this.points) this.points.push(this.points[0])
} }
getShapeUtils(shape).onSessionComplete(shape) getShapeUtils(shape)
.setProperty(shape, 'points', this.points)
.onSessionComplete(shape)
tld.updateParents(data, [shape.id]) tld.updateParents(data, [shape.id])

View file

@ -12,12 +12,20 @@ export default class HandleSession extends BaseSession {
shiftKey: boolean shiftKey: boolean
initialShape: Shape initialShape: Shape
handleId: string 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) super(data)
this.origin = point this.origin = point
this.handleId = handleId this.handleId = handleId
this.initialShape = deepClone(tld.getShape(data, shapeId)) this.initialShape = deepClone(tld.getShape(data, shapeId))
this.isCreating = isCreating
} }
update( update(
@ -48,13 +56,21 @@ export default class HandleSession extends BaseSession {
} }
cancel(data: Data): void { 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 { complete(data: Data): void {
const before = this.initialShape const before = this.initialShape
const after = deepClone(tld.getShape(data, before.id)) 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

@ -59,17 +59,15 @@ const ellipse = registerShapeUtils<EllipseShape>({
return ( return (
<> <>
{style.isFilled && ( <ellipse
<ellipse cx={radiusX}
cx={radiusX} cy={radiusY}
cy={radiusY} rx={rx}
rx={rx} ry={ry}
ry={ry} stroke="none"
stroke="none" fill={style.isFilled ? styles.fill : 'transparent'}
fill={styles.fill} pointerEvents="all"
pointerEvents="fill" />
/>
)}
<path <path
d={path} d={path}
fill={styles.stroke} fill={styles.stroke}
@ -106,7 +104,7 @@ const ellipse = registerShapeUtils<EllipseShape>({
strokeWidth={sw} strokeWidth={sw}
strokeDasharray={strokeDasharray} strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset} strokeDashoffset={strokeDashoffset}
pointerEvents={style.isFilled ? 'all' : 'stroke'} pointerEvents="all"
/> />
) )
}, },

View file

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

View file

@ -40,26 +40,23 @@ const rectangle = registerShapeUtils<RectangleShape>({
return ( return (
<> <>
{style.isFilled && ( <rect
<rect rx={radius}
rx={radius} ry={radius}
ry={radius} x={+styles.strokeWidth / 2}
x={+styles.strokeWidth / 2} y={+styles.strokeWidth / 2}
y={+styles.strokeWidth / 2} width={Math.max(0, size[0] - strokeWidth)}
width={Math.max(0, size[0] - strokeWidth)} height={Math.max(0, size[1] - strokeWidth)}
height={Math.max(0, size[1] - strokeWidth)} fill={style.isFilled ? styles.fill : 'transparent'}
strokeWidth={0} stroke="none"
fill={styles.fill} />
stroke={styles.stroke}
/>
)}
<path <path
d={pathData} d={pathData}
fill={styles.stroke} fill={styles.stroke}
stroke={styles.stroke} stroke={styles.stroke}
strokeWidth={styles.strokeWidth} strokeWidth={styles.strokeWidth}
filter={isHovered ? 'url(#expand)' : 'none'} filter={isHovered ? 'url(#expand)' : 'none'}
pointerEvents={style.isFilled ? 'all' : 'stroke'} pointerEvents="all"
/> />
</> </>
) )
@ -110,7 +107,7 @@ const rectangle = registerShapeUtils<RectangleShape>({
fill={styles.fill} fill={styles.fill}
stroke="transparent" stroke="transparent"
strokeWidth={sw} strokeWidth={sw}
pointerEvents={style.isFilled ? 'all' : 'stroke'} pointerEvents="all"
/> />
<g filter={isHovered ? 'url(#expand)' : 'none'} pointerEvents="stroke"> <g filter={isHovered ? 'url(#expand)' : 'none'} pointerEvents="stroke">
{paths} {paths}

View file

@ -93,8 +93,8 @@ const initialData: Data = {
const draw = new Draw({ const draw = new Draw({
points: [ points: [
...Utils.getPointsBetween([0, 0], [20, 50]), ...Utils.getPointsBetween([0, 0], [20, 50]),
...Utils.getPointsBetween([20, 50], [100, 20], 3), ...Utils.getPointsBetween([20, 50], [100, 20], { steps: 3 }),
...Utils.getPointsBetween([100, 20], [100, 100], 10), ...Utils.getPointsBetween([100, 20], [100, 100], { steps: 10 }),
[100, 100], [100, 100],
], ],
}) })
@ -937,6 +937,9 @@ const state = createState({
POINTED_CANVAS: { POINTED_CANVAS: {
to: 'ellipse.editing', to: 'ellipse.editing',
}, },
POINTED_SHAPE: {
to: 'ellipse.editing',
},
}, },
}, },
editing: { editing: {
@ -1453,7 +1456,7 @@ const state = createState({
breakSession(data) { breakSession(data) {
session.cancel(data) session.cancel(data)
history.disable() history.disable()
commands.deleteSelected(data) commands.deleteShapes(data, tld.getSelectedShapes(data))
history.enable() history.enable()
}, },
cancelSession(data) { cancelSession(data) {
@ -1550,7 +1553,8 @@ const state = createState({
data, data,
shapeId, shapeId,
handleId, handleId,
tld.screenToWorld(inputs.pointer.origin, data) tld.screenToWorld(inputs.pointer.origin, data),
false
) )
) )
}, },
@ -1667,7 +1671,8 @@ const state = createState({
data, data,
shapeId, shapeId,
handleId, 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') commands.toggle(data, 'isAspectRatioLocked')
}, },
deleteSelection(data) { deleteSelection(data) {
commands.deleteSelected(data) commands.deleteShapes(data, tld.getSelectedShapes(data))
}, },
rotateSelectionCcw(data) { rotateSelectionCcw(data) {
commands.rotateCcw(data) commands.rotateCcw(data)
@ -2250,7 +2255,18 @@ const state = createState({
return commonStyle 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) { shapesToRender(data) {
const viewport = tld.getViewport(data) const viewport = tld.getViewport(data)

View file

@ -15,6 +15,7 @@ import {
ShapeTreeNode, ShapeTreeNode,
} from 'types' } from 'types'
import { AssertionError } from 'assert' import { AssertionError } from 'assert'
import { lerp } from './utils'
export default class StateUtils { export default class StateUtils {
static getCameraZoom(zoom: number): number { static getCameraZoom(zoom: number): number {
@ -93,6 +94,155 @@ export default class StateUtils {
return Object.values(page.shapes) 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. * Get the current selected shapes as an array.
* @param data * @param data

3056
yarn.lock

File diff suppressed because it is too large Load diff