Fix bug on missing others, adds new tests

This commit is contained in:
Steve Ruiz 2021-07-01 15:03:02 +01:00
parent 50d4517d0d
commit 8ee78d1b90
59 changed files with 2745 additions and 1450 deletions

34
.vscode/snippets.code-snippets vendored Normal file
View file

@ -0,0 +1,34 @@
{
// Place your tldraw workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
// Placeholders with the same ids are connected.
// Example:
// "Print to console": {
// "scope": "javascript,typescript",
// "prefix": "log",
// "body": [
// "console.log('$1');",
// "$2"
// ],
// "description": "Log output to console"
// }
"createComment": {
"scope": "typescript",
"prefix": "/**",
"body": [
"/**",
" * ${1:description}",
" *",
" * ### Example",
" *",
" *```ts",
" * ${2:example}",
" *```",
" */"
],
"description": "comment"
}
}

View file

@ -116,3 +116,120 @@ Object {
},
}
`;
exports[`restoring project remounts the state after mutating the current state: data after re-mount from file 1`] = `
Object {
"code": Object {
"file0": Object {
"code": "",
"id": "file0",
"name": "index.ts",
},
},
"id": "0001",
"name": "My Document",
"pages": Object {
"page1": Object {
"childIndex": 0,
"id": "page1",
"name": "Page 1",
"shapes": Object {
"1f6c251c-e12e-40b4-8dd2-c1847d80b72f": Object {
"childIndex": 24,
"id": "1f6c251c-e12e-40b4-8dd2-c1847d80b72f",
"isAspectRatioLocked": false,
"isGenerated": false,
"isHidden": false,
"isLocked": false,
"name": "Rectangle",
"parentId": "page1",
"point": Array [
0,
0,
],
"radius": 2,
"rotation": 0,
"seed": 0.6440313303074272,
"size": Array [
67.22075383450237,
72.92795609221832,
],
"style": Object {
"color": "Black",
"dash": "Solid",
"isFilled": false,
"size": "Small",
},
"type": "rectangle",
},
"5ca167d7-54de-47c9-aa8f-86affa25e44d": Object {
"bend": 0,
"childIndex": 16,
"decorations": Object {
"end": null,
"middle": null,
"start": null,
},
"handles": Object {
"bend": Object {
"id": "bend",
"index": 2,
"point": Array [
3.2518097616315345,
140.54510317291172,
],
},
"end": Object {
"id": "end",
"index": 1,
"point": Array [
0,
0,
],
},
"start": Object {
"id": "start",
"index": 0,
"point": Array [
6.503619523263069,
281.09020634582345,
],
},
},
"id": "5ca167d7-54de-47c9-aa8f-86affa25e44d",
"isAspectRatioLocked": false,
"isGenerated": false,
"isHidden": false,
"isLocked": false,
"name": "Arrow",
"parentId": "page1",
"point": Array [
100,
100,
],
"points": Array [
Array [
6.503619523263069,
281.09020634582345,
],
Array [
0,
0,
],
],
"rotation": 0,
"seed": 0.08116783083496548,
"style": Object {
"color": "Black",
"dash": "Solid",
"isFilled": false,
"size": "Small",
},
"type": "arrow",
},
},
"type": "page",
},
},
}
`;

72
__tests__/bounds.test.ts Normal file
View file

@ -0,0 +1,72 @@
import { getShapeUtils } from 'state/shape-utils'
import { getCommonBounds } from 'utils'
import TestState, { arrowId, rectangleId } from './test-utils'
describe('selection', () => {
const tt = new TestState()
it('measures correct bounds for selected item', () => {
// Note: Each item should test its own bounds in its ./shapes/[shape].tsx file
const shape = tt.getShape(rectangleId)
tt.deselectAll().clickShape(rectangleId)
expect(tt.state.values.selectedBounds).toStrictEqual(
getShapeUtils(shape).getBounds(shape)
)
})
it('measures correct bounds for rotated selected item', () => {
const shape = tt.getShape(rectangleId)
getShapeUtils(shape).rotateBy(shape, Math.PI * 2 * Math.random())
tt.deselectAll().clickShape(rectangleId)
expect(tt.state.values.selectedBounds).toStrictEqual(
getShapeUtils(shape).getBounds(shape)
)
getShapeUtils(shape).rotateBy(shape, -Math.PI * 2 * Math.random())
expect(tt.state.values.selectedBounds).toStrictEqual(
getShapeUtils(shape).getBounds(shape)
)
})
it('measures correct bounds for selected items', () => {
const shape1 = tt.getShape(rectangleId)
const shape2 = tt.getShape(arrowId)
tt.deselectAll()
.clickShape(shape1.id)
.clickShape(shape2.id, { shiftKey: true })
expect(tt.state.values.selectedBounds).toStrictEqual(
getCommonBounds(
getShapeUtils(shape1).getRotatedBounds(shape1),
getShapeUtils(shape2).getRotatedBounds(shape2)
)
)
})
it('measures correct bounds for rotated selected items', () => {
const shape1 = tt.getShape(rectangleId)
const shape2 = tt.getShape(arrowId)
getShapeUtils(shape1).rotateBy(shape1, Math.PI * 2 * Math.random())
getShapeUtils(shape2).rotateBy(shape2, Math.PI * 2 * Math.random())
tt.deselectAll()
.clickShape(shape1.id)
.clickShape(shape2.id, { shiftKey: true })
expect(tt.state.values.selectedBounds).toStrictEqual(
getCommonBounds(
getShapeUtils(shape1).getRotatedBounds(shape1),
getShapeUtils(shape2).getRotatedBounds(shape2)
)
)
})
})

300
__tests__/children.test.ts Normal file
View file

@ -0,0 +1,300 @@
import { MoveType, ShapeType } from 'types'
import TestState from './test-utils'
describe('shapes with children', () => {
const tt = new TestState()
tt.resetDocumentState()
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 1,
},
'delete-me-bottom'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 2,
},
'1'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [300, 0],
size: [100, 100],
childIndex: 3,
},
'2'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 300],
size: [100, 100],
childIndex: 4,
},
'delete-me-middle'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 300],
size: [100, 100],
childIndex: 5,
},
'3'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [300, 300],
size: [100, 100],
childIndex: 6,
},
'4'
)
// Delete shapes at the start and in the middle of the list
tt.clickShape('delete-me-bottom')
.send('DELETED')
.clickShape('delete-me-middle')
.send('DELETED')
it('has shapes in order', () => {
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.childIndex)
).toStrictEqual([2, 3, 5, 6])
})
it('moves a shape to back', () => {
tt.clickShape('3').send('MOVED', {
type: MoveType.ToBack,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['3', '1', '2', '4'])
})
it('moves two adjacent siblings to back', () => {
tt.clickShape('4').clickShape('2', { shiftKey: true }).send('MOVED', {
type: MoveType.ToBack,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['2', '4', '3', '1'])
})
it('moves two non-adjacent siblings to back', () => {
tt.clickShape('4').clickShape('1', { shiftKey: true }).send('MOVED', {
type: MoveType.ToBack,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['4', '1', '2', '3'])
})
it('moves a shape backward', () => {
tt.clickShape('3').send('MOVED', {
type: MoveType.Backward,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['4', '1', '3', '2'])
})
it('moves a shape at first index backward', () => {
tt.clickShape('4').send('MOVED', {
type: MoveType.Backward,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['4', '1', '3', '2'])
})
it('moves two adjacent siblings backward', () => {
tt.clickShape('3').clickShape('2', { shiftKey: true }).send('MOVED', {
type: MoveType.Backward,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['4', '3', '2', '1'])
})
it('moves two non-adjacent siblings backward', () => {
tt.clickShape('3').clickShape('1', { shiftKey: true }).send('MOVED', {
type: MoveType.Backward,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['3', '4', '1', '2'])
})
it('moves two adjacent siblings backward at zero index', () => {
tt.clickShape('3').clickShape('4', { shiftKey: true }).send('MOVED', {
type: MoveType.Backward,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['3', '4', '1', '2'])
})
it('moves a shape forward', () => {
tt.clickShape('4').send('MOVED', {
type: MoveType.Forward,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['3', '1', '4', '2'])
})
it('moves a shape forward at the top index', () => {
tt.clickShape('2').send('MOVED', {
type: MoveType.Forward,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['3', '1', '4', '2'])
})
it('moves two adjacent siblings forward', () => {
tt.deselectAll()
.clickShape('4')
.clickShape('1', { shiftKey: true })
.send('MOVED', {
type: MoveType.Forward,
})
expect(tt.idsAreSelected(['1', '4'])).toBe(true)
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['3', '2', '1', '4'])
})
it('moves two non-adjacent siblings forward', () => {
tt.deselectAll()
.clickShape('3')
.clickShape('1', { shiftKey: true })
.send('MOVED', {
type: MoveType.Forward,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['2', '3', '4', '1'])
})
it('moves two adjacent siblings forward at top index', () => {
tt.deselectAll()
.clickShape('3')
.clickShape('1', { shiftKey: true })
.send('MOVED', {
type: MoveType.Forward,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['2', '4', '3', '1'])
})
it('moves a shape to front', () => {
tt.deselectAll().clickShape('2').send('MOVED', {
type: MoveType.ToFront,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['4', '3', '1', '2'])
})
it('moves two adjacent siblings to front', () => {
tt.deselectAll()
.clickShape('3')
.clickShape('1', { shiftKey: true })
.send('MOVED', {
type: MoveType.ToFront,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['4', '2', '3', '1'])
})
it('moves two non-adjacent siblings to front', () => {
tt.deselectAll()
.clickShape('4')
.clickShape('3', { shiftKey: true })
.send('MOVED', {
type: MoveType.ToFront,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['2', '1', '4', '3'])
})
it('moves siblings already at front to front', () => {
tt.deselectAll()
.clickShape('4')
.clickShape('3', { shiftKey: true })
.send('MOVED', {
type: MoveType.ToFront,
})
expect(
Object.values(tt.data.document.pages[tt.data.currentParentId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
).toStrictEqual(['2', '1', '4', '3'])
})
})

46
__tests__/coop.test.ts Normal file
View file

@ -0,0 +1,46 @@
import state from 'state'
import coopState from 'state/coop/coop-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')
coopState.reset()
describe('coop', () => {
it('joins a room', () => {
// TODO
null
})
it('leaves a room', () => {
// TODO
null
})
it('rejoins a room', () => {
// TODO
null
})
it('handles another user joining room', () => {
// TODO
null
})
it('handles another user leaving room', () => {
// TODO
null
})
it('sends mouse movements', () => {
// TODO
null
})
it('receives mouse movements', () => {
// TODO
null
})
})

View file

@ -7,18 +7,22 @@ state.send('CLEARED_PAGE')
describe('arrow shape', () => {
it('creates a shape', () => {
// TODO
null
})
it('cancels shape while creating', () => {
// TODO
null
})
it('removes shape on undo and restores it on redo', () => {
// TODO
null
})
it('does not create shape when readonly', () => {
// TODO
null
})
})

View file

@ -1,4 +1,4 @@
import { getPerfectDashProps } from 'utils/dashes'
import { getPerfectDashProps } from 'utils'
describe('ellipse dash props', () => {
it('renders dashed props on a circle correctly', () => {

View file

@ -1,157 +1,120 @@
import state from 'state'
import inputs from 'state/inputs'
import { ShapeType } from 'types'
import {
idsAreSelected,
point,
rectangleId,
arrowId,
getOnlySelectedShape,
assertShapeProps,
} from './test-utils'
import tld from 'utils/tld'
import * as json from './__mocks__/document.json'
import TestState, { rectangleId, arrowId } from './test-utils'
describe('deleting single shapes', () => {
state.reset()
state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
const tt = new TestState()
it('deletes a shape and undoes the delete', () => {
state
.send('CANCELED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
describe('deleting single shapes', () => {
it('deletes a shape and undoes the delete', () => {
tt.deselectAll().clickShape(rectangleId).pressDelete()
expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
expect(tt.idsAreSelected([])).toBe(true)
state.send('DELETED')
expect(tt.getShape(rectangleId)).toBe(undefined)
expect(idsAreSelected(state.data, [])).toBe(true)
expect(tld.getShape(state.data, rectangleId)).toBe(undefined)
tt.undo()
state.send('UNDO')
expect(tt.getShape(rectangleId)).toBeTruthy()
expect(tt.idsAreSelected([rectangleId])).toBe(true)
expect(tld.getShape(state.data, rectangleId)).toBeTruthy()
expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
tt.redo()
state.send('REDO')
expect(tld.getShape(state.data, rectangleId)).toBe(undefined)
state.send('UNDO')
})
})
describe('deletes and restores grouped shapes', () => {
state.reset()
state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
it('creates a group', () => {
state
.send('CANCELED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), arrowId)
)
expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true)
state.send('GROUPED')
const group = getOnlySelectedShape(state.data)
// Should select the group
expect(assertShapeProps(group, { type: ShapeType.Group }))
const arrow = tld.getShape(state.data, arrowId)
// The arrow should be have the group as its parent
expect(assertShapeProps(arrow, { parentId: group.id }))
expect(tt.getShape(rectangleId)).toBe(undefined)
})
})
// it('selects the new group', () => {
// expect(idsAreSelected(state.data, [groupId])).toBe(true)
// })
describe('deleting and restoring grouped shapes', () => {
it('creates a group', () => {
tt.reset()
.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
.send('GROUPED')
// it('assigns a new parent', () => {
// expect(groupId === state.data.currentPageId).toBe(false)
// })
const group = tt.getOnlySelectedShape()
// // Rectangle has the same new parent?
// it('assigns new parent to all selected shapes', () => {
// expect(hasParent(state.data, arrowId, groupId)).toBe(true)
// })
// Should select the group
expect(tt.assertShapeProps(group, { type: ShapeType.Group })).toBe(true)
// // New parent is selected?
// it('selects the new parent', () => {
// expect(idsAreSelected(state.data, [groupId])).toBe(true)
// })
const arrow = tt.getShape(arrowId)
// The arrow should be have the group as its parent
expect(tt.assertShapeProps(arrow, { parentId: group.id })).toBe(true)
})
it('selects the new group', () => {
const groupId = tt.getShape(arrowId).parentId
expect(tt.idsAreSelected([groupId])).toBe(true)
})
it('assigns a new parent', () => {
const groupId = tt.getShape(arrowId).parentId
expect(groupId === tt.data.currentPageId).toBe(false)
})
// Rectangle has the same new parent?
it('assigns new parent to all selected shapes', () => {
const groupId = tt.getShape(arrowId).parentId
expect(tt.hasParent(arrowId, groupId)).toBe(true)
})
})
describe('selecting within the group', () => {
it('selects the group when pointing a shape', () => {
const groupId = tt.getShape(arrowId).parentId
tt.deselectAll().clickShape(rectangleId)
expect(tt.idsAreSelected([groupId])).toBe(true)
})
it('keeps selection when pointing group shape', () => {
const groupId = tt.getShape(arrowId).parentId
tt.deselectAll().clickShape(groupId)
expect(tt.idsAreSelected([groupId])).toBe(true)
})
it('selects a grouped shape by double-pointing', () => {
tt.deselectAll().doubleClickShape(rectangleId)
expect(tt.idsAreSelected([rectangleId])).toBe(true)
})
it('selects a sibling on point after double-pointing into a grouped shape children', () => {
tt.deselectAll().doubleClickShape(rectangleId).clickShape(arrowId)
expect(tt.idsAreSelected([arrowId])).toBe(true)
})
it('rises up a selection level when escape is pressed', () => {
const groupId = tt.getShape(arrowId).parentId
tt.deselectAll().doubleClickShape(rectangleId).send('CANCELLED')
tt.clickShape(rectangleId)
expect(tt.idsAreSelected([groupId])).toBe(true)
})
// it('deletes and restores one shape', () => {
// // Delete the rectangle first
// state.send('UNDO')
// expect(tld.getShape(tt.data, rectangleId)).toBeTruthy()
// expect(tt.idsAreSelected([rectangleId])).toBe(true)
// state.send('REDO')
// expect(tld.getShape(tt.data, rectangleId)).toBe(undefined)
// state.send('UNDO')
// expect(tld.getShape(tt.data, rectangleId)).toBeTruthy()
// expect(tt.idsAreSelected([rectangleId])).toBe(true)
})
})
// // it('selects the group when pointing a shape', () => {
// // state
// // .send('CANCELED')
// // .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
// // .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
// // expect(idsAreSelected(state.data, [groupId])).toBe(true)
// // })
// // it('keeps selection when pointing bounds', () => {
// // state
// // .send('CANCELED')
// // .send('POINTED_BOUNDS', inputs.pointerDown(point(), 'bounds'))
// // .send('STOPPED_POINTING', inputs.pointerUp(point(), 'bounds'))
// // expect(idsAreSelected(state.data, [groupId])).toBe(true)
// // })
// // it('selects a grouped shape by double-pointing', () => {
// // state
// // .send('CANCELED')
// // .send('DOUBLE_POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
// // .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
// // expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
// // })
// // it('selects a sibling on point when selecting a grouped shape', () => {
// // state
// // .send('POINTED_SHAPE', inputs.pointerDown(point(), arrowId))
// // .send('STOPPED_POINTING', inputs.pointerUp(point(), arrowId))
// // expect(idsAreSelected(state.data, [arrowId])).toBe(true)
// // })
// // it('rises up a selection level when escape is pressed', () => {
// // state
// // .send('CANCELED')
// // .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
// // .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
// // expect(idsAreSelected(state.data, [groupId])).toBe(true)
// // })
// // it('deletes and restores one shape', () => {
// // // Delete the rectangle first
// // state.send('UNDO')
// // expect(tld.getShape(state.data, rectangleId)).toBeTruthy()
// // expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
// // state.send('REDO')
// // expect(tld.getShape(state.data, rectangleId)).toBe(undefined)
// // state.send('UNDO')
// // expect(tld.getShape(state.data, rectangleId)).toBeTruthy()
// // expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
// // })
// })

61
__tests__/locked.test.ts Normal file
View file

@ -0,0 +1,61 @@
import TestState from './test-utils'
describe('locked shapes', () => {
const tt = new TestState()
tt.resetDocumentState()
it('toggles a locked shape', () => {
// TODO
null
})
it('selects a locked shape', () => {
// TODO
null
})
it('does not translate a locked shape', () => {
// TODO
null
})
it('does not translate a locked shape in a group', () => {
// TODO
null
})
it('does not rotate a locked shape', () => {
// TODO
null
})
it('does not rotate a locked shape in a group', () => {
// TODO
null
})
it('dpes not transform a locked single shape', () => {
// TODO
null
})
it('does not transform a locked shape in a multiple selection', () => {
// TODO
null
})
it('does not transform a locked shape in a group', () => {
// TODO
null
})
it('does not change the style of a locked shape', () => {
// TODO
null
})
it('does not change the handles of a locked shape', () => {
// TODO
null
})
})

View file

@ -7,12 +7,36 @@ describe('project', () => {
it('mounts the state', () => {
state.send('MOUNTED')
expect(state.isIn('ready')).toBe(true)
})
it('loads file from json', () => {
state.send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
expect(state.isIn('ready')).toBe(true)
expect(state.data.document).toMatchSnapshot('data after mount from file')
})
})
describe('restoring project', () => {
state.reset()
state.enableLog(true)
it('remounts the state after mutating the current state', () => {
state
.send('MOUNTED')
.send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
.send('CLEARED_PAGE')
expect(
state.data.document.pages[state.data.currentPageId].shapes
).toStrictEqual({})
state
.send('MOUNTED')
.send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
expect(state.data.document).toMatchSnapshot('data after re-mount from file')
})
})

View file

@ -1,139 +1,81 @@
import state from 'state'
import inputs from 'state/inputs'
import { idsAreSelected, point, rectangleId, arrowId } from './test-utils'
import * as json from './__mocks__/document.json'
// Mount the state and load the test file from json
state.reset()
state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
import TestState, { rectangleId, arrowId } from './test-utils'
describe('selection', () => {
it('selects a shape', () => {
state
.send('CANCELED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
const tt = new TestState()
expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
it('selects a shape', () => {
tt.deselectAll().clickShape(rectangleId)
expect(tt.idsAreSelected([rectangleId])).toBe(true)
})
it('selects and deselects a shape', () => {
state
.send('CANCELED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
tt.deselectAll().clickShape(rectangleId).clickCanvas()
expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
state
.send('POINTED_CANVAS', inputs.pointerDown(point(), 'canvas'))
.send('STOPPED_POINTING', inputs.pointerUp(point(), 'canvas'))
expect(idsAreSelected(state.data, [])).toBe(true)
expect(tt.idsAreSelected([])).toBe(true)
})
it('selects multiple shapes', () => {
expect(idsAreSelected(state.data, [])).toBe(true)
tt.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
state
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), arrowId)
)
expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true)
expect(tt.idsAreSelected([rectangleId, arrowId])).toBe(true)
})
it('shift-selects to deselect shapes', () => {
state
.send('CANCELLED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), arrowId)
)
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), rectangleId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), rectangleId)
)
tt.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
.clickShape(rectangleId, { shiftKey: true })
expect(idsAreSelected(state.data, [arrowId])).toBe(true)
expect(tt.idsAreSelected([arrowId])).toBe(true)
})
it('single-selects shape in selection on pointerup', () => {
state
.send('CANCELLED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), arrowId)
)
it('single-selects shape in selection on click', () => {
tt.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
.clickShape(arrowId)
expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true)
expect(tt.idsAreSelected([arrowId])).toBe(true)
})
state.send('POINTED_SHAPE', inputs.pointerDown(point(), arrowId))
it('single-selects shape in selection on pointerup only', () => {
tt.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true)
expect(tt.idsAreSelected([rectangleId, arrowId])).toBe(true)
state.send('STOPPED_POINTING', inputs.pointerUp(point(), arrowId))
tt.startClick(arrowId)
expect(idsAreSelected(state.data, [arrowId])).toBe(true)
expect(tt.idsAreSelected([rectangleId, arrowId])).toBe(true)
tt.stopClick(arrowId)
expect(tt.idsAreSelected([arrowId])).toBe(true)
})
it('selects shapes if shift key is lifted before pointerup', () => {
state
.send('CANCELLED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), arrowId)
)
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send('STOPPED_POINTING', inputs.pointerUp(point(), arrowId))
tt.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
.startClick(rectangleId, { shiftKey: true })
.stopClick(rectangleId)
expect(idsAreSelected(state.data, [arrowId])).toBe(true)
expect(tt.idsAreSelected([rectangleId])).toBe(true)
})
it('does not select on meta-click', () => {
state
.send('CANCELLED')
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ ctrlKey: true }), rectangleId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ ctrlKey: true }), rectangleId)
)
tt.deselectAll().clickShape(rectangleId, { ctrlKey: true })
expect(idsAreSelected(state.data, [])).toBe(true)
expect(tt.idsAreSelected([])).toBe(true)
})
it('does not select on meta-shift-click', () => {
tt.deselectAll().clickShape(rectangleId, { ctrlKey: true, shiftKey: true })
expect(tt.idsAreSelected([])).toBe(true)
})
})

View file

@ -7,62 +7,74 @@ state.send('CLEARED_PAGE')
describe('arrow shape', () => {
it('creates shape', () => {
// TODO
null
})
it('cancels shape while creating', () => {
// TODO
null
})
it('moves shape', () => {
// TODO
null
})
it('rotates shape', () => {
// TODO
null
})
it('measures bounds', () => {
it('rotates shape in a group', () => {
// TODO
null
})
it('measures rotated bounds', () => {
it('measures shape bounds', () => {
// TODO
null
})
it('transforms single', () => {
it('measures shape rotated bounds', () => {
// TODO
null
})
it('transforms single shape', () => {
// TODO
null
})
it('transforms in a group', () => {
// TODO
null
})
/* -------------------- Specific -------------------- */
it('creates compass-aligned shape with shift key', () => {
// TODO
null
})
it('changes start handle', () => {
// TODO
null
})
it('changes end handle', () => {
// TODO
null
})
it('changes bend handle', () => {
// TODO
null
})
it('resets bend handle when double-pointed', () => {
null
})
/* -------------------- Readonly -------------------- */
it('does not create shape when readonly', () => {
// TODO
null
})
})

28
__tests__/style.test.ts Normal file
View file

@ -0,0 +1,28 @@
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')
describe('shape styles', () => {
it('sets the color style of a shape', () => {
// TODO
null
})
it('sets the size style of a shape', () => {
// TODO
null
})
it('sets the dash style of a shape', () => {
// TODO
null
})
it('sets the isFilled style of a shape', () => {
// TODO
null
})
})

View file

@ -1,11 +1,18 @@
import { Data, Shape, ShapeType } from 'types'
import _state from 'state'
import tld from 'utils/tld'
import inputs from 'state/inputs'
import { createShape, getShapeUtils } from 'state/shape-utils'
import { Data, Shape, ShapeType, ShapeUtility } from 'types'
import { deepCompareArrays, uniqueId, vec } from 'utils'
import * as json from './__mocks__/document.json'
type State = typeof _state
export const rectangleId = '1f6c251c-e12e-40b4-8dd2-c1847d80b72f'
export const arrowId = '5ca167d7-54de-47c9-aa8f-86affa25e44d'
interface PointerOptions {
id?: string
id?: number
x?: number
y?: number
shiftKey?: boolean
@ -13,77 +20,573 @@ interface PointerOptions {
ctrlKey?: boolean
}
export function point(
options: PointerOptions = {} as PointerOptions
): PointerEvent {
const {
id = '1',
x = 0,
y = 0,
shiftKey = false,
altKey = false,
ctrlKey = false,
} = options
class TestState {
state: State
return {
shiftKey,
altKey,
ctrlKey,
pointerId: id,
clientX: x,
clientY: y,
} as any
}
constructor() {
this.state = _state
this.reset()
}
export function idsAreSelected(
data: Data,
ids: string[],
strict = true
): boolean {
const selectedIds = tld.getSelectedIds(data)
return (
(strict ? selectedIds.size === ids.length : true) &&
ids.every((id) => selectedIds.has(id))
)
}
/**
* Reset the test state.
*
* ### Example
*
*```ts
* tt.reset()
*```
*/
reset(): TestState {
this.state.reset()
this.state
.send('MOUNTED')
.send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
export function hasParent(
data: Data,
childId: string,
parentId: string
): boolean {
return tld.getShape(data, childId).parentId === parentId
}
return this
}
export function getOnlySelectedShape(data: Data): Shape {
const selectedShapes = tld.getSelectedShapes(data)
return selectedShapes.length === 1 ? selectedShapes[0] : undefined
}
/**
* Reset the document state. Will remove all shapes and extra pages.
*
* ### Example
*
*```ts
* tt.resetDocumentState()
*```
*/
resetDocumentState(): TestState {
this.state.send('RESET_DOCUMENT_STATE')
return this
}
export function assertShapeType(
data: Data,
shapeId: string,
type: ShapeType
): boolean {
const shape = tld.getShape(data, shapeId)
if (shape.type !== type) {
throw new TypeError(
`expected shape ${shapeId} to be of type ${type}, found ${shape?.type} instead`
/**
* Send a message to the state.
*
* ### Example
*
*```ts
* tt.send("MOVED_TO_FRONT")
*```
*/
send(eventName: string, payload?: unknown): TestState {
this.state.send(eventName, payload)
return this
}
/**
* Create a new shape on the current page. Optionally provide an id.
*
* ### Example
*
*```ts
* tt.createShape({ type: ShapeType.Rectangle, point: [100, 100]})
* tt.createShape({ type: ShapeType.Rectangle, point: [100, 100]}, "myId")
*```
*/
createShape(props: Partial<Shape>, id = uniqueId()): TestState {
const shape = createShape(props.type, props)
getShapeUtils(shape).setProperty(shape, 'id', id)
this.data.document.pages[this.data.currentPageId].shapes[shape.id] = shape
return this
}
/**
* Get whether the provided ids are the current selected ids. If the `strict` argument is `true`, then the result will be false if the state has selected ids in addition to those provided.
*
* ### Example
*
*```ts
* tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId'])
* tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId'], true)
*```
*/
idsAreSelected(ids: string[], strict = true): boolean {
const selectedIds = tld.getSelectedIds(this.data)
return (
(strict ? selectedIds.size === ids.length : true) &&
ids.every((id) => selectedIds.has(id))
)
}
return true
}
export function assertShapeProps<T extends Shape>(
shape: T,
props: { [K in keyof Partial<T>]: T[K] }
): boolean {
for (const key in props) {
if (shape[key] !== props[key]) {
/**
* Get whether the shape with the provided id has the provided parent id.
*
* ### Example
*
*```ts
* tt.hasParent('childId', 'parentId')
*```
*/
hasParent(childId: string, parentId: string): boolean {
return tld.getShape(this.data, childId).parentId === parentId
}
/**
* Get the only selected shape. If more than one shape is selected, the test will fail.
*
* ### Example
*
*```ts
* tt.getOnlySelectedShape()
*```
*/
getOnlySelectedShape(): Shape {
const selectedShapes = tld.getSelectedShapes(this.data)
return selectedShapes.length === 1 ? selectedShapes[0] : undefined
}
/**
* Assert that a shape has the provided type.
*
* ### Example
*
*```ts
* tt.example
*```
*/
assertShapeType(shapeId: string, type: ShapeType): boolean {
const shape = tld.getShape(this.data, shapeId)
if (shape.type !== type) {
throw new TypeError(
`expected shape ${shape.id} to have property ${key}: ${props[key]}, found ${key}: ${shape[key]} instead`
`expected shape ${shapeId} to be of type ${type}, found ${shape?.type} instead`
)
}
return true
}
/**
* Assert that the provided shape has the provided props.
*
* ### Example
*
*```
* tt.assertShapeProps(myShape, { point: [0,0], style: { color: ColorStyle.Blue } } )
*```
*/
assertShapeProps<T extends Shape>(
shape: T,
props: { [K in keyof Partial<T>]: T[K] }
): boolean {
for (const key in props) {
let result: boolean
const value = props[key]
if (Array.isArray(value)) {
result = deepCompareArrays(value, shape[key] as typeof value)
} else if (typeof value === 'object') {
const target = shape[key] as typeof value
result =
target &&
Object.entries(value).every(([k, v]) => target[k] === props[key][v])
} else {
result = shape[key] === value
}
if (!result) {
throw new TypeError(
`expected shape ${shape.id} to have property ${key}: ${props[key]}, found ${key}: ${shape[key]} instead`
)
}
}
return true
}
/**
* Click a shape.
*
* ### Example
*
*```ts
* tt.clickShape("myShapeId")
*```
*/
clickShape(id: string, options: PointerOptions = {}): TestState {
this.state
.send('POINTED_SHAPE', inputs.pointerDown(TestState.point(options), id))
.send('STOPPED_POINTING', inputs.pointerUp(TestState.point(options), id))
return this
}
/**
* Start a click (but do not stop it).
*
* ### Example
*
*```ts
* tt.startClick("myShapeId")
*```
*/
startClick(id: string, options: PointerOptions = {}): TestState {
this.state.send(
'POINTED_SHAPE',
inputs.pointerDown(TestState.point(options), id)
)
return this
}
/**
* Stop a click (after starting it).
*
* ### Example
*
*```ts
* tt.stopClick("myShapeId")
*```
*/
stopClick(id: string, options: PointerOptions = {}): TestState {
this.state.send(
'STOPPED_POINTING',
inputs.pointerUp(TestState.point(options), id)
)
return this
}
/**
* Double click a shape.
*
* ### Example
*
*```ts
* tt.clickShape("myShapeId")
*```
*/
doubleClickShape(id: string, options: PointerOptions = {}): TestState {
this.state
.send(
'DOUBLE_POINTED_SHAPE',
inputs.pointerDown(TestState.point(options), id)
)
.send('STOPPED_POINTING', inputs.pointerUp(TestState.point(options), id))
return this
}
/**
* Click the canvas.
*
* ### Example
*
*```ts
* tt.clickCanvas("myShapeId")
*```
*/
clickCanvas(options: PointerOptions = {}): TestState {
this.state
.send(
'POINTED_CANVAS',
inputs.pointerDown(TestState.point(options), 'canvas')
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(TestState.point(options), 'canvas')
)
return this
}
/**
* Click the background / body of the bounding box.
*
* ### Example
*
*```ts
* tt.clickBounds()
*```
*/
clickBounds(options: PointerOptions = {}): TestState {
this.state
.send(
'POINTED_BOUNDS',
inputs.pointerDown(TestState.point(options), 'bounds')
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(TestState.point(options), 'bounds')
)
return this
}
/**
* Move the pointer to a new point, or to several points in order.
*
* ### Example
*
*```ts
* tt.movePointerTo([100, 100])
* tt.movePointerTo([100, 100], { shiftKey: true })
* tt.movePointerTo([[100, 100], [150, 150], [200, 200]])
*```
*/
movePointerTo(
to: number[] | number[][],
options: Omit<PointerOptions, 'x' | 'y'> = {}
): TestState {
if (Array.isArray(to[0])) {
;(to as number[][]).forEach(([x, y]) => {
this.state.send(
'MOVED_POINTER',
inputs.pointerMove(TestState.point({ x, y, ...options }))
)
})
} else {
const [x, y] = to as number[]
this.state.send(
'MOVED_POINTER',
inputs.pointerMove(TestState.point({ x, y, ...options }))
)
}
return this
}
/**
* Move the pointer by a delta.
*
* ### Example
*
*```ts
* tt.movePointerBy([10,10])
* tt.movePointerBy([10,10], { shiftKey: true })
*```
*/
movePointerBy(
by: number[] | number[][],
options: Omit<PointerOptions, 'x' | 'y'> = {}
): TestState {
let pt = inputs.pointer?.point || [0, 0]
if (Array.isArray(by[0])) {
;(by as number[][]).forEach((delta) => {
pt = vec.add(pt, delta)
this.state.send(
'MOVED_POINTER',
inputs.pointerMove(
TestState.point({ x: pt[0], y: pt[1], ...options })
)
)
})
} else {
pt = vec.add(pt, by as number[])
this.state.send(
'MOVED_POINTER',
inputs.pointerMove(TestState.point({ x: pt[0], y: pt[1], ...options }))
)
}
return this
}
/**
* Move pointer over a shape. Will move the pointer to the top-left corner of the shape.
*
* ###
* ```
* tt.movePointerOverShape('myShapeId', [100, 100])
* ```
*/
movePointerOverShape(
id: string,
options: Omit<PointerOptions, 'x' | 'y'> = {}
): TestState {
const shape = tld.getShape(this.state.data, id)
const [x, y] = vec.add(shape.point, [1, 1])
this.state.send(
'MOVED_OVER_SHAPE',
inputs.pointerEnter(TestState.point({ x, y, ...options }), id)
)
return this
}
/**
* Move the pointer over a group. Will move the pointer to the top-left corner of the group.
*
* ### Example
*
*```ts
* tt.movePointerOverHandle('myGroupId')
* tt.movePointerOverHandle('myGroupId', { shiftKey: true })
*```
*/
movePointerOverGroup(
id: string,
options: Omit<PointerOptions, 'x' | 'y'> = {}
): TestState {
const shape = tld.getShape(this.state.data, id)
const [x, y] = vec.add(shape.point, [1, 1])
this.state.send(
'MOVED_OVER_GROUP',
inputs.pointerEnter(TestState.point({ x, y, ...options }), id)
)
return this
}
/**
* Move the pointer over a handle. Will move the pointer to the top-left corner of the handle.
*
* ### Example
*
*```ts
* tt.movePointerOverHandle('bend')
* tt.movePointerOverHandle('bend', { shiftKey: true })
*```
*/
movePointerOverHandle(
id: string,
options: Omit<PointerOptions, 'x' | 'y'> = {}
): TestState {
const shape = tld.getShape(this.state.data, id)
const handle = shape.handles?.[id]
const [x, y] = vec.add(handle.point, [1, 1])
this.state.send(
'MOVED_OVER_HANDLE',
inputs.pointerEnter(TestState.point({ x, y, ...options }), id)
)
return this
}
/**
* Deselect all shapes.
*
* ### Example
*
*```ts
* tt.deselectAll()
*```
*/
deselectAll(): TestState {
this.state.send('DESELECTED_ALL')
return this
}
/**
* Delete the selected shapes
*
* ### Example
*
*```ts
* tt.pressDelete()
*```
*/
pressDelete(): TestState {
this.state.send('DELETED')
return this
}
/**
* Get a shape and test it.
*
* ### Example
*
*```ts
* tt.testShape("myShapeId", myShape => myShape )
*```
*/
testShape<T extends Shape>(
id: string,
fn: (shape: T, shapeUtils: ShapeUtility<T>) => boolean
): boolean {
const shape = this.getShape<T>(id)
return fn(shape, shape && getShapeUtils(shape))
}
/**
* Get a shape
*
* ### Example
*
*```ts
* tt.getShape("myShapeId")
*```
*/
getShape<T extends Shape>(id: string): T {
return tld.getShape(this.data, id) as T
}
/**
* Undo.
*
* ### Example
*
*```ts
* tt.undo()
*```
*/
undo(): TestState {
this.state.send('UNDO')
return this
}
/**
* Redo.
*
* ### Example
*
*```ts
* tt.redo()
*```
*/
redo(): TestState {
this.state.send('REDO')
return this
}
/**
* Get the state's current data.
*
* ### Example
*
*```ts
* tt.data
*```
*/
get data(): Readonly<Data> {
return this.state.data
}
/**
* Get a fake PointerEvent.
*
* ### Example
*
*```ts
* tt.point()
* tt.point({ x: 0, y: 0})
* tt.point({ x: 0, y: 0, shiftKey: true } )
*```
*/
static point(options: PointerOptions = {} as PointerOptions): PointerEvent {
const {
id = 1,
x = 0,
y = 0,
shiftKey = false,
altKey = false,
ctrlKey = false,
} = options
return {
shiftKey,
altKey,
ctrlKey,
pointerId: id,
clientX: x,
clientY: y,
} as any
}
return true
}
export default TestState

View file

@ -0,0 +1,91 @@
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')
describe('transforms shapes', () => {
it('transforms from the top edge', () => {
// TODO
null
})
it('transforms from the right edge', () => {
// TODO
null
})
it('transforms from the bottom edge', () => {
// TODO
null
})
it('transforms from the left edge', () => {
// TODO
null
})
it('transforms from the top-left corner', () => {
// TODO
null
})
it('transforms from the top-right corner', () => {
// TODO
null
})
it('transforms from the bottom-right corner', () => {
// TODO
null
})
it('transforms from the bottom-left corner', () => {
// TODO
null
})
})
describe('transforms shapes while aspect-ratio locked', () => {
// Fixed
it('transforms from the top edge while aspect-ratio locked', () => {
// TODO
null
})
it('transforms from the right edge while aspect-ratio locked', () => {
// TODO
null
})
it('transforms from the bottom edge while aspect-ratio locked', () => {
// TODO
null
})
it('transforms from the left edge while aspect-ratio locked', () => {
// TODO
null
})
it('transforms from the top-left corner while aspect-ratio locked', () => {
// TODO
null
})
it('transforms from the top-right corner while aspect-ratio locked', () => {
// TODO
null
})
it('transforms from the bottom-right corner while aspect-ratio locked', () => {
// TODO
null
})
it('transforms from the bottom-left corner while aspect-ratio locked', () => {
// TODO
null
})
})

View file

@ -0,0 +1,38 @@
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')
describe('translates shapes', () => {
it('translates a single selected shape', () => {
// TODO
null
})
it('translates multiple selected shape', () => {
// TODO
null
})
it('translates while axis-locked', () => {
// TODO
null
})
it('translates after leaving axis-locked state', () => {
// TODO
null
})
it('creates clones while translating', () => {
// TODO
null
})
it('removes clones after leaving cloning state', () => {
// TODO
null
})
})

View file

@ -71,8 +71,8 @@ const MainSVG = styled('svg', {
function ErrorFallback({ error, resetErrorBoundary }) {
React.useEffect(() => {
console.error(error)
const copy = 'Sorry, something went wrong. Clear canvas and continue?'
console.error(error)
if (window.confirm(copy)) {
state.send('CLEARED_PAGE')
resetErrorBoundary()

View file

@ -6,23 +6,24 @@ export default function Presence(): JSX.Element {
const others = useCoopSelector((s) => s.data.others)
const currentPageId = useSelector((s) => s.data.currentPageId)
if (!others) return null
return (
<>
{Object.values(others).map(({ connectionId, presence }) => {
if (presence === null) return null
if (presence.pageId !== currentPageId) return null
return (
<Cursor
key={`cursor-${connectionId}`}
color={'red'}
duration={presence.duration}
times={presence.times}
bufferedXs={presence.bufferedXs}
bufferedYs={presence.bufferedYs}
/>
)
})}
{Object.values(others)
.filter(({ presence }) => presence?.pageId === currentPageId)
.map(({ connectionId, presence }) => {
return (
<Cursor
key={`cursor-${connectionId}`}
color={'red'}
duration={presence.duration}
times={presence.times}
bufferedXs={presence.bufferedXs}
bufferedYs={presence.bufferedYs}
/>
)
})}
</>
)
}

View file

@ -8,11 +8,11 @@ import useShapeEvents from 'hooks/useShapeEvents'
import vec from 'utils/vec'
import { getShapeStyle } from 'state/shape-styles'
import useShapeDef from 'hooks/useShape'
import { ShapeUtility } from 'types'
import { BooleanArraySupportOption } from 'prettier'
interface ShapeProps {
id: string
isSelecting: boolean
isSelecting: BooleanArraySupportOption
}
function Shape({ id, isSelecting }: ShapeProps): JSX.Element {
@ -51,28 +51,27 @@ function Shape({ id, isSelecting }: ShapeProps): JSX.Element {
`
})
// From here on, not reactive—if we're here, we can trust that the
// shape in state is a shape with changes that we need to render.
const isCurrentParent = useSelector((s) => {
return s.data.currentParentId === id
})
const events = useShapeEvents(id, isCurrentParent, rGroup)
const shape = tld.getShape(state.data, id)
const shapeUtils = shape ? getShapeUtils(shape) : ({} as ShapeUtility<any>)
const {
isParent = false,
isForeignObject = false,
canStyleFill = false,
} = shapeUtils
const events = useShapeEvents(id, isParent, rGroup)
if (!shape) return null
// From here on, not reactive—if we're here, we can trust that the
// shape in state is a shape with changes that we need to render.
const { isParent, isForeignObject, canStyleFill } = getShapeUtils(shape)
return (
<StyledGroup
id={id + '-group'}
ref={rGroup}
transform={transform}
isCurrentParent={isCurrentParent}
{...events}
>
{isSelecting &&
@ -204,4 +203,24 @@ const EventSoak = styled('use', {
const StyledGroup = styled('g', {
outline: 'none',
'& > *[data-shy=true]': {
opacity: 0,
},
'&:hover': {
'& > *[data-shy=true]': {
opacity: 1,
},
},
variants: {
isCurrentParent: {
true: {
'& > *[data-shy=true]': {
opacity: 1,
},
},
},
},
})

View file

@ -145,43 +145,39 @@ interface GroupShape extends BaseShape {
size: number[]
}
// type DeepPartial<T> = {
// [P in keyof T]?: DeepPartial<T[P]>
// }
type ShapeProps<T extends Shape> = {
[P in keyof T]?: P extends 'style' ? Partial<T[P]> : T[P]
}
type MutableShape =
| DotShape
| EllipseShape
| LineShape
| RayShape
| PolylineShape
| DrawShape
| RectangleShape
| ArrowShape
| TextShape
| GroupShape
interface Shapes {
[ShapeType.Dot]: Readonly<DotShape>
[ShapeType.Ellipse]: Readonly<EllipseShape>
[ShapeType.Line]: Readonly<LineShape>
[ShapeType.Ray]: Readonly<RayShape>
[ShapeType.Polyline]: Readonly<PolylineShape>
[ShapeType.Draw]: Readonly<DrawShape>
[ShapeType.Rectangle]: Readonly<RectangleShape>
[ShapeType.Arrow]: Readonly<ArrowShape>
[ShapeType.Text]: Readonly<TextShape>
[ShapeType.Group]: Readonly<GroupShape>
interface MutableShapes {
[ShapeType.Dot]: DotShape
[ShapeType.Ellipse]: EllipseShape
[ShapeType.Line]: LineShape
[ShapeType.Ray]: RayShape
[ShapeType.Polyline]: PolylineShape
[ShapeType.Draw]: DrawShape
[ShapeType.Rectangle]: RectangleShape
[ShapeType.Arrow]: ArrowShape
[ShapeType.Text]: TextShape
[ShapeType.Group]: GroupShape
}
type MutableShape = MutableShapes[keyof MutableShapes]
type Shapes = { [K in keyof MutableShapes]: Readonly<MutableShapes[K]> }
type Shape = Readonly<MutableShape>
type ShapeByType<T extends ShapeType> = Shapes[T]
type IsParent<T> = 'children' extends RequiredKeys<T> ? T : never
type ParentShape = {
[K in keyof MutableShapes]: IsParent<MutableShapes[K]>
}[keyof MutableShapes]
type ParentTypes = ParentShape['type'] & 'page'
enum Decoration {
Arrow = 'Arrow',
}
@ -232,6 +228,15 @@ interface PointerInfo {
altKey: boolean
}
interface KeyboardInfo {
key: string
keys: string[]
shiftKey: boolean
ctrlKey: boolean
metaKey: boolean
altKey: boolean
}
enum Edge {
Top = 'top_edge',
Right = 'right_edge',
@ -276,8 +281,6 @@ interface BoundsSnapshot extends PointSnapshot {
nh: number
}
type Difference<A, B> = A extends B ? never : A
type ShapeSpecificProps<T extends Shape> = Pick<
T,
Difference<keyof T, keyof BaseShape>
@ -561,6 +564,16 @@ interface ShapeUtility<K extends Shape> {
shouldRender(this: ShapeUtility<K>, shape: K, previous: K): boolean
}
/* -------------------------------------------------- */
/* Utilities */
/* -------------------------------------------------- */
type Difference<A, B> = A extends B ? never : A
type RequiredKeys<T> = {
[K in keyof T]-?: Record<string, unknown> extends Pick<T, K> ? never : K
}[keyof T]
@ -695,43 +708,39 @@ interface GroupShape extends BaseShape {
size: number[]
}
// type DeepPartial<T> = {
// [P in keyof T]?: DeepPartial<T[P]>
// }
type ShapeProps<T extends Shape> = {
[P in keyof T]?: P extends 'style' ? Partial<T[P]> : T[P]
}
type MutableShape =
| DotShape
| EllipseShape
| LineShape
| RayShape
| PolylineShape
| DrawShape
| RectangleShape
| ArrowShape
| TextShape
| GroupShape
interface Shapes {
[ShapeType.Dot]: Readonly<DotShape>
[ShapeType.Ellipse]: Readonly<EllipseShape>
[ShapeType.Line]: Readonly<LineShape>
[ShapeType.Ray]: Readonly<RayShape>
[ShapeType.Polyline]: Readonly<PolylineShape>
[ShapeType.Draw]: Readonly<DrawShape>
[ShapeType.Rectangle]: Readonly<RectangleShape>
[ShapeType.Arrow]: Readonly<ArrowShape>
[ShapeType.Text]: Readonly<TextShape>
[ShapeType.Group]: Readonly<GroupShape>
interface MutableShapes {
[ShapeType.Dot]: DotShape
[ShapeType.Ellipse]: EllipseShape
[ShapeType.Line]: LineShape
[ShapeType.Ray]: RayShape
[ShapeType.Polyline]: PolylineShape
[ShapeType.Draw]: DrawShape
[ShapeType.Rectangle]: RectangleShape
[ShapeType.Arrow]: ArrowShape
[ShapeType.Text]: TextShape
[ShapeType.Group]: GroupShape
}
type MutableShape = MutableShapes[keyof MutableShapes]
type Shapes = { [K in keyof MutableShapes]: Readonly<MutableShapes[K]> }
type Shape = Readonly<MutableShape>
type ShapeByType<T extends ShapeType> = Shapes[T]
type IsParent<T> = 'children' extends RequiredKeys<T> ? T : never
type ParentShape = {
[K in keyof MutableShapes]: IsParent<MutableShapes[K]>
}[keyof MutableShapes]
type ParentTypes = ParentShape['type'] & 'page'
enum Decoration {
Arrow = 'Arrow',
}
@ -782,6 +791,15 @@ interface PointerInfo {
altKey: boolean
}
interface KeyboardInfo {
key: string
keys: string[]
shiftKey: boolean
ctrlKey: boolean
metaKey: boolean
altKey: boolean
}
enum Edge {
Top = 'top_edge',
Right = 'right_edge',
@ -826,8 +844,6 @@ interface BoundsSnapshot extends PointSnapshot {
nh: number
}
type Difference<A, B> = A extends B ? never : A
type ShapeSpecificProps<T extends Shape> = Pick<
T,
Difference<keyof T, keyof BaseShape>
@ -1111,6 +1127,16 @@ interface ShapeUtility<K extends Shape> {
shouldRender(this: ShapeUtility<K>, shape: K, previous: K): boolean
}
/* -------------------------------------------------- */
/* Utilities */
/* -------------------------------------------------- */
type Difference<A, B> = A extends B ? never : A
type RequiredKeys<T> = {
[K in keyof T]-?: Record<string, unknown> extends Pick<T, K> ? never : K
}[keyof T]

View file

@ -8,6 +8,7 @@ export default function useLoadOnMount(roomId?: string) {
fonts.load('12px Verveine Regular', 'Fonts are loaded!').then(() => {
state.send('MOUNTED')
if (roomId !== undefined) {
state.send('RT_LOADED_ROOM', { id: roomId })
}

View file

@ -6,14 +6,15 @@ import Vec from 'utils/vec'
export default function useShapeEvents(
id: string,
isParent: boolean,
isCurrentParent: boolean,
rGroup: MutableRefObject<SVGElement>
) {
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (isParent) return
if (isCurrentParent) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
rGroup.current.setPointerCapture(e.pointerId)
const info = inputs.pointerDown(e, id)
@ -28,30 +29,30 @@ export default function useShapeEvents(
state.send('RIGHT_POINTED', info)
}
},
[id]
[id, isCurrentParent]
)
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (isCurrentParent) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
rGroup.current.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', inputs.pointerUp(e, id))
},
[id]
[id, isCurrentParent]
)
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (isCurrentParent) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
if (isParent) {
state.send('HOVERED_GROUP', inputs.pointerEnter(e, id))
} else {
state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id))
}
state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id))
},
[id]
[id, isCurrentParent]
)
const handlePointerMove = useCallback(
@ -72,26 +73,22 @@ export default function useShapeEvents(
return
}
if (isParent) {
state.send('MOVED_OVER_GROUP', inputs.pointerEnter(e, id))
} else {
state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id))
}
if (isCurrentParent) return
state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id))
},
[id]
[id, isCurrentParent]
)
const handlePointerLeave = useCallback(
(e: React.PointerEvent) => {
if (isCurrentParent) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
if (isParent) {
state.send('UNHOVERED_GROUP', { target: id })
} else {
state.send('UNHOVERED_SHAPE', { target: id })
}
state.send('UNHOVERED_SHAPE', { target: id })
},
[id]
[id, isCurrentParent]
)
const handleTouchStart = useCallback((e: React.TouchEvent) => {

View file

@ -12,7 +12,7 @@
"start": "next start",
"test-all": "yarn lint && yarn type-check && yarn test",
"test:update": "jest --updateSnapshot",
"test:watch": "jest --watchAll --verbose=false --silent=false",
"test:watch": "jest --watchAll",
"test": "jest --watchAll=false",
"type-check": "tsc --pretty --noEmit"
},

View file

@ -1,5 +1,5 @@
import CodeShape from './index'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { ArrowShape, Decoration, ShapeProps, ShapeType } from 'types'
import { defaultStyle } from 'state/shape-styles'
import { getShapeUtils } from 'state/shape-utils'

View file

@ -5,7 +5,7 @@ import {
TextCodeControl,
VectorCodeControl,
} from 'types'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
export const controls: Record<string, any> = {}

View file

@ -1,5 +1,5 @@
import CodeShape from './index'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { DotShape, ShapeProps, ShapeType } from 'types'
import { defaultStyle } from 'state/shape-styles'

View file

@ -1,5 +1,5 @@
import CodeShape from './index'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { DrawShape, ShapeProps, ShapeType } from 'types'
import { defaultStyle } from 'state/shape-styles'

View file

@ -1,5 +1,5 @@
import CodeShape from './index'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { EllipseShape, ShapeProps, ShapeType } from 'types'
import { defaultStyle } from 'state/shape-styles'

View file

@ -1,5 +1,5 @@
import CodeShape from './index'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { LineShape, ShapeProps, ShapeType } from 'types'
import { defaultStyle } from 'state/shape-styles'

View file

@ -1,5 +1,5 @@
import CodeShape from './index'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { PolylineShape, ShapeProps, ShapeType } from 'types'
import { defaultStyle } from 'state/shape-styles'

View file

@ -1,5 +1,5 @@
import CodeShape from './index'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { RayShape, ShapeProps, ShapeType } from 'types'
import { defaultStyle } from 'state/shape-styles'

View file

@ -1,5 +1,5 @@
import CodeShape from './index'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { RectangleShape, ShapeProps, ShapeType } from 'types'
import { defaultStyle } from 'state/shape-styles'
import { getShapeUtils } from 'state/shape-utils'

View file

@ -1,5 +1,5 @@
import CodeShape from './index'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { TextShape, ShapeProps, ShapeType } from 'types'
import { defaultStyle } from 'state/shape-styles'
import { getShapeUtils } from 'state/shape-utils'

View file

@ -16,10 +16,12 @@ export default function changePage(data: Data, toPageId: string): void {
storage.savePage(data, data.document.id, fromPageId)
storage.loadPage(data, data.document.id, toPageId)
data.currentPageId = toPageId
data.currentParentId = toPageId
},
undo(data) {
storage.loadPage(data, data.document.id, fromPageId)
data.currentPageId = fromPageId
data.currentParentId = fromPageId
},
})
)

View file

@ -1,7 +1,7 @@
import Command from './command'
import history from '../history'
import { Data, Page, PageState } from 'types'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import storage from 'state/storage'
export default function createPage(data: Data, goToPage = true): void {

View file

@ -3,7 +3,7 @@ import history from '../history'
import { Data } from 'types'
import { deepClone } from 'utils'
import tld from 'utils/tld'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import vec from 'utils/vec'
export default function duplicateCommand(data: Data): void {

View file

@ -57,6 +57,7 @@ export default function moveCommand(data: Data, type: MoveType): void {
.sort((a, b) => b.childIndex - a.childIndex)
.forEach((shape) => moveForward(shape, siblings, visited))
}
break
}
case MoveType.Backward: {

View file

@ -3,7 +3,7 @@ import history from '../history'
import { Data, Shape } from 'types'
import { getCommonBounds, setToArray } from 'utils'
import tld from 'utils/tld'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import vec from 'utils/vec'
import { getShapeUtils } from 'state/shape-utils'
import state from 'state/state'

View file

@ -6,7 +6,7 @@ import {
MyPresenceCallback,
OthersEventCallback,
} from '@liveblocks/client/lib/cjs/types'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
class CoopClient {
id = uniqueId()

View file

@ -160,6 +160,7 @@ class Inputs {
}
this.pointer = info
return info
}

View file

@ -2,7 +2,7 @@ import { Data, GroupShape, Shape, ShapeType } from 'types'
import vec from 'utils/vec'
import BaseSession from './base-session'
import commands from 'state/commands'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { getShapeUtils } from 'state/shape-utils'
import tld from 'utils/tld'

View file

@ -1,12 +1,16 @@
import { getArcLength, uniqueId } from 'utils'
import vec from 'utils/vec'
import {
getArcLength,
uniqueId,
getSvgPathFromStroke,
rng,
getBoundsFromPoints,
translateBounds,
pointInBounds,
pointInCircle,
circleFromThreePoints,
isAngleBetween,
getPerfectDashProps,
} from 'utils'
import {
ArrowShape,
@ -15,7 +19,6 @@ import {
ShapeHandle,
ShapeType,
} from 'types'
import { circleFromThreePoints, isAngleBetween } from 'utils'
import {
intersectArcBounds,
intersectLineSegmentBounds,
@ -24,7 +27,6 @@ import { defaultStyle, getShapeStyle } from 'state/shape-styles'
import getStroke from 'perfect-freehand'
import React from 'react'
import { registerShapeUtils } from './register'
import { getPerfectDashProps } from 'utils/dashes'
const pathCache = new WeakMap<ArrowShape, string>([])
@ -37,55 +39,65 @@ function getCtp(shape: ArrowShape) {
const arrow = registerShapeUtils<ArrowShape>({
boundsCache: new WeakMap([]),
defaultProps: {
id: uniqueId(),
type: ShapeType.Arrow,
isGenerated: false,
name: 'Arrow',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
bend: 0,
handles: {
start: {
id: 'start',
index: 0,
point: [0, 0],
},
end: {
id: 'end',
index: 1,
point: [1, 1],
},
bend: {
id: 'bend',
index: 2,
point: [0.5, 0.5],
},
},
decorations: {
start: null,
middle: null,
end: Decoration.Arrow,
},
style: {
...defaultStyle,
isFilled: false,
},
},
create(props) {
const {
point = [0, 0],
handles = {
start: {
id: 'start',
index: 0,
point: [0, 0],
},
end: {
id: 'end',
index: 1,
point: [1, 1],
},
bend: {
id: 'bend',
index: 2,
point: [0.5, 0.5],
},
},
} = props
return {
id: uniqueId(),
type: ShapeType.Arrow,
isGenerated: false,
name: 'Arrow',
parentId: 'page1',
childIndex: 0,
point,
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
bend: 0,
handles,
decorations: {
start: null,
middle: null,
end: Decoration.Arrow,
},
const shape = {
...this.defaultProps,
...props,
decorations: {
...this.defaultProps.decorations,
...props.decorations,
},
style: {
...defaultStyle,
...this.defaultProps.style,
...props.style,
isFilled: false,
},
}
// shape.handles.bend.point = getBendPoint(shape)
return shape
},
shouldRender(shape, prev) {

View file

@ -1,4 +1,4 @@
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import { DotShape, ShapeType } from 'types'
import { intersectCircleBounds } from 'utils/intersections'
import { boundsContained, translateBounds } from 'utils'
@ -8,27 +8,19 @@ import { registerShapeUtils } from './register'
const dot = registerShapeUtils<DotShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uniqueId(),
type: ShapeType.Dot,
isGenerated: false,
name: 'Dot',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
...props,
style: {
...defaultStyle,
...props.style,
isFilled: false,
},
}
defaultProps: {
id: uniqueId(),
type: ShapeType.Dot,
isGenerated: false,
name: 'Dot',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
},
render({ id }) {

View file

@ -1,4 +1,4 @@
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import vec from 'utils/vec'
import { DashStyle, DrawShape, ShapeStyles, ShapeType } from 'types'
import { intersectPolylineBounds } from 'utils/intersections'
@ -22,27 +22,20 @@ const draw = registerShapeUtils<DrawShape>({
canStyleFill: true,
create(props) {
return {
id: uniqueId(),
type: ShapeType.Draw,
isGenerated: false,
name: 'Draw',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
points: [],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
...props,
style: {
...defaultStyle,
...props.style,
},
}
defaultProps: {
id: uniqueId(),
type: ShapeType.Draw,
isGenerated: false,
name: 'Draw',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
points: [],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
},
shouldRender(shape, prev) {

View file

@ -1,4 +1,3 @@
import { getPerfectDashProps } from 'utils/dashes'
import vec from 'utils/vec'
import { DashStyle, EllipseShape, ShapeType } from 'types'
import { getShapeUtils } from './index'
@ -11,6 +10,7 @@ import {
pointInEllipse,
boundsContained,
getRotatedEllipseBounds,
getPerfectDashProps,
} from 'utils'
import { defaultStyle, getShapeStyle } from 'state/shape-styles'
import getStroke from 'perfect-freehand'
@ -21,25 +21,21 @@ const pathCache = new WeakMap<EllipseShape, string>([])
const ellipse = registerShapeUtils<EllipseShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uniqueId(),
type: ShapeType.Ellipse,
isGenerated: false,
name: 'Ellipse',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
radiusX: 1,
radiusY: 1,
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
...props,
}
defaultProps: {
id: uniqueId(),
type: ShapeType.Ellipse,
isGenerated: false,
name: 'Ellipse',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
radiusX: 1,
radiusY: 1,
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
},
shouldRender(shape, prev) {

View file

@ -1,4 +1,4 @@
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import vec from 'utils/vec'
import { GroupShape, ShapeType } from 'types'
import { getShapeUtils } from './index'
@ -12,26 +12,21 @@ const group = registerShapeUtils<GroupShape>({
isShy: true,
isParent: true,
create(props) {
return {
id: uniqueId(),
type: ShapeType.Group,
isGenerated: false,
name: 'Group',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
size: [1, 1],
radius: 2,
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
children: [],
...props,
}
defaultProps: {
id: uniqueId(),
type: ShapeType.Group,
isGenerated: false,
name: 'Group',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
size: [1, 1],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
children: [],
},
render(shape) {

View file

@ -1,4 +1,4 @@
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import vec from 'utils/vec'
import { LineShape, ShapeType } from 'types'
import { intersectCircleBounds } from 'utils/intersections'
@ -10,28 +10,20 @@ import { registerShapeUtils } from './register'
const line = registerShapeUtils<LineShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uniqueId(),
type: ShapeType.Line,
isGenerated: false,
name: 'Line',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
direction: [0, 0],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
...props,
style: {
...defaultStyle,
...props.style,
isFilled: false,
},
}
defaultProps: {
id: uniqueId(),
type: ShapeType.Line,
isGenerated: false,
name: 'Line',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
direction: [0, 0],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
},
shouldRender(shape, prev) {

View file

@ -1,4 +1,4 @@
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import vec from 'utils/vec'
import { PolylineShape, ShapeType } from 'types'
import { intersectPolylineBounds } from 'utils/intersections'
@ -13,24 +13,20 @@ import { registerShapeUtils } from './register'
const polyline = registerShapeUtils<PolylineShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uniqueId(),
type: ShapeType.Polyline,
isGenerated: false,
name: 'Polyline',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
points: [[0, 0]],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
...props,
}
defaultProps: {
id: uniqueId(),
type: ShapeType.Polyline,
isGenerated: false,
name: 'Polyline',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
points: [[0, 0]],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
},
shouldRender(shape, prev) {

View file

@ -1,4 +1,4 @@
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import vec from 'utils/vec'
import { RayShape, ShapeType } from 'types'
import { intersectCircleBounds } from 'utils/intersections'
@ -10,28 +10,20 @@ import { registerShapeUtils } from './register'
const ray = registerShapeUtils<RayShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uniqueId(),
type: ShapeType.Ray,
isGenerated: false,
name: 'Ray',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
direction: [0, 1],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
...props,
style: {
...defaultStyle,
...props.style,
isFilled: false,
},
}
defaultProps: {
id: uniqueId(),
type: ShapeType.Ray,
isGenerated: false,
name: 'Ray',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
direction: [0, 1],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
},
shouldRender(shape, prev) {

View file

@ -1,36 +1,32 @@
import { uniqueId } from 'utils'
import { uniqueId, getPerfectDashProps } from 'utils/utils'
import vec from 'utils/vec'
import { DashStyle, RectangleShape, ShapeType } from 'types'
import { getSvgPathFromStroke, translateBounds, rng, shuffleArr } from 'utils'
import { defaultStyle, getShapeStyle } from 'state/shape-styles'
import getStroke from 'perfect-freehand'
import { registerShapeUtils } from './register'
import { getPerfectDashProps } from 'utils/dashes'
const pathCache = new WeakMap<number[], string>([])
const rectangle = registerShapeUtils<RectangleShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uniqueId(),
defaultProps: {
id: uniqueId(),
type: ShapeType.Rectangle,
isGenerated: false,
name: 'Rectangle',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
size: [1, 1],
radius: 2,
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
...props,
}
type: ShapeType.Rectangle,
isGenerated: false,
name: 'Rectangle',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
size: [1, 1],
radius: 2,
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
},
shouldRender(shape, prev) {

View file

@ -1,6 +1,6 @@
import { Shape, ShapeUtility } from 'types'
import vec from 'utils/vec'
import React from 'react'
import {
vec,
pointInBounds,
getBoundsCenter,
getBoundsFromPoints,
@ -8,8 +8,7 @@ import {
boundsCollidePolygon,
boundsContainPolygon,
} from 'utils'
import { uniqueId } from 'utils'
import React from 'react'
import { Shape, ShapeUtility } from 'types'
function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
return {
@ -22,19 +21,19 @@ function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
isParent: false,
isForeignObject: false,
defaultProps: {} as T,
create(props) {
return {
id: uniqueId(),
isGenerated: false,
point: [0, 0],
name: 'Shape',
parentId: 'page1',
childIndex: 0,
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
...this.defaultProps,
...props,
style: {
...this.defaultProps.style,
...props.style,
isFilled: this.canStyleFill
? props.style?.isFilled || this.defaultProps.style.isFilled
: false,
},
} as T
},

View file

@ -1,4 +1,4 @@
import { uniqueId, isMobile } from 'utils'
import { uniqueId, isMobile } from 'utils/utils'
import vec from 'utils/vec'
import { TextShape, ShapeType } from 'types'
import {
@ -47,27 +47,23 @@ const text = registerShapeUtils<TextShape>({
isForeignObject: true,
canChangeAspectRatio: false,
canEdit: true,
boundsCache: new WeakMap([]),
create(props) {
return {
id: uniqueId(),
type: ShapeType.Text,
isGenerated: false,
name: 'Text',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
text: '',
scale: 1,
...props,
}
defaultProps: {
id: uniqueId(),
type: ShapeType.Text,
isGenerated: false,
name: 'Text',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
text: '',
scale: 1,
},
shouldRender(shape, prev) {

View file

@ -1,15 +1,16 @@
import { createSelectorHook, createState } from '@state-designer/react'
import { updateFromCode } from './code/generate'
import { createShape, getShapeUtils } from './shape-utils'
import vec from 'utils/vec'
import * as Sessions from './sessions'
import inputs from './inputs'
import history from './history'
import storage from './storage'
import session from './session'
import clipboard from './clipboard'
import * as Sessions from './sessions'
import coopClient from './coop/client-liveblocks'
import commands from './commands'
import {
vec,
getCommonBounds,
rotateBounds,
getBoundsCenter,
@ -18,7 +19,7 @@ import {
pointInBounds,
uniqueId,
} from 'utils'
import tld from 'utils/tld'
import tld from '../utils/tld'
import {
Data,
PointerInfo,
@ -36,7 +37,6 @@ import {
SizeStyle,
ColorStyle,
} from 'types'
import session from './session'
const initialData: Data = {
isReadOnly: false,
@ -288,10 +288,15 @@ const state = createState({
unless: 'isInSession',
do: ['loadDocumentFromJson', 'resetHistory'],
},
DESELECTED_ALL: {
unless: 'isInSession',
do: 'deselectAll',
to: 'selecting',
},
SELECTED_ALL: {
unless: 'isInSession',
to: 'selecting',
do: 'selectAll',
to: 'selecting',
},
CHANGED_PAGE: {
unless: 'isInSession',
@ -398,8 +403,15 @@ const state = createState({
notPointing: {
onEnter: 'clearPointedId',
on: {
CANCELLED: 'clearSelectedIds',
POINTED_CANVAS: { to: 'brushSelecting' },
CANCELLED: {
if: 'hasCurrentParentShape',
do: ['selectCurrentParentId', 'raiseCurrentParentId'],
else: 'clearSelectedIds',
},
POINTED_CANVAS: {
to: 'brushSelecting',
do: 'setCurrentParentIdToPage',
},
POINTED_BOUNDS: [
{
if: 'isPressingMetaKey',
@ -477,7 +489,7 @@ const state = createState({
{
unless: 'isPressingShiftKey',
do: [
'setDrilledPointedId',
'setCurrentParentId',
'clearSelectedIds',
'pushPointedIdToSelectedIds',
],
@ -1120,6 +1132,9 @@ const state = createState({
hasMultipleSelection(data) {
return tld.getSelectedIds(data).size > 1
},
hasCurrentParentShape(data) {
return data.currentParentId !== data.currentPageId
},
isToolLocked(data) {
return data.settings.isToolLocked
},
@ -1180,6 +1195,14 @@ const state = createState({
data.currentPageId = newId
data.pointedId = null
data.hoveredId = null
data.editingId = null
data.currentPageId = 'page1'
data.currentParentId = 'page1'
data.currentCodeFileId = 'file0'
data.codeControls = {}
data.document.pages = {
[newId]: {
id: newId,
@ -1234,6 +1257,7 @@ const state = createState({
createShape(data, payload, type: ShapeType) {
const shape = createShape(type, {
id: uniqueId(),
parentId: data.currentPageId,
point: vec.round(tld.screenToWorld(payload.point, data)),
style: deepClone(data.currentStyle),
@ -1500,11 +1524,12 @@ const state = createState({
)
)
},
clearInputs() {
inputs.clear()
},
deselectAll(data) {
tld.getSelectedIds(data).clear()
},
selectAll(data) {
const selectedIds = tld.getSelectedIds(data)
const page = tld.getPage(data)
@ -1525,10 +1550,24 @@ const state = createState({
data.pointedId = getPointedId(data, payload.target)
data.currentParentId = getParentId(data, data.pointedId)
},
setDrilledPointedId(data, payload: PointerInfo) {
setCurrentParentId(data, payload: PointerInfo) {
data.pointedId = getDrilledPointedId(data, payload.target)
data.currentParentId = getParentId(data, data.pointedId)
},
raiseCurrentParentId(data) {
const currentParent = tld.getShape(data, data.currentParentId)
data.currentParentId =
currentParent.parentId === data.currentPageId
? data.currentPageId
: currentParent.parentId
},
setCurrentParentIdToPage(data) {
data.currentParentId = data.currentPageId
},
selectCurrentParentId(data) {
tld.setSelectedIds(data, [data.currentParentId])
},
clearCurrentParentId(data) {
data.currentParentId = data.currentPageId
data.pointedId = undefined

View file

@ -1,7 +1,7 @@
import { Data, PageState, TLDocument } from 'types'
import { decompress, compress, setToArray } from 'utils'
import state from './state'
import { uniqueId } from 'utils'
import { uniqueId } from 'utils/utils'
import * as idb from 'idb-keyval'
const CURRENT_VERSION = 'code_slate_0.0.8'

View file

@ -458,6 +458,9 @@ export type PropsOfType<T extends Record<string, unknown>> = {
export type Mutable<T extends Shape> = { -readonly [K in keyof T]: T[K] }
export interface ShapeUtility<K extends Shape> {
// Default properties when creating a new shape
defaultProps: K
// A cache for the computed bounds of this kind of shape.
boundsCache: WeakMap<K, Bounds>
@ -483,7 +486,7 @@ export interface ShapeUtility<K extends Shape> {
isShy: boolean
// Create a new shape.
create(props: Partial<K>): K
create(this: ShapeUtility<K>, props: Partial<K>): K
// Update a shape's styles
applyStyles(

View file

@ -1,41 +0,0 @@
/**
* Get balanced dash-strokearray and dash-strokeoffset properties for a path of a given length.
* @param length The length of the path.
* @param strokeWidth The shape's stroke-width property.
* @param style The stroke's style: "dashed" or "dotted" (default "dashed").
* @param snap An interval for dashes (e.g. 4 will produce arrays with 4, 8, 16, etc dashes).
*/
export function getPerfectDashProps(
length: number,
strokeWidth: number,
style: 'dashed' | 'dotted' = 'dashed',
snap = 1
): {
strokeDasharray: string
strokeDashoffset: string
} {
let dashLength: number
let strokeDashoffset: string
let ratio: number
if (style === 'dashed') {
dashLength = strokeWidth * 2
ratio = 1
strokeDashoffset = (dashLength / 2).toString()
} else {
dashLength = strokeWidth / 100
ratio = 100
strokeDashoffset = '0'
}
let dashes = Math.floor(length / dashLength / (2 * ratio))
dashes -= dashes % snap
if (dashes === 0) dashes = 1
const gapLength = (length - dashes * dashLength) / dashes
return {
strokeDasharray: [dashLength, gapLength].join(' '),
strokeDashoffset,
}
}

View file

@ -1 +1,5 @@
import vec from './vec'
import svg from './svg'
export * from './utils'
export { vec, svg }

View file

@ -1,4 +1,4 @@
import { clamp, deepClone, getCommonBounds, setToArray } from './utils'
import { clamp, deepClone, getCommonBounds, setToArray } from 'utils'
import { getShapeUtils } from 'state/shape-utils'
import vec from './vec'
import {
@ -15,7 +15,7 @@ import {
} from 'types'
import { AssertionError } from 'assert'
export default class ProjectUtils {
export default class StateUtils {
static getCameraZoom(zoom: number): number {
return clamp(zoom, 0.1, 5)
}

File diff suppressed because it is too large Load diff